diff --git a/.env b/.env index 0e3a9d5610..734b9df42a 100644 --- a/.env +++ b/.env @@ -48,6 +48,26 @@ POSTGRES_DB=prowler_db # POSTGRES_REPLICA_MAX_ATTEMPTS=3 # POSTGRES_REPLICA_RETRY_BASE_DELAY=0.5 +# Neo4j auth +NEO4J_HOST=neo4j +NEO4J_PORT=7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=neo4j_password +# Neo4j settings +NEO4J_DBMS_MAX__DATABASES=1000 +NEO4J_SERVER_MEMORY_PAGECACHE_SIZE=1G +NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE=1G +NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE=1G +NEO4J_POC_EXPORT_FILE_ENABLED=true +NEO4J_APOC_IMPORT_FILE_ENABLED=true +NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true +NEO4J_PLUGINS=["apoc"] +NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST=apoc.* +NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=apoc.* +NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687 +# Neo4j Prowler settings +ATTACK_PATHS_FINDINGS_BATCH_SIZE=1000 + # Celery-Prowler task settings TASK_RETRY_DELAY_SECONDS=0.1 TASK_RETRY_ATTEMPTS=5 @@ -117,7 +137,6 @@ SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} - #### Prowler release version #### NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.16.0 diff --git a/.github/actions/setup-python-poetry/action.yml b/.github/actions/setup-python-poetry/action.yml index 790f3ef0e6..cb17c050a2 100644 --- a/.github/actions/setup-python-poetry/action.yml +++ b/.github/actions/setup-python-poetry/action.yml @@ -29,7 +29,7 @@ runs: run: | BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" echo "Using branch: $BRANCH_NAME" - sed -i "s|@master|@$BRANCH_NAME|g" pyproject.toml + sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml - name: Install poetry shell: bash diff --git a/.github/labeler.yml b/.github/labeler.yml index fa2c7981f9..7e87c1a6a3 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -46,12 +46,17 @@ provider/oci: - changed-files: - any-glob-to-any-file: "prowler/providers/oraclecloud/**" - any-glob-to-any-file: "tests/providers/oraclecloud/**" - + provider/alibabacloud: - changed-files: - any-glob-to-any-file: "prowler/providers/alibabacloud/**" - any-glob-to-any-file: "tests/providers/alibabacloud/**" +provider/cloudflare: + - changed-files: + - any-glob-to-any-file: "prowler/providers/cloudflare/**" + - any-glob-to-any-file: "tests/providers/cloudflare/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" @@ -67,15 +72,21 @@ mutelist: - any-glob-to-any-file: "prowler/providers/azure/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/gcp/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/kubernetes/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/m365/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/mongodbatlas/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/oraclecloud/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**" - any-glob-to-any-file: "tests/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/gcp/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/kubernetes/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/m365/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/mongodbatlas/lib/mutelist/**" - - any-glob-to-any-file: "tests/providers/oci/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/oraclecloud/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**" integration/s3: - changed-files: diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index 285262ce7f..e8a8efc879 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -61,7 +61,8 @@ jobs: - name: Safety if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check + run: poetry run safety check --ignore 79023,79027 + # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - name: Vulture if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml index 4a1f8e502a..cc22767552 100644 --- a/.github/workflows/pr-check-changelog.yml +++ b/.github/workflows/pr-check-changelog.yml @@ -42,14 +42,16 @@ jobs: ui/** prowler/** mcp_server/** + poetry.lock + pyproject.toml - name: Check for folder changes and changelog presence id: check-folders run: | missing_changelogs="" - # Check api folder if [[ "${{ steps.changed-files.outputs.any_changed }}" == "true" ]]; then + # Check monitored folders for folder in $MONITORED_FOLDERS; do # Get files changed in this folder changed_in_folder=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep "^${folder}/" || true) @@ -64,6 +66,22 @@ jobs: fi fi done + + # Check root-level dependency files (poetry.lock, pyproject.toml) + # These are associated with the prowler folder changelog + root_deps_changed=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep -E "^(poetry\.lock|pyproject\.toml)$" || true) + if [ -n "$root_deps_changed" ]; then + echo "Detected changes in root dependency files: $root_deps_changed" + # Check if prowler/CHANGELOG.md was already updated (might have been caught above) + prowler_changelog_updated=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep "^prowler/CHANGELOG.md$" || true) + if [ -z "$prowler_changelog_updated" ]; then + # Only add if prowler wasn't already flagged + if ! echo "$missing_changelogs" | grep -q "prowler"; then + echo "No changelog update found for root dependency changes" + missing_changelogs="${missing_changelogs}- \`prowler\` (root dependency files changed)"$'\n' + fi + fi + fi fi { diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index 054bd69f6e..6dcc2f73af 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -116,7 +116,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: '20.x' + node-version: '24.13.0' - name: Setup pnpm uses: pnpm/action-setup@v4 with: @@ -125,16 +125,20 @@ jobs: - name: Get pnpm store directory shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - name: Setup pnpm cache + - name: Setup pnpm and Next.js cache uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('ui/pnpm-lock.yaml') }} + path: | + ${{ env.STORE_PATH }} + ./ui/node_modules + ./ui/.next/cache + key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }} restore-keys: | - ${{ runner.os }}-pnpm-store- + ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}- + ${{ runner.os }}-pnpm-nextjs- - name: Install UI dependencies working-directory: ./ui - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --prefer-offline - name: Build UI application working-directory: ./ui run: pnpm run build diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index e657e56960..e7d1834f91 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -16,7 +16,7 @@ concurrency: env: UI_WORKING_DIR: ./ui - NODE_VERSION: '20.x' + NODE_VERSION: '24.13.0' jobs: ui-tests: @@ -62,18 +62,22 @@ jobs: shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - name: Setup pnpm cache + - name: Setup pnpm and Next.js cache if: steps.check-changes.outputs.any_changed == 'true' uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('ui/pnpm-lock.yaml') }} + path: | + ${{ env.STORE_PATH }} + ${{ env.UI_WORKING_DIR }}/node_modules + ${{ env.UI_WORKING_DIR }}/.next/cache + key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }} restore-keys: | - ${{ runner.os }}-pnpm-store- + ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}- + ${{ runner.os }}-pnpm-nextjs- - name: Install dependencies if: steps.check-changes.outputs.any_changed == 'true' - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --prefer-offline - name: Run healthcheck if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 24f8f0f211..0a4164bc43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: "--remove-unused-variable", ] - - repo: https://github.com/timothycrosley/isort + - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort @@ -120,7 +120,8 @@ repos: name: safety description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" # TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X - entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745' + # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 + entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027' language: system - id: vulture diff --git a/AGENTS.md b/AGENTS.md index 62ebb587f1..e203deb845 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Use these skills for detailed patterns on-demand: | `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) | | `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) | | `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) | +| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) | | `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) | | `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) | | `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) | @@ -38,7 +39,9 @@ Use these skills for detailed patterns on-demand: | `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) | | `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) | | `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) | +| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) | | `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) | +| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) | | `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) | | `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) | | `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) | @@ -49,13 +52,20 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Action | Skill | |--------|-------| +| Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding DRF pagination or permissions | `django-drf` | | Adding new providers | `prowler-provider` | | Adding services to existing providers | `prowler-provider` | | After creating/modifying a skill | `skill-sync` | | App Router / Server Actions | `nextjs-15` | | Building AI chat features | `ai-sdk-5` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | | Create a PR with gh pr create | `prowler-pr` | +| Creating API endpoints | `jsonapi` | +| Creating ViewSets, serializers, or filters in api/ | `django-drf` | | Creating Zod schemas | `zod-4` | +| Creating a git commit | `prowler-commit` | | Creating new checks | `prowler-sdk-check` | | Creating new skills | `skill-creator` | | Creating/modifying Prowler UI components | `prowler-ui` | @@ -64,13 +74,16 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Debug why a GitHub Actions job is failing | `prowler-ci` | | Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` | | General Prowler development questions | `prowler` | -| Generic DRF patterns | `django-drf` | +| Implementing JSON:API endpoints | `django-drf` | | Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` | | Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` | | Mapping checks to compliance controls | `prowler-compliance` | | Mocking AWS with moto in tests | `prowler-test-sdk` | +| Modifying API responses | `jsonapi` | | Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` | | Review PR requirements: template, title conventions, changelog gate | `prowler-pr` | +| Review changelog format and conventions | `prowler-changelog` | +| Reviewing JSON:API compliance | `jsonapi` | | Reviewing compliance framework PRs | `prowler-compliance-review` | | Testing RLS tenant isolation | `prowler-test-api` | | Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` | @@ -78,6 +91,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Understand PR title conventional-commit validation | `prowler-ci` | | Understand changelog gate and no-changelog label behavior | `prowler-ci` | | Understand review ownership with CODEOWNERS | `prowler-pr` | +| Update CHANGELOG.md in any component | `prowler-changelog` | | Updating existing checks and metadata | `prowler-sdk-check` | | Using Zustand stores | `zustand-5` | | Working on MCP server tools | `prowler-mcp` | diff --git a/README.md b/README.md index bdf08ee20a..5bb96eaca5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,23 @@ prowler dashboard ``` ![Prowler Dashboard](docs/images/products/dashboard.png) + +## Attack Paths + +Attack Paths automatically extends every completed AWS scan with a Neo4j graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan and therefore requires: + +- An accessible Neo4j instance (the Docker Compose files already ships a `neo4j` service). +- The following environment variables so Django and Celery can connect: + + | Variable | Description | Default | + | --- | --- | --- | + | `NEO4J_HOST` | Hostname used by the API containers. | `neo4j` | + | `NEO4J_PORT` | Bolt port exposed by Neo4j. | `7687` | + | `NEO4J_USER` / `NEO4J_PASSWORD` | Credentials with rights to create per-tenant databases. | `neo4j` / `neo4j_password` | + +Every AWS provider scan will enqueue an Attack Paths ingestion job automatically. Other cloud providers will be added in future iterations. + + # Prowler at a Glance > [!Tip] > For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com). @@ -87,16 +104,17 @@ prowler dashboard | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 584 | 85 | 40 | 17 | Official | UI, API, CLI | -| GCP | 89 | 17 | 14 | 5 | Official | UI, API, CLI | -| Azure | 169 | 22 | 15 | 8 | Official | UI, API, CLI | -| Kubernetes | 84 | 7 | 6 | 9 | Official | UI, API, CLI | +| AWS | 584 | 84 | 40 | 17 | Official | UI, API, CLI | +| Azure | 169 | 22 | 16 | 12 | Official | UI, API, CLI | +| GCP | 100 | 17 | 14 | 7 | Official | UI, API, CLI | +| Kubernetes | 84 | 7 | 7 | 9 | Official | UI, API, CLI | | GitHub | 20 | 2 | 1 | 2 | Official | UI, API, CLI | -| M365 | 70 | 7 | 3 | 2 | Official | UI, API, CLI | -| OCI | 52 | 15 | 1 | 12 | Official | UI, API, CLI | -| Alibaba Cloud | 63 | 10 | 1 | 9 | Official | CLI | +| M365 | 71 | 7 | 4 | 3 | Official | UI, API, CLI | +| OCI | 52 | 14 | 1 | 12 | Official | UI, API, CLI | +| Alibaba Cloud | 64 | 9 | 2 | 9 | Official | UI, API, CLI | +| Cloudflare | 23 | 2 | 0 | 5 | Official | CLI | | IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI | -| MongoDB Atlas | 10 | 4 | 0 | 3 | Official | UI, API, CLI | +| MongoDB Atlas | 10 | 3 | 0 | 3 | Official | UI, API, CLI | | LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | diff --git a/api/AGENTS.md b/api/AGENTS.md index f43e0dcbb3..399f12b691 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -4,6 +4,7 @@ > - [`prowler-api`](../skills/prowler-api/SKILL.md) - Models, Serializers, Views, RLS patterns > - [`prowler-test-api`](../skills/prowler-test-api/SKILL.md) - Testing patterns (pytest-django) > - [`django-drf`](../skills/django-drf/SKILL.md) - Generic DRF patterns +> - [`jsonapi`](../skills/jsonapi/SKILL.md) - Strict JSON:API v1.1 spec compliance > - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns ### Auto-invoke Skills @@ -12,9 +13,20 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Action | Skill | |--------|-------| +| Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding DRF pagination or permissions | `django-drf` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating API endpoints | `jsonapi` | +| Creating ViewSets, serializers, or filters in api/ | `django-drf` | +| Creating a git commit | `prowler-commit` | | Creating/modifying models, views, serializers | `prowler-api` | -| Generic DRF patterns | `django-drf` | +| Implementing JSON:API endpoints | `django-drf` | +| Modifying API responses | `jsonapi` | +| Review changelog format and conventions | `prowler-changelog` | +| Reviewing JSON:API compliance | `jsonapi` | | Testing RLS tenant isolation | `prowler-test-api` | +| Update CHANGELOG.md in any component | `prowler-changelog` | | Writing Prowler API tests | `prowler-test-api` | | Writing Python tests with pytest | `pytest` | diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 48a7753832..34a38ada6f 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,45 +2,96 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.18.0] (Prowler UNRELEASED) +## [1.19.0] (Prowler UNRELEASED) -### Added -- `/api/v1/overviews/compliance-watchlist` to retrieve the compliance watchlist [(#9596)](https://github.com/prowler-cloud/prowler/pull/9596) -- Support AlibabaCloud provider [(#9485)](https://github.com/prowler-cloud/prowler/pull/9485) -- `provider_id` and `provider_id__in` filter aliases for findings endpoints to enable consistent frontend parameter naming [(#9701)](https://github.com/prowler-cloud/prowler/pull/9701) +### πŸš€ Added + +- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885) +- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) +- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444) +- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101) +- Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904) + +### πŸ”„ Changed + +- Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857) +- Remove unused indexes [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904) --- -## [1.17.2] (Prowler v5.16.2) +## [1.18.2] (Prowler UNRELEASED) -### Security -- Updated dependencies to patch security vulnerabilities: Django 5.1.15 (CVE-2025-64460, CVE-2025-13372), Werkzeug 3.1.4 (CVE-2025-66221), sqlparse 0.5.5 (PVE-2025-82038), fonttools 4.60.2 (CVE-2025-66034) [(#9730)](https://github.com/prowler-cloud/prowler/pull/9730) +### 🐞 Fixed + +- Attack Paths: `aws-security-groups-open-internet-facing` query returning no results due to incorrect relationship matching [(#9892)](https://github.com/prowler-cloud/prowler/pull/9892) + +--- + +## [1.18.1] (Prowler v5.17.1) + +### 🐞 Fixed + +- Improve API startup process by `manage.py` argument detection [(#9856)](https://github.com/prowler-cloud/prowler/pull/9856) +- Deleting providers don't try to delete a `None` Neo4j database when an Attack Paths scan is scheduled [(#9858)](https://github.com/prowler-cloud/prowler/pull/9858) +- Use replica database for reading Findings to add them to the Attack Paths graph [(#9861)](https://github.com/prowler-cloud/prowler/pull/9861) +- Attack paths findings loading query to use streaming generator for O(batch_size) memory instead of O(total_findings) [(#9862)](https://github.com/prowler-cloud/prowler/pull/9862) +- Lazy load Neo4j driver [(#9868)](https://github.com/prowler-cloud/prowler/pull/9868) +- Use `Findings.all_objects` to avoid the `ActiveProviderPartitionedManager` [(#9869)](https://github.com/prowler-cloud/prowler/pull/9869) +- Lazy load Neo4j driver for workers only [(#9872)](https://github.com/prowler-cloud/prowler/pull/9872) +- Improve Cypher query for inserting Findings into Attack Paths scan graphs [(#9874)](https://github.com/prowler-cloud/prowler/pull/9874) +- Clear Neo4j database cache after Attack Paths scan and each API query [(#9877)](https://github.com/prowler-cloud/prowler/pull/9877) +- Deduplicated scheduled scans for long-running providers [(#9829)](https://github.com/prowler-cloud/prowler/pull/9829) + +--- + +## [1.18.0] (Prowler v5.17.0) + +### πŸš€ Added + +- `/api/v1/overviews/compliance-watchlist` endpoint to retrieve the compliance watchlist [(#9596)](https://github.com/prowler-cloud/prowler/pull/9596) +- AlibabaCloud provider support [(#9485)](https://github.com/prowler-cloud/prowler/pull/9485) +- `/api/v1/overviews/resource-groups` endpoint to retrieve an overview of resource groups based on finding severities [(#9694)](https://github.com/prowler-cloud/prowler/pull/9694) +- `group` filter for `GET /findings` and `GET /findings/metadata/latest` endpoints [(#9694)](https://github.com/prowler-cloud/prowler/pull/9694) +- `provider_id` and `provider_id__in` filter aliases for findings endpoints to enable consistent frontend parameter naming [(#9701)](https://github.com/prowler-cloud/prowler/pull/9701) +- Attack Paths: `/api/v1/attack-paths-scans` for AWS providers backed by Neo4j [(#9805)](https://github.com/prowler-cloud/prowler/pull/9805) + +### πŸ” Security + +- Django 5.1.15 (CVE-2025-64460, CVE-2025-13372), Werkzeug 3.1.4 (CVE-2025-66221), sqlparse 0.5.5 (PVE-2025-82038), fonttools 4.60.2 (CVE-2025-66034) [(#9730)](https://github.com/prowler-cloud/prowler/pull/9730) +- `safety` to `3.7.0` and `filelock` to `3.20.3` due to [Safety vulnerability 82754 (CVE-2025-68146)](https://data.safetycli.com/v/82754/97c/) [(#9816)](https://github.com/prowler-cloud/prowler/pull/9816) +- `pyasn1` to v0.6.2 to address [CVE-2026-23490](https://nvd.nist.gov/vuln/detail/CVE-2026-23490) [(#9818)](https://github.com/prowler-cloud/prowler/pull/9818) +- `django-allauth[saml]` to v65.13.0 to address [CVE-2025-65431](https://nvd.nist.gov/vuln/detail/CVE-2025-65431) [(#9575)](https://github.com/prowler-cloud/prowler/pull/9575) --- ## [1.17.1] (Prowler v5.16.1) -### Changed +### πŸ”„ Changed + - Security Hub integration error when no regions [(#9635)](https://github.com/prowler-cloud/prowler/pull/9635) -### Fixed +### 🐞 Fixed + - Orphan scheduled scans caused by transaction isolation during provider creation [(#9633)](https://github.com/prowler-cloud/prowler/pull/9633) --- ## [1.17.0] (Prowler v5.16.0) -### Added +### πŸš€ Added + - New endpoint to retrieve and overview of the categories based on finding severities [(#9529)](https://github.com/prowler-cloud/prowler/pull/9529) - Endpoints `GET /findings` and `GET /findings/latests` can now use the category filter [(#9529)](https://github.com/prowler-cloud/prowler/pull/9529) - Account id, alias and provider name to PDF reporting table [(#9574)](https://github.com/prowler-cloud/prowler/pull/9574) -### Changed +### πŸ”„ Changed + - Endpoint `GET /overviews/attack-surfaces` no longer returns the related check IDs [(#9529)](https://github.com/prowler-cloud/prowler/pull/9529) - OpenAI provider to only load chat-compatible models with tool calling support [(#9523)](https://github.com/prowler-cloud/prowler/pull/9523) - Increased execution delay for the first scheduled scan tasks to 5 seconds[(#9558)](https://github.com/prowler-cloud/prowler/pull/9558) -### Fixed +### 🐞 Fixed + - Made `scan_id` a required filter in the compliance overview endpoint [(#9560)](https://github.com/prowler-cloud/prowler/pull/9560) - Reduced unnecessary UPDATE resources operations by only saving when tag mappings change, lowering write load during scans [(#9569)](https://github.com/prowler-cloud/prowler/pull/9569) @@ -48,19 +99,22 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.16.1] (Prowler v5.15.1) -### Fixed +### 🐞 Fixed + - Race condition in scheduled scan creation by adding countdown to task [(#9516)](https://github.com/prowler-cloud/prowler/pull/9516) ## [1.16.0] (Prowler v5.15.0) -### Added +### πŸš€ Added + - New endpoint to retrieve an overview of the attack surfaces [(#9309)](https://github.com/prowler-cloud/prowler/pull/9309) - New endpoint `GET /api/v1/overviews/findings_severity/timeseries` to retrieve daily aggregated findings by severity level [(#9363)](https://github.com/prowler-cloud/prowler/pull/9363) - Lighthouse AI support for Amazon Bedrock API key [(#9343)](https://github.com/prowler-cloud/prowler/pull/9343) - Exception handler for provider deletions during scans [(#9414)](https://github.com/prowler-cloud/prowler/pull/9414) - Support to use admin credentials through the read replica database [(#9440)](https://github.com/prowler-cloud/prowler/pull/9440) -### Changed +### πŸ”„ Changed + - Error messages from Lighthouse celery tasks [(#9165)](https://github.com/prowler-cloud/prowler/pull/9165) - Restore the compliance overview endpoint's mandatory filters [(#9338)](https://github.com/prowler-cloud/prowler/pull/9338) @@ -68,7 +122,8 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.15.2] (Prowler v5.14.2) -### Fixed +### 🐞 Fixed + - Unique constraint violation during compliance overviews task [(#9436)](https://github.com/prowler-cloud/prowler/pull/9436) - Division by zero error in ENS PDF report when all requirements are manual [(#9443)](https://github.com/prowler-cloud/prowler/pull/9443) @@ -76,7 +131,8 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.15.1] (Prowler v5.14.1) -### Fixed +### 🐞 Fixed + - Fix typo in PDF reporting [(#9345)](https://github.com/prowler-cloud/prowler/pull/9345) - Fix IaC provider initialization failure when mutelist processor is configured [(#9331)](https://github.com/prowler-cloud/prowler/pull/9331) - Match logic for ThreatScore when counting findings [(#9348)](https://github.com/prowler-cloud/prowler/pull/9348) @@ -85,7 +141,8 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.15.0] (Prowler v5.14.0) -### Added +### πŸš€ Added + - IaC (Infrastructure as Code) provider support for remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751) - Extend `GET /api/v1/providers` with provider-type filters and optional pagination disable to support the new Overview filters [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975) - New endpoint to retrieve the number of providers grouped by provider type [(#8975)](https://github.com/prowler-cloud/prowler/pull/8975) @@ -104,11 +161,13 @@ All notable changes to the **Prowler API** are documented in this file. - Enhanced compliance overview endpoint with provider filtering and latest scan aggregation [(#9244)](https://github.com/prowler-cloud/prowler/pull/9244) - New endpoint `GET /api/v1/overview/regions` to retrieve aggregated findings data by region [(#9273)](https://github.com/prowler-cloud/prowler/pull/9273) -### Changed +### πŸ”„ Changed + - Optimized database write queries for scan related tasks [(#9190)](https://github.com/prowler-cloud/prowler/pull/9190) - Date filters are now optional for `GET /api/v1/overviews/services` endpoint; returns latest scan data by default [(#9248)](https://github.com/prowler-cloud/prowler/pull/9248) -### Fixed +### 🐞 Fixed + - Scans no longer fail when findings have UIDs exceeding 300 characters; such findings are now skipped with detailed logging [(#9246)](https://github.com/prowler-cloud/prowler/pull/9246) - Updated unique constraint for `Provider` model to exclude soft-deleted entries, resolving duplicate errors when re-deleting providers [(#9054)](https://github.com/prowler-cloud/prowler/pull/9054) - Removed compliance generation for providers without compliance frameworks [(#9208)](https://github.com/prowler-cloud/prowler/pull/9208) @@ -116,14 +175,16 @@ All notable changes to the **Prowler API** are documented in this file. - Severity overview endpoint now ignores muted findings as expected [(#9283)](https://github.com/prowler-cloud/prowler/pull/9283) - Fixed discrepancy between ThreatScore PDF report values and database calculations [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296) -### Security +### πŸ” Security + - Django updated to the latest 5.1 security release, 5.1.14, due to problems with potential [SQL injection](https://github.com/prowler-cloud/prowler/security/dependabot/113) and [denial-of-service vulnerability](https://github.com/prowler-cloud/prowler/security/dependabot/114) [(#9176)](https://github.com/prowler-cloud/prowler/pull/9176) --- ## [1.14.1] (Prowler v5.13.1) -### Fixed +### 🐞 Fixed + - `/api/v1/overviews/providers` collapses data by provider type so the UI receives a single aggregated record per cloud family even when multiple accounts exist [(#9053)](https://github.com/prowler-cloud/prowler/pull/9053) - Added retry logic to database transactions to handle Aurora read replica connection failures during scale-down events [(#9064)](https://github.com/prowler-cloud/prowler/pull/9064) - Security Hub integrations stop failing when they read relationships via the replica by allowing replica relations and saving updates through the primary [(#9080)](https://github.com/prowler-cloud/prowler/pull/9080) @@ -132,7 +193,8 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.14.0] (Prowler v5.13.0) -### Added +### πŸš€ Added + - Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655) - `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920) - Support C5 compliance framework for the AWS provider [(#8830)](https://github.com/prowler-cloud/prowler/pull/8830) @@ -145,35 +207,41 @@ All notable changes to the **Prowler API** are documented in this file. - Support Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000) - Add `provider_id__in` filter support to findings and findings severity overview endpoints [(#8951)](https://github.com/prowler-cloud/prowler/pull/8951) -### Changed +### πŸ”„ Changed + - Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281) - Now at least one user with MANAGE_ACCOUNT permission is required in the tenant [(#8729)](https://github.com/prowler-cloud/prowler/pull/8729) -### Security +### πŸ” Security + - Django updated to the latest 5.1 security release, 5.1.13, due to problems with potential [SQL injection](https://github.com/prowler-cloud/prowler/security/dependabot/104) and [directory traversals](https://github.com/prowler-cloud/prowler/security/dependabot/103) [(#8842)](https://github.com/prowler-cloud/prowler/pull/8842) --- ## [1.13.2] (Prowler v5.12.3) -### Fixed +### 🐞 Fixed + - 500 error when deleting user [(#8731)](https://github.com/prowler-cloud/prowler/pull/8731) --- ## [1.13.1] (Prowler v5.12.2) -### Changed +### πŸ”„ Changed + - Renamed compliance overview task queue to `compliance` [(#8755)](https://github.com/prowler-cloud/prowler/pull/8755) -### Security +### πŸ” Security + - Django updated to the latest 5.1 security release, 5.1.12, due to [problems](https://www.djangoproject.com/weblog/2025/sep/03/security-releases/) with potential SQL injection in FilteredRelation column aliases [(#8693)](https://github.com/prowler-cloud/prowler/pull/8693) --- ## [1.13.0] (Prowler v5.12.0) -### Added +### πŸš€ Added + - Integration with JIRA, enabling sending findings to a JIRA project [(#8622)](https://github.com/prowler-cloud/prowler/pull/8622), [(#8637)](https://github.com/prowler-cloud/prowler/pull/8637) - `GET /overviews/findings_severity` now supports `filter[status]` and `filter[status__in]` to aggregate by specific statuses (`FAIL`, `PASS`)[(#8186)](https://github.com/prowler-cloud/prowler/pull/8186) - Throttling options for `/api/v1/tokens` using the `DJANGO_THROTTLE_TOKEN_OBTAIN` environment variable [(#8647)](https://github.com/prowler-cloud/prowler/pull/8647) @@ -182,101 +250,120 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.12.0] (Prowler v5.11.0) -### Added +### πŸš€ Added + - Lighthouse support for OpenAI GPT-5 [(#8527)](https://github.com/prowler-cloud/prowler/pull/8527) - Integration with Amazon Security Hub, enabling sending findings to Security Hub [(#8365)](https://github.com/prowler-cloud/prowler/pull/8365) - Generate ASFF output for AWS providers with SecurityHub integration enabled [(#8569)](https://github.com/prowler-cloud/prowler/pull/8569) -### Fixed +### 🐞 Fixed + - GitHub provider always scans user instead of organization when using provider UID [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587) --- ## [1.11.0] (Prowler v5.10.0) -### Added +### πŸš€ Added + - Github provider support [(#8271)](https://github.com/prowler-cloud/prowler/pull/8271) - Integration with Amazon S3, enabling storage and retrieval of scan data via S3 buckets [(#8056)](https://github.com/prowler-cloud/prowler/pull/8056) -### Fixed +### 🐞 Fixed + - Avoid sending errors to Sentry in M365 provider when user authentication fails [(#8420)](https://github.com/prowler-cloud/prowler/pull/8420) --- ## [1.10.2] (Prowler v5.9.2) -### Changed +### πŸ”„ Changed + - Optimized queries for resources views [(#8336)](https://github.com/prowler-cloud/prowler/pull/8336) --- ## [v1.10.1] (Prowler v5.9.1) -### Fixed +### 🐞 Fixed + - Calculate failed findings during scans to prevent heavy database queries [(#8322)](https://github.com/prowler-cloud/prowler/pull/8322) --- ## [v1.10.0] (Prowler v5.9.0) -### Added +### πŸš€ Added + - SSO with SAML support [(#8175)](https://github.com/prowler-cloud/prowler/pull/8175) - `GET /resources/metadata`, `GET /resources/metadata/latest` and `GET /resources/latest` to expose resource metadata and latest scan results [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112) -### Changed +### πŸ”„ Changed + - `/processors` endpoints to post-process findings. Currently, only the Mutelist processor is supported to allow to mute findings. - Optimized the underlying queries for resources endpoints [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112) - Optimized include parameters for resources view [(#8229)](https://github.com/prowler-cloud/prowler/pull/8229) - Optimized overview background tasks [(#8300)](https://github.com/prowler-cloud/prowler/pull/8300) -### Fixed +### 🐞 Fixed + - Search filter for findings and resources [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112) - RBAC is now applied to `GET /overviews/providers` [(#8277)](https://github.com/prowler-cloud/prowler/pull/8277) -### Changed +### πŸ”„ Changed + - `POST /schedules/daily` returns a `409 CONFLICT` if already created [(#8258)](https://github.com/prowler-cloud/prowler/pull/8258) -### Security +### πŸ” Security + - Enhanced password validation to enforce 12+ character passwords with special characters, uppercase, lowercase, and numbers [(#8225)](https://github.com/prowler-cloud/prowler/pull/8225) --- ## [v1.9.1] (Prowler v5.8.1) -### Added +### πŸš€ Added + - Custom exception for provider connection errors during scans [(#8234)](https://github.com/prowler-cloud/prowler/pull/8234) -### Changed +### πŸ”„ Changed + - Summary and overview tasks now use a dedicated queue and no longer propagate errors to compliance tasks [(#8214)](https://github.com/prowler-cloud/prowler/pull/8214) -### Fixed +### 🐞 Fixed + - Scan with no resources will not trigger legacy code for findings metadata [(#8183)](https://github.com/prowler-cloud/prowler/pull/8183) - Invitation email comparison case-insensitive [(#8206)](https://github.com/prowler-cloud/prowler/pull/8206) -### Removed +### ❌ Removed + - Validation of the provider's secret type during updates [(#8197)](https://github.com/prowler-cloud/prowler/pull/8197) --- ## [v1.9.0] (Prowler v5.8.0) -### Added +### πŸš€ Added + - Support GCP Service Account key [(#7824)](https://github.com/prowler-cloud/prowler/pull/7824) - `GET /compliance-overviews` endpoints to retrieve compliance metadata and specific requirements statuses [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877) - Lighthouse configuration support [(#7848)](https://github.com/prowler-cloud/prowler/pull/7848) -### Changed +### πŸ”„ Changed + - Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877) - Optional `user` and `password` for M365 provider [(#7992)](https://github.com/prowler-cloud/prowler/pull/7992) -### Fixed +### 🐞 Fixed + - Scheduled scans are no longer deleted when their daily schedule run is disabled [(#8082)](https://github.com/prowler-cloud/prowler/pull/8082) --- ## [v1.8.5] (Prowler v5.7.5) -### Fixed +### 🐞 Fixed + - Normalize provider UID to ensure safe and unique export directory paths [(#8007)](https://github.com/prowler-cloud/prowler/pull/8007). - Blank resource types in `/metadata` endpoints [(#8027)](https://github.com/prowler-cloud/prowler/pull/8027) @@ -284,20 +371,24 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.8.4] (Prowler v5.7.4) -### Removed +### ❌ Removed + - Reverted RLS transaction handling and DB custom backend [(#7994)](https://github.com/prowler-cloud/prowler/pull/7994) --- ## [v1.8.3] (Prowler v5.7.3) -### Added +### πŸš€ Added + - Database backend to handle already closed connections [(#7935)](https://github.com/prowler-cloud/prowler/pull/7935) -### Changed +### πŸ”„ Changed + - Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) -### Fixed +### 🐞 Fixed + - Transaction persistence with RLS operations [(#7916)](https://github.com/prowler-cloud/prowler/pull/7916) - Reverted the change `get_with_retry` to use the original `get` method for retrieving tasks [(#7932)](https://github.com/prowler-cloud/prowler/pull/7932) @@ -305,7 +396,8 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.8.2] (Prowler v5.7.2) -### Fixed +### 🐞 Fixed + - Task lookup to use task_kwargs instead of task_args for scan report resolution [(#7830)](https://github.com/prowler-cloud/prowler/pull/7830) - Kubernetes UID validation to allow valid context names [(#7871)](https://github.com/prowler-cloud/prowler/pull/7871) - Connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831) @@ -316,14 +408,16 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.8.1] (Prowler v5.7.1) -### Fixed +### 🐞 Fixed + - Added database index to improve performance on finding lookup [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800) --- ## [v1.8.0] (Prowler v5.7.0) -### Added +### πŸš€ Added + - Huge improvements to `/findings/metadata` and resource related filters for findings [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690) - Improvements to `/overviews` endpoints [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690) - Queue to perform backfill background tasks [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690) @@ -334,7 +428,7 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.7.0] (Prowler v5.6.0) -### Added +### πŸš€ Added - M365 as a new provider [(#7563)](https://github.com/prowler-cloud/prowler/pull/7563) - `compliance/` folder and ZIP‐export functionality for all compliance reports [(#7653)](https://github.com/prowler-cloud/prowler/pull/7653) @@ -344,7 +438,7 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.6.0] (Prowler v5.5.0) -### Added +### πŸš€ Added - Support for developing new integrations [(#7167)](https://github.com/prowler-cloud/prowler/pull/7167) - HTTP Security Headers [(#7289)](https://github.com/prowler-cloud/prowler/pull/7289) @@ -356,14 +450,16 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.5.4] (Prowler v5.4.4) -### Fixed +### 🐞 Fixed + - Bug with periodic tasks when trying to delete a provider [(#7466)](https://github.com/prowler-cloud/prowler/pull/7466) --- ## [v1.5.3] (Prowler v5.4.3) -### Fixed +### 🐞 Fixed + - Duplicated scheduled scans handling [(#7401)](https://github.com/prowler-cloud/prowler/pull/7401) - Environment variable to configure the deletion task batch size [(#7423)](https://github.com/prowler-cloud/prowler/pull/7423) @@ -371,14 +467,16 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.5.2] (Prowler v5.4.2) -### Changed +### πŸ”„ Changed + - Refactored deletion logic and implemented retry mechanism for deletion tasks [(#7349)](https://github.com/prowler-cloud/prowler/pull/7349) --- ## [v1.5.1] (Prowler v5.4.1) -### Fixed +### 🐞 Fixed + - Handle response in case local files are missing [(#7183)](https://github.com/prowler-cloud/prowler/pull/7183) - Race condition when deleting export files after the S3 upload [(#7172)](https://github.com/prowler-cloud/prowler/pull/7172) - Handle exception when a provider has no secret in test connection [(#7283)](https://github.com/prowler-cloud/prowler/pull/7283) @@ -387,19 +485,22 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.5.0] (Prowler v5.4.0) -### Added +### πŸš€ Added + - Social login integration with Google and GitHub [(#6906)](https://github.com/prowler-cloud/prowler/pull/6906) - API scan report system, now all scans launched from the API will generate a compressed file with the report in OCSF, CSV and HTML formats [(#6878)](https://github.com/prowler-cloud/prowler/pull/6878) - Configurable Sentry integration [(#6874)](https://github.com/prowler-cloud/prowler/pull/6874) -### Changed +### πŸ”„ Changed + - Optimized `GET /findings` endpoint to improve response time and size [(#7019)](https://github.com/prowler-cloud/prowler/pull/7019) --- ## [v1.4.0] (Prowler v5.3.0) -### Changed +### πŸ”„ Changed + - Daily scheduled scan instances are now created beforehand with `SCHEDULED` state [(#6700)](https://github.com/prowler-cloud/prowler/pull/6700) - Findings endpoints now require at least one date filter [(#6800)](https://github.com/prowler-cloud/prowler/pull/6800) - Findings metadata endpoint received a performance improvement [(#6863)](https://github.com/prowler-cloud/prowler/pull/6863) diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index f9b19e04d0..eea024a4a2 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -32,7 +32,7 @@ start_prod_server() { start_worker() { echo "Starting the worker..." - poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance -E --max-tasks-per-child 1 + poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans -E --max-tasks-per-child 1 } start_worker_beat() { diff --git a/api/poetry.lock b/api/poetry.lock index 84e67cd040..3ee0f5f918 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "about-time" @@ -12,6 +12,71 @@ files = [ {file = "about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341"}, ] +[[package]] +name = "adal" +version = "1.2.7" +description = "Note: This library is already replaced by MSAL Python, available here: https://pypi.org/project/msal/ .ADAL Python remains available here as a legacy. The ADAL for Python library makes it easy for python application to authenticate to Azure Active Directory (AAD) in order to access AAD protected web resources." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "adal-1.2.7-py2.py3-none-any.whl", hash = "sha256:2a7451ed7441ddbc57703042204a3e30ef747478eea022c70f789fc7f084bc3d"}, + {file = "adal-1.2.7.tar.gz", hash = "sha256:d74f45b81317454d96e982fd1c50e6fb5c99ac2223728aea8764433a39f566f1"}, +] + +[package.dependencies] +cryptography = ">=1.1.0" +PyJWT = ">=1.0.0,<3" +python-dateutil = ">=2.1.0,<3" +requests = ">=2.0.0,<3" + +[[package]] +name = "aioboto3" +version = "15.5.0" +description = "Async boto3 wrapper" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6"}, + {file = "aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979"}, +] + +[package.dependencies] +aiobotocore = {version = "2.25.1", extras = ["boto3"]} +aiofiles = ">=23.2.1" + +[package.extras] +chalice = ["chalice (>=1.24.0)"] +s3cse = ["cryptography (>=44.0.1)"] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +description = "Async client for aws services using botocore and aiohttp" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"}, + {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"}, +] + +[package.dependencies] +aiohttp = ">=3.9.2,<4.0.0" +aioitertools = ">=0.5.1,<1.0.0" +boto3 = {version = ">=1.40.46,<1.40.62", optional = true, markers = "extra == \"boto3\""} +botocore = ">=1.40.46,<1.40.62" +jmespath = ">=0.7.1,<2.0.0" +multidict = ">=6.0.0,<7.0.0" +python-dateutil = ">=2.1,<3.0.0" +wrapt = ">=1.10.10,<2.0.0" + +[package.extras] +awscli = ["awscli (>=1.42.46,<1.42.62)"] +boto3 = ["boto3 (>=1.40.46,<1.40.62)"] +httpx = ["httpx (>=0.25.1,<0.29)"] + [[package]] name = "aiofiles" version = "24.1.0" @@ -178,6 +243,18 @@ yarl = ">=1.17.0,<2.0" [package.extras] speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] +[[package]] +name = "aioitertools" +version = "0.13.0" +description = "itertools and builtins for AsyncIO and mixed iterables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be"}, + {file = "aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c"}, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -451,17 +528,18 @@ alibabacloud_credentials = ">=0.3.4" [[package]] name = "alibabacloud-openapi-util" -version = "0.2.2" +version = "0.2.4" description = "Aliyun Tea OpenApi Library for Python" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "alibabacloud_openapi_util-0.2.2.tar.gz", hash = "sha256:ebbc3906f554cb4bf8f513e43e8a33e8b6a3d4a0ef13617a0e14c3dda8ef52a8"}, + {file = "alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd"}, + {file = "alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34"}, ] [package.dependencies] -alibabacloud_tea_util = ">=0.0.2" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" cryptography = ">=3.0.0" [[package]] @@ -743,7 +821,7 @@ version = "4.10.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, @@ -757,6 +835,18 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.26.1)"] +[[package]] +name = "applicationinsights" +version = "0.11.10" +description = "This project extends the Application Insights API surface to support Python." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea"}, + {file = "applicationinsights-0.11.10.tar.gz", hash = "sha256:0b761f3ef0680acf4731906dfc1807faa6f2a57168ae74592db0084a6099f7b3"}, +] + [[package]] name = "apscheduler" version = "3.11.2" @@ -785,6 +875,21 @@ tornado = ["tornado (>=4.3)"] twisted = ["twisted"] zookeeper = ["kazoo"] +[[package]] +name = "argcomplete" +version = "3.5.3" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argcomplete-3.5.3-py3-none-any.whl", hash = "sha256:2ab2c4a215c59fd6caaff41a869480a23e8f6a5f910b266c1808037f4e375b61"}, + {file = "argcomplete-3.5.3.tar.gz", hash = "sha256:c12bf50eded8aebb298c7b7da7a5ff3ee24dffd9f5281867dfe1424b58c55392"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + [[package]] name = "asgiref" version = "3.9.1" @@ -887,6 +992,58 @@ files = [ {file = "awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0"}, ] +[[package]] +name = "azure-cli-core" +version = "2.81.0" +description = "Microsoft Azure Command-Line Tools Core Module" +optional = false +python-versions = ">=3.10.0" +groups = ["main"] +files = [ + {file = "azure_cli_core-2.81.0-py3-none-any.whl", hash = "sha256:2c711ff890d003b10bb40f932d008a872c6451324ee533fbbb861c950c3f0f93"}, + {file = "azure_cli_core-2.81.0.tar.gz", hash = "sha256:bae30dcd6f8c1883c8e32fbcb7c43dbe2dbfe522e428c7629edef271933f7b1e"}, +] + +[package.dependencies] +argcomplete = ">=3.5.2,<3.6.0" +azure-cli-telemetry = "==1.1.0.*" +azure-core = ">=1.35.0,<1.36.0" +azure-mgmt-core = ">=1.2.0,<2" +cryptography = "*" +distro = {version = "*", markers = "sys_platform == \"linux\""} +humanfriendly = ">=10.0,<11.0" +jmespath = "*" +knack = ">=0.11.0,<0.12.0" +microsoft-security-utilities-secret-masker = ">=1.0.0b4,<1.1.0" +msal = [ + {version = "1.34.0b1", extras = ["broker"], markers = "sys_platform == \"win32\""}, + {version = "1.34.0b1", markers = "sys_platform != \"win32\""}, +] +msal-extensions = "1.2.0" +packaging = ">=20.9" +pkginfo = ">=1.5.0.1" +psutil = {version = ">=5.9", markers = "sys_platform != \"cygwin\""} +py-deviceid = "*" +PyJWT = ">=2.1.0" +pyopenssl = ">=17.1.0" +requests = {version = "*", extras = ["socks"]} + +[[package]] +name = "azure-cli-telemetry" +version = "1.1.0" +description = "Microsoft Azure CLI Telemetry Package" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "azure-cli-telemetry-1.1.0.tar.gz", hash = "sha256:d922379cda1b48952be75fb3bd2ac5e7ceecf569492a6088bab77894c624a278"}, + {file = "azure_cli_telemetry-1.1.0-py3-none-any.whl", hash = "sha256:2fc12608c0cf0ea6e69b392af9cab92f1249340b8caff7e9674cf91b3becb337"}, +] + +[package.dependencies] +applicationinsights = ">=0.11.1,<0.12" +portalocker = ">=1.6,<3" + [[package]] name = "azure-common" version = "1.1.28" @@ -1027,6 +1184,23 @@ azure-mgmt-core = ">=1.3.2" isodate = ">=0.6.1" typing-extensions = ">=4.6.0" +[[package]] +name = "azure-mgmt-containerinstance" +version = "10.1.0" +description = "Microsoft Azure Container Instance Client Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "azure-mgmt-containerinstance-10.1.0.zip", hash = "sha256:78d437adb28574f448c838ed5f01f9ced378196098061deb59d9f7031704c17e"}, + {file = "azure_mgmt_containerinstance-10.1.0-py3-none-any.whl", hash = "sha256:ee7977b7b70f2233e44ec6ce8c99027f3f7892bb3452b4bad46df340d9f98959"}, +] + +[package.dependencies] +azure-common = ">=1.1,<2.0" +azure-mgmt-core = ">=1.3.2,<2.0.0" +isodate = ">=0.6.1,<1.0.0" + [[package]] name = "azure-mgmt-containerregistry" version = "12.0.0" @@ -1113,6 +1287,42 @@ azure-common = ">=1.1,<2.0" azure-mgmt-core = ">=1.3.2,<2.0.0" isodate = ">=0.6.1,<1.0.0" +[[package]] +name = "azure-mgmt-datafactory" +version = "9.2.0" +description = "Microsoft Azure Data Factory Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_datafactory-9.2.0-py3-none-any.whl", hash = "sha256:d870a7a6099227e91d1c258a956c2aa32c2ea4c0a4409913d8f215887349f128"}, + {file = "azure_mgmt_datafactory-9.2.0.tar.gz", hash = "sha256:5132e9c24c441ac225f2a60225924baa55079ca81eff7db99a70d661d64bb0d7"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + +[[package]] +name = "azure-mgmt-eventgrid" +version = "10.4.0" +description = "Microsoft Azure Event Grid Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_eventgrid-10.4.0-py3-none-any.whl", hash = "sha256:5e4637245bbff33298d5f427971b870dbb03d873a3ef68f328190a7b7a38c56f"}, + {file = "azure_mgmt_eventgrid-10.4.0.tar.gz", hash = "sha256:303e5e27cf4bb5ec833ba4e5a9ef70b5bc410e190412ec47cde59d82e413fb7e"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-mgmt-keyvault" version = "10.3.1" @@ -1148,6 +1358,23 @@ azure-common = ">=1.1,<2.0" azure-mgmt-core = ">=1.2.0,<2.0.0" msrest = ">=0.6.21" +[[package]] +name = "azure-mgmt-logic" +version = "10.0.0" +description = "Microsoft Azure Logic Apps Management Client Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "azure-mgmt-logic-10.0.0.zip", hash = "sha256:b3fa4864f14aaa7af41d778d925f051ed29b6016f46344765ecd0f49d0f04dd6"}, + {file = "azure_mgmt_logic-10.0.0-py3-none-any.whl", hash = "sha256:525c78afedf3edb35eb0a16152c8beba89769ee1bc6af01bcdc42842a551e443"}, +] + +[package.dependencies] +azure-common = ">=1.1,<2.0" +azure-mgmt-core = ">=1.3.0,<2.0.0" +msrest = ">=0.6.21" + [[package]] name = "azure-mgmt-monitor" version = "6.0.2" @@ -1414,6 +1641,18 @@ typing-extensions = ">=4.6.0" [package.extras] aio = ["azure-core[aio] (>=1.30.0)"] +[[package]] +name = "backoff" +version = "2.2.1" +description = "Function decoration for backoff and retry" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + [[package]] name = "bandit" version = "1.7.9" @@ -1465,34 +1704,34 @@ files = [ [[package]] name = "boto3" -version = "1.39.15" +version = "1.40.61" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.39.15-py3-none-any.whl", hash = "sha256:38fc54576b925af0075636752de9974e172c8a2cf7133400e3e09b150d20fb6a"}, - {file = "boto3-1.39.15.tar.gz", hash = "sha256:b4483625f0d8c35045254dee46cd3c851bbc0450814f20b9b25bee1b5c0d8409"}, + {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, + {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, ] [package.dependencies] -botocore = ">=1.39.15,<1.40.0" +botocore = ">=1.40.61,<1.41.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.13.0,<0.14.0" +s3transfer = ">=0.14.0,<0.15.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.39.15" +version = "1.40.61" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.39.15-py3-none-any.whl", hash = "sha256:eb9cfe918ebfbfb8654e1b153b29f0c129d586d2c0d7fb4032731d49baf04cff"}, - {file = "botocore-1.39.15.tar.gz", hash = "sha256:2aa29a717f14f8c7ca058c2e297aaed0aa10ecea24b91514eee802814d1b7600"}, + {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"}, + {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"}, ] [package.dependencies] @@ -1501,7 +1740,7 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] -crt = ["awscrt (==0.23.8)"] +crt = ["awscrt (==0.27.6)"] [[package]] name = "cachetools" @@ -1515,6 +1754,75 @@ files = [ {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, ] +[[package]] +name = "cartography" +version = "0.0.1.dev1268+gc134846c0" +description = "Explore assets and their relationships across your technical infrastructure." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +adal = ">=1.2.4" +aioboto3 = ">=13.0.0" +azure-cli-core = ">=2.26.0" +azure-identity = ">=1.5.0" +azure-mgmt-authorization = ">=0.60.0" +azure-mgmt-compute = ">=5.0.0" +azure-mgmt-containerinstance = ">=10.0.0" +azure-mgmt-containerservice = ">=30.0.0" +azure-mgmt-cosmosdb = ">=6.0.0" +azure-mgmt-datafactory = ">=8.0.0" +azure-mgmt-eventgrid = ">=10.0.0" +azure-mgmt-logic = ">=10.0.0" +azure-mgmt-monitor = ">=3.0.0" +azure-mgmt-network = ">=25.0.0" +azure-mgmt-resource = ">=10.2.0" +azure-mgmt-security = ">=5.0.0" +azure-mgmt-sql = ">=3.0.1,<4" +azure-mgmt-storage = ">=16.0.0" +azure-mgmt-web = ">=7.0.0" +backoff = ">=2.1.2" +boto3 = ">=1.15.1" +botocore = ">=1.18.1" +cloudflare = ">=4.1.0,<5.0.0" +crowdstrike-falconpy = ">=0.5.1" +dnspython = ">=1.15.0" +duo-client = "*" +google-api-python-client = ">=1.7.8" +google-auth = ">=2.37.0" +google-cloud-asset = ">=1.0.0" +google-cloud-resource-manager = ">=1.14.2" +httpx = ">=0.24.0" +kubernetes = ">=22.6.0" +marshmallow = ">=3.0.0rc7" +msgraph-sdk = "*" +msrestazure = ">=0.6.4" +neo4j = ">=5.28.2,<6.0.0" +oci = ">=2.71.0" +okta = "<1.0.0" +packaging = "*" +pdpyras = ">=4.3.0" +policyuniverse = ">=1.1.0.0" +python-dateutil = "*" +python-digitalocean = ">=1.16.0" +pyyaml = ">=5.3.1" +requests = ">=2.22.0" +scaleway = ">=2.10.0" +slack-sdk = ">=3.37.0" +statsd = "*" +typer = ">=0.9.0" +types-aiobotocore-ecr = "*" +xmltodict = "*" + +[package.source] +type = "git" +url = "https://github.com/prowler-cloud/cartography" +reference = "master" +resolved_reference = "c134846c0db64747340f880cf0b5085f5e473e03" + [[package]] name = "celery" version = "5.4.0" @@ -1851,6 +2159,26 @@ prompt-toolkit = ">=3.0.36" [package.extras] testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] +[[package]] +name = "cloudflare" +version = "4.3.1" +description = "The official Python library for the cloudflare API" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6"}, + {file = "cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +httpx = ">=0.23.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +typing-extensions = ">=4.10,<5" + [[package]] name = "colorama" version = "0.4.6" @@ -1862,7 +2190,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "contextlib2" @@ -2048,6 +2376,25 @@ files = [ [package.extras] dev = ["polib"] +[[package]] +name = "crowdstrike-falconpy" +version = "1.5.4" +description = "The CrowdStrike Falcon SDK for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "crowdstrike_falconpy-1.5.4-py3-none-any.whl", hash = "sha256:f698d4571d0dfac1dc450a69329959e1827d49a92ce9f81d2f9963f6da41b670"}, + {file = "crowdstrike_falconpy-1.5.4.tar.gz", hash = "sha256:b357850664639af5e8441d1ed5efb905b03af74c7c5f49b4ade161202e8e45c7"}, +] + +[package.dependencies] +requests = "*" +urllib3 = "*" + +[package.extras] +dev = ["bandit", "coverage", "flake8", "pydocstyle", "pylint", "pytest", "pytest-cov"] + [[package]] name = "cryptography" version = "44.0.1" @@ -2328,13 +2675,14 @@ bcrypt = ["bcrypt"] [[package]] name = "django-allauth" -version = "65.11.0" +version = "65.13.0" description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "django_allauth-65.11.0.tar.gz", hash = "sha256:d08ee0b60a1a54f84720bb749518628c517c9af40b6cfb3bc980206e182745ab"}, + {file = "django_allauth-65.13.0-py3-none-any.whl", hash = "sha256:119c0cf1cc2e0d1a0fe2f13588f30951d64989256084de2d60f13ab9308f9fa0"}, + {file = "django_allauth-65.13.0.tar.gz", hash = "sha256:7d7b7e7ad603eb3864c142f051e2cce7be2f9a9c6945a51172ec83d48c6c843b"}, ] [package.dependencies] @@ -2346,6 +2694,7 @@ python3-saml = {version = ">=1.15.0,<2.0.0", optional = true, markers = "extra = requests = {version = ">=2.0.0,<3", optional = true, markers = "extra == \"socialaccount\""} [package.extras] +headless = ["pyjwt[crypto] (>=2.0,<3)"] headless-spec = ["PyYAML (>=6,<7)"] idp-oidc = ["oauthlib (>=3.3.0,<4)", "pyjwt[crypto] (>=2.0,<3)"] mfa = ["fido2 (>=1.1.2,<3)", "qrcode (>=7.0.0,<9)"] @@ -2780,6 +3129,21 @@ merge = ["merge3"] paramiko = ["paramiko"] pgp = ["gpg"] +[[package]] +name = "duo-client" +version = "5.5.0" +description = "Reference client for Duo Security APIs" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "duo_client-5.5.0-py3-none-any.whl", hash = "sha256:4fbf1e97a2b25ef64e9f88171ab817162cf45bafc1c63026af4883baf8892a12"}, + {file = "duo_client-5.5.0.tar.gz", hash = "sha256:303109e047fe7525ba4fc4a294c1f3deb4125066e89c10d33f7430378867b1d6"}, +] + +[package.dependencies] +setuptools = "*" + [[package]] name = "durationpy" version = "0.10" @@ -2825,21 +3189,16 @@ testing = ["hatch", "pre-commit", "pytest", "tox"] [[package]] name = "filelock" -version = "3.12.4" +version = "3.20.3" description = "A platform independent file lock." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, - {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, ] -[package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] -typing = ["typing-extensions (>=4.7.1) ; python_version < \"3.11\""] - [[package]] name = "flask" version = "3.1.2" @@ -3147,6 +3506,8 @@ files = [ [package.dependencies] google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.56.2,<2.0.0" +grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = ">=1.22.3,<2.0.0" protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" @@ -3219,6 +3580,103 @@ files = [ google-auth = "*" httplib2 = ">=0.19.0" +[[package]] +name = "google-cloud-access-context-manager" +version = "0.3.0" +description = "Google Cloud Access Context Manager Protobufs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_access_context_manager-0.3.0-py3-none-any.whl", hash = "sha256:5d15ad51547f06c281e35f16b4ffcb3e98bb2d898b01470f88b94edfb2eeb0a3"}, + {file = "google_cloud_access_context_manager-0.3.0.tar.gz", hash = "sha256:f3aa35c9225b7aaef85ecdacedcc1577789be8d458b7a41b6ad23b504786e5f9"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-asset" +version = "4.1.0" +description = "Google Cloud Asset API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_asset-4.1.0-py3-none-any.whl", hash = "sha256:2ce567bf002ad5099e173f6106393a7abc4782598c5d0d27de7d86ac26736e06"}, + {file = "google_cloud_asset-4.1.0.tar.gz", hash = "sha256:00ba110085ff9f284b49961bcb9d2da5b5863fb91643c16d173ed38d73bfe35c"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +google-cloud-access-context-manager = ">=0.1.2,<1.0.0" +google-cloud-org-policy = ">=0.1.2,<2.0.0" +google-cloud-os-config = ">=1.0.0,<2.0.0" +grpc-google-iam-v1 = ">=0.14.0,<1.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-org-policy" +version = "1.15.0" +description = "Google Cloud Org Policy API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_org_policy-1.15.0-py3-none-any.whl", hash = "sha256:5da410288236b334b8d05010501ea6180c5dc9e30888ff09488f2f107632f35b"}, + {file = "google_cloud_org_policy-1.15.0.tar.gz", hash = "sha256:271d16a10e75347eace60d02cde322b2b1b613bcc99917109e0ebf2a4102253a"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-os-config" +version = "1.22.0" +description = "Google Cloud Os Config API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_os_config-1.22.0-py3-none-any.whl", hash = "sha256:92afa402aa3b94d765751907fded1ef5908a6a5322f1fc88dee9e4c7f1cd7e54"}, + {file = "google_cloud_os_config-1.22.0.tar.gz", hash = "sha256:d79a310f6fa1ce7470aaa084c70e38dc05d98531f468f821b3a526e4d33a70e4"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-resource-manager" +version = "1.15.0" +description = "Google Cloud Resource Manager API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_resource_manager-1.15.0-py3-none-any.whl", hash = "sha256:0ccde5db644b269ddfdf7b407a2c7b60bdbf459f8e666344a5285601d00c7f6d"}, + {file = "google_cloud_resource_manager-1.15.0.tar.gz", hash = "sha256:3d0b78c3daa713f956d24e525b35e9e9a76d597c438837171304d431084cedaf"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpc-google-iam-v1 = ">=0.14.0,<1.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + [[package]] name = "googleapis-common-protos" version = "1.70.0" @@ -3232,6 +3690,7 @@ files = [ ] [package.dependencies] +grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [package.extras] @@ -3346,6 +3805,117 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil", "setuptools"] +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +description = "IAM API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6"}, + {file = "grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389"}, +] + +[package.dependencies] +googleapis-common-protos = {version = ">=1.56.0,<2.0.0", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "grpcio" +version = "1.76.0" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"}, + {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"}, + {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"}, + {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"}, + {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"}, + {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"}, + {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"}, + {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"}, + {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"}, + {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"}, + {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"}, + {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"}, + {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"}, + {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"}, + {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"}, + {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"}, + {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"}, + {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"}, + {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"}, + {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"}, + {file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"}, + {file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"}, + {file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"}, + {file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"}, + {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.76.0)"] + +[[package]] +name = "grpcio-status" +version = "1.76.0" +description = "Status proto mapping for gRPC" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18"}, + {file = "grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.76.0" +protobuf = ">=6.31.1,<7.0.0" + [[package]] name = "gunicorn" version = "23.0.0" @@ -3374,7 +3944,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -3414,7 +3984,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -3451,7 +4021,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -3471,6 +4041,21 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "humanfriendly" +version = "10.0" +description = "Human friendly output for text interfaces using Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} + [[package]] name = "hyperframe" version = "6.1.0" @@ -3714,6 +4299,37 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "joblib" +version = "1.5.3" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, + {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, +] + +[[package]] +name = "jsonpickle" +version = "4.1.1" +description = "jsonpickle encodes/decodes any Python object to/from JSON" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91"}, + {file = "jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1"}, +] + +[package.extras] +cov = ["pytest-cov"] +dev = ["black", "pyupgrade"] +docs = ["furo", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +packaging = ["build", "setuptools (>=61.2)", "setuptools_scm[toml] (>=6.0)", "twine"] +testing = ["PyYAML", "atheris (>=2.3.0,<2.4.0) ; python_version < \"3.12\"", "bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=6.0,!=8.1.*)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy (>=1.9.3) ; python_version > \"3.10\"", "scipy ; python_version <= \"3.10\"", "simplejson", "sqlalchemy", "ujson"] + [[package]] name = "jsonschema" version = "4.23.0" @@ -3862,6 +4478,26 @@ files = [ {file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"}, ] +[[package]] +name = "knack" +version = "0.11.0" +description = "A Command-Line Interface framework" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "knack-0.11.0-py3-none-any.whl", hash = "sha256:6704c867840978a119a193914a90e2e98c7be7dff764c8fcd8a2286c5a978d00"}, + {file = "knack-0.11.0.tar.gz", hash = "sha256:eb6568001e9110b1b320941431c51033d104cc98cda2254a5c2b09ba569fd494"}, +] + +[package.dependencies] +argcomplete = "*" +jmespath = "*" +packaging = "*" +pygments = "*" +pyyaml = "*" +tabulate = "*" + [[package]] name = "kombu" version = "5.5.4" @@ -4175,7 +4811,7 @@ version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, @@ -4270,7 +4906,7 @@ version = "3.26.2" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, @@ -4381,7 +5017,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -4501,21 +5137,38 @@ files = [ [package.dependencies] microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" +[[package]] +name = "microsoft-security-utilities-secret-masker" +version = "1.0.0b4" +description = "A tool for detecting and masking secrets" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "microsoft_security_utilities_secret_masker-1.0.0b4-py3-none-any.whl", hash = "sha256:0429fcaad10fc8ae3f940ab84fd2926e4f50ede134162144123b35937be831a8"}, + {file = "microsoft_security_utilities_secret_masker-1.0.0b4.tar.gz", hash = "sha256:a30bd361ac18c8b52f6844076bc26465335949ea9c7a004d95f5196ec6fdef3e"}, +] + [[package]] name = "msal" -version = "1.33.0" +version = "1.34.0b1" description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "msal-1.33.0-py3-none-any.whl", hash = "sha256:c0cd41cecf8eaed733ee7e3be9e040291eba53b0f262d3ae9c58f38b04244273"}, - {file = "msal-1.33.0.tar.gz", hash = "sha256:836ad80faa3e25a7d71015c990ce61f704a87328b1e73bcbb0623a18cbf17510"}, + {file = "msal-1.34.0b1-py3-none-any.whl", hash = "sha256:3b6373325e3509d97873e36965a75e9cc9393f1b579d12cc03c0ca0ef6d37eb4"}, + {file = "msal-1.34.0b1.tar.gz", hash = "sha256:86cdbfec14955e803379499d017056c6df4ed40f717fd6addde94bdeb4babd78"}, ] [package.dependencies] cryptography = ">=2.5,<48" PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]} +pymsalruntime = [ + {version = ">=0.14,<0.19", optional = true, markers = "python_version >= \"3.6\" and platform_system == \"Windows\" and extra == \"broker\""}, + {version = ">=0.17,<0.19", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Darwin\" and extra == \"broker\""}, + {version = ">=0.18,<0.19", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Linux\" and extra == \"broker\""}, +] requests = ">=2.0.0,<3" [package.extras] @@ -4523,21 +5176,19 @@ broker = ["pymsalruntime (>=0.14,<0.19) ; python_version >= \"3.6\" and platform [[package]] name = "msal-extensions" -version = "1.3.1" +version = "1.2.0" description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." optional = false -python-versions = ">=3.9" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, - {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, + {file = "msal_extensions-1.2.0-py3-none-any.whl", hash = "sha256:cf5ba83a2113fa6dc011a254a72f1c223c88d7dfad74cc30617c4679a417704d"}, + {file = "msal_extensions-1.2.0.tar.gz", hash = "sha256:6f41b320bfd2933d631a215c91ca0dd3e67d84bd1a2f50ce917d5874ec646bef"}, ] [package.dependencies] msal = ">=1.29,<2" - -[package.extras] -portalocker = ["portalocker (>=1.4,<4)"] +portalocker = ">=1.4,<3" [[package]] name = "msgraph-core" @@ -4605,6 +5256,23 @@ requests-oauthlib = ">=0.5.0" [package.extras] async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] +[[package]] +name = "msrestazure" +version = "0.6.4.post1" +description = "AutoRest swagger generator Python client runtime. Azure-specific module." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "msrestazure-0.6.4.post1-py2.py3-none-any.whl", hash = "sha256:2264493b086c2a0a82ddf5fd87b35b3fffc443819127fed992ac5028354c151e"}, + {file = "msrestazure-0.6.4.post1.tar.gz", hash = "sha256:39842007569e8c77885ace5c46e4bf2a9108fcb09b1e6efdf85b6e2c642b55d4"}, +] + +[package.dependencies] +adal = ">=0.6.0,<2.0.0" +msrest = ">=0.6.0,<2.0.0" +six = "*" + [[package]] name = "multidict" version = "6.6.4" @@ -4809,6 +5477,26 @@ pyspark = ["pyspark (>=3.5.0)"] pyspark-connect = ["pyspark[connect] (>=3.5.0)"] sqlframe = ["sqlframe (>=3.22.0)"] +[[package]] +name = "neo4j" +version = "5.28.2" +description = "Neo4j Bolt driver for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "neo4j-5.28.2-py3-none-any.whl", hash = "sha256:5c53b5c3eee6dee7e920c9724391aa38d7135a651e71b766da00533b92a91a94"}, + {file = "neo4j-5.28.2.tar.gz", hash = "sha256:7d38e27e4f987a45cc9052500c6ee27325cb23dae6509037fe31dd7ddaed70c7"}, +] + +[package.dependencies] +pytz = "*" + +[package.extras] +numpy = ["numpy (>=1.7.0,<3.0.0)"] +pandas = ["numpy (>=1.7.0,<3.0.0)", "pandas (>=1.1.0,<3.0.0)"] +pyarrow = ["pyarrow (>=1.0.0)"] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -4821,6 +5509,32 @@ files = [ {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, ] +[[package]] +name = "nltk" +version = "3.9.2" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"}, + {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + [[package]] name = "numpy" version = "2.0.2" @@ -4916,6 +5630,22 @@ pytz = ">=2016.10" [package.extras] adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""] +[[package]] +name = "okta" +version = "0.0.4" +description = "Okta client APIs" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "okta-0.0.4.tar.gz", hash = "sha256:53e792c68d3684ff4140b4cb1c02af3821090368f8110fde54c0bdb638449332"}, +] + +[package.dependencies] +python-dateutil = ">=2.4.2" +requests = ">=2.5.3" +six = ">=1.9.0" + [[package]] name = "openai" version = "1.101.0" @@ -5106,6 +5836,22 @@ files = [ [package.dependencies] setuptools = "*" +[[package]] +name = "pdpyras" +version = "5.4.1" +description = "PagerDuty Python REST API Sessions." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "pdpyras-5.4.1-py2.py3-none-any.whl", hash = "sha256:e16020cf57e4c916ab3dace7c7dffe21a2e7059ab7411ce3ddf1e620c54e9c89"}, + {file = "pdpyras-5.4.1.tar.gz", hash = "sha256:36021aff5979a79f1d87edc95e0c46e98ce8549292bc0cab3d9f33501795703b"}, +] + +[package.dependencies] +requests = "*" +urllib3 = "*" + [[package]] name = "pillow" version = "11.3.0" @@ -5231,6 +5977,21 @@ tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "ole typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] +[[package]] +name = "pkginfo" +version = "1.12.1.2" +description = "Query metadata from sdists / bdists / installed packages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, + {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, +] + +[package.extras] +testing = ["pytest", "pytest-cov", "wheel"] + [[package]] name = "platformdirs" version = "4.3.8" @@ -5288,6 +6049,42 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "policyuniverse" +version = "1.5.1.20231109" +description = "Parse and Process AWS IAM Policies, Statements, ARNs, and wildcards." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321"}, + {file = "policyuniverse-1.5.1.20231109.tar.gz", hash = "sha256:74e56d410560915c2c5132e361b0130e4bffe312a2f45230eac50d7c094bc40a"}, +] + +[package.extras] +dev = ["black", "pre-commit"] +tests = ["bandit", "coveralls", "pytest"] + +[[package]] +name = "portalocker" +version = "2.10.1" +description = "Wraps the portalocker recipe for easy usage" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "portalocker-2.10.1-py3-none-any.whl", hash = "sha256:53a5984ebc86a025552264b459b46a2086e269b21823cb572f8f28ee759e45bf"}, + {file = "portalocker-2.10.1.tar.gz", hash = "sha256:ef1bf844e878ab08aee7e40184156e1151f228f103aa5c6bd0724cc330960f8f"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + +[package.extras] +docs = ["sphinx (>=1.7.1)"] +redis = ["redis"] +tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"] + [[package]] name = "prompt-toolkit" version = "3.0.51" @@ -5501,8 +6298,9 @@ azure-mgmt-subscription = "3.1.1" azure-mgmt-web = "8.0.0" azure-monitor-query = "2.0.0" azure-storage-blob = "12.24.1" -boto3 = "1.39.15" -botocore = "1.39.15" +boto3 = "1.40.61" +botocore = "1.40.61" +cloudflare = "4.3.1" colorama = "0.4.6" cryptography = "44.0.1" dash = "3.1.1" @@ -5528,7 +6326,7 @@ python-dateutil = ">=2.9.0.post0,<3.0.0" pytz = "2025.1" schema = "0.7.5" shodan = "1.31.0" -slack-sdk = "3.34.0" +slack-sdk = "3.39.0" tabulate = "0.9.0" tzlocal = "5.3.1" @@ -5536,37 +6334,38 @@ tzlocal = "5.3.1" type = "git" url = "https://github.com/prowler-cloud/prowler.git" reference = "master" -resolved_reference = "d7f0b5b19094f92b460ee266935d2db4c4ad1de9" +resolved_reference = "2c4f866e42c25973d6eb3dc7d58e7ce72b1ed3f0" [[package]] name = "psutil" -version = "6.0.0" +version = "6.1.1" description = "Cross-platform lib for process and system monitoring in Python." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -groups = ["main", "dev"] +groups = ["main"] files = [ - {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, - {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, - {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, - {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, - {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, - {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, - {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, - {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, - {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, - {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, - {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, - {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, - {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, - {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, - {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, - {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, - {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, + {file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"}, + {file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"}, + {file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"}, + {file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"}, + {file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"}, + {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"}, + {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"}, + {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"}, ] [package.extras] -test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""] +dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] +test = ["pytest", "pytest-xdist", "setuptools"] [[package]] name = "psycopg2-binary" @@ -5650,6 +6449,18 @@ files = [ {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, ] +[[package]] +name = "py-deviceid" +version = "0.1.1" +description = "A simple library to get or create a unique device id for a device in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "py_deviceid-0.1.1-py3-none-any.whl", hash = "sha256:c0e32815e87a08087a0811c18f4402ee88b28a321f997753d75ecdaab570321b"}, + {file = "py_deviceid-0.1.1.tar.gz", hash = "sha256:c3e7577ada23666e7f39e69370dfdaa76fe9de79c02635376d6aa0229bfa30e3"}, +] + [[package]] name = "py-iam-expand" version = "0.1.0" @@ -5684,14 +6495,14 @@ pydantic = ">=2.9.2,<3.0.0" [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.2" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, + {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, + {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, ] [[package]] @@ -5741,7 +6552,7 @@ description = "PycURL -- A Python Interface To The cURL library" optional = false python-versions = ">=3.5" groups = ["main"] -markers = "sys_platform != \"win32\" and platform_python_implementation == \"CPython\"" +markers = "platform_python_implementation == \"CPython\" and sys_platform != \"win32\"" files = [ {file = "pycurl-7.45.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c31b390f1e2cd4525828f1bb78c1f825c0aab5d1588228ed71b22c4784bdb593"}, {file = "pycurl-7.45.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:942b352b69184cb26920db48e0c5cb95af39874b57dbe27318e60f1e68564e37"}, @@ -5778,133 +6589,122 @@ files = [ [[package]] name = "pydantic" -version = "2.11.7" +version = "2.9.2" description = "Data validation using Python type hints" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" +pydantic-core = "2.23.4" +typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.23.4" description = "Core functionality for Pydantic validation and serialization" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, ] [package.dependencies] @@ -5936,7 +6736,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -5994,6 +6794,47 @@ tomlkit = ">=0.10.1" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] +[[package]] +name = "pymsalruntime" +version = "0.18.1" +description = "The MSALRuntime Python Interop Package" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "(platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\") and sys_platform == \"win32\"" +files = [ + {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0c22e2e83faa10de422bbfaacc1bb2887c9025ee8a53f0fc2e4f7db01c4a7b66"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8ce2944a0f944833d047bb121396091e00287e2b6373716106da86ea99abf379"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:9f7945ae0ee78357e9ca87d381f1c19763629a7197391ae7f84f4967a9f06e5b"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-win32.whl", hash = "sha256:10020abdfc34bbbf3414b86359de551d2d8bc7c241bc38c59a2468c4d49f21d5"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:f9aec2f44470d71feae35b611d1d8f15a549d96446e4f60e1ca1fb71856fffed"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e9320fb187fe1298d2165fa248af00907ca15d3a903a1d35fed86f6bc20b5880"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:9b2cecf3a570b7812d2007764df6dfbc27fca401a0d74532d5403aa20a9ef380"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:6f66fd99668abc3d4b8d93a9eb80c75178dc63186c79e6dbe133427b279835e0"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-win32.whl", hash = "sha256:74416947b1071054f3258cac3448a7adf708888727bf283267df2bb27f0998f1"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:beb926655aae3367b7e4bda2baad86f9271beefee1121f71642da0ed4de37fd2"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6c07651cf4e07690d1b022da0977f56820ef553ac6dcbf4c9e68e9611020997"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0b6c4f54ec13309cc7b717ac8760c2d9856d4924cefa2b794b6d03db4cfdeef8"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:06c73a47f024fcf36006b89fe32f2f6f6a004aa661cf8a03d3e496d1ef84cfe8"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-win32.whl", hash = "sha256:ace12bf9b7fcbf1bf21a03c227717e09ba99acd9190623fe0821a08832ece4eb"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:f9fd8ea52395f52f7d62498e47754adf2bfe6530816ff57eff1ba6f524aee51b"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:047a98b6709cddf6a1f50f78ee16d06fea0f42a44971b6d3e2988537277a1a17"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:910e653c65cd66fa9ce46dec103d3948da2276f7d4d315631a145eaab968d9a8"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:7ae0b160983ea0715d8ac69b441bbd29e7a9f31c9a5a2c350c79a794f5599f38"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-win32.whl", hash = "sha256:adf4200a1b423fe5d8e984c142cc64f0b76a9b0f7f8ff767490a2dde94fa642b"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:5a759aa551d084b160799f6df59c9891898ab305eb75ff1705bf04281675eb4b"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_arm64.whl", hash = "sha256:12b8990c4da1327ea46f6271bd57b28a90d3e795deacb370052914c3ff40d4c5"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:8dd68f9fedc200950093378b30a2ade4517324cef060788a759b575ea58dc6b2"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-manylinux_2_35_x86_64.whl", hash = "sha256:7183b1b1542a277db119fe55285c7609c661b8506b99cd7e53b7066ce6b838e4"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-win32.whl", hash = "sha256:56c3d708ba86311f049b004de81aa97655fed82782d3ec67e14ae1e27d4f5e5b"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:a8adc80fcf723b980976b81a0b409affe80f32d89ae6096d856fd20471d2f0c1"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:600d0f2b9b03dfb457ee1e13f191c2c217c0f6bceca512f1741e5215bc4bc5dc"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:daae8515ae8adac8662d8230f22af242f87c72d86f308ec51b7432f316199c1b"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-manylinux_2_35_x86_64.whl", hash = "sha256:864b8b9555a180c6baf8a57df3976b2e511582d54099561fbfe73f9f0b95c9f5"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-win32.whl", hash = "sha256:b90a3c8079ded9d5abc765bd90fdc34f6e49412793740ddbc6122a601008d50f"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:852dc82b3eaad0cce2c583314705183bf216e7fa7178040defd3a13195c1c406"}, +] + [[package]] name = "pynacl" version = "1.6.2" @@ -6070,6 +6911,35 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyreadline3" +version = "3.5.4" +description = "A python implementation of GNU readline." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, + {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, +] + +[package.extras] +dev = ["build", "flake8", "mypy", "pytest", "twine"] + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + [[package]] name = "pytest" version = "8.2.2" @@ -6263,6 +7133,22 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-digitalocean" +version = "1.17.0" +description = "digitalocean.com API to manage Droplets and Images" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python-digitalocean-1.17.0.tar.gz", hash = "sha256:107854fde1aafa21774e8053cf253b04173613c94531f75d5a039ad770562b24"}, + {file = "python_digitalocean-1.17.0-py3-none-any.whl", hash = "sha256:0032168e022e85fca314eb3f8dfaabf82087f2ed40839eb28f1eeeeca5afb1fa"}, +] + +[package.dependencies] +jsonpickle = "*" +requests = "*" + [[package]] name = "python-memcached" version = "1.62" @@ -6315,7 +7201,6 @@ description = "Python for Window Extensions" optional = false python-versions = "*" groups = ["main", "dev"] -markers = "sys_platform == \"win32\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -6338,6 +7223,7 @@ files = [ {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, ] +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "pyyaml" @@ -6439,6 +7325,147 @@ attrs = ">=22.2.0" rpds-py = ">=0.7.0" typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} +[[package]] +name = "regex" +version = "2026.1.15" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"}, + {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"}, + {file = "regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3"}, + {file = "regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218"}, + {file = "regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a"}, + {file = "regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1"}, + {file = "regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569"}, + {file = "regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7"}, + {file = "regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22"}, + {file = "regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913"}, + {file = "regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a"}, + {file = "regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3"}, + {file = "regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f"}, + {file = "regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e"}, + {file = "regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60"}, + {file = "regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952"}, + {file = "regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10"}, + {file = "regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1"}, + {file = "regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1"}, + {file = "regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903"}, + {file = "regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db"}, + {file = "regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e"}, + {file = "regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf"}, + {file = "regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55b4ea996a8e4458dd7b584a2f89863b1655dd3d17b88b46cbb9becc495a0ec5"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1e28be779884189cdd57735e997f282b64fd7ccf6e2eef3e16e57d7a34a815"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0057de9eaef45783ff69fa94ae9f0fd906d629d0bd4c3217048f46d1daa32e9b"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7cd0b2be0f0269283a45c0d8b2c35e149d1319dcb4a43c9c3689fa935c1ee6"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8db052bbd981e1666f09e957f3790ed74080c2229007c1dd67afdbf0b469c48b"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:343db82cb3712c31ddf720f097ef17c11dab2f67f7a3e7be976c4f82eba4e6df"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e9d0118d97794367309635df398bdfd7c33b93e2fdfa0b239661cd74b4c14e"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:008b185f235acd1e53787333e5690082e4f156c44c87d894f880056089e9bc7c"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fd65af65e2aaf9474e468f9e571bd7b189e1df3a61caa59dcbabd0000e4ea839"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f42e68301ff4afee63e365a5fc302b81bb8ba31af625a671d7acb19d10168a8c"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f7792f27d3ee6e0244ea4697d92b825f9a329ab5230a78c1a68bd274e64b5077"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dbaf3c3c37ef190439981648ccbf0c02ed99ae066087dd117fcb616d80b010a4"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:adc97a9077c2696501443d8ad3fa1b4fc6d131fc8fd7dfefd1a723f89071cf0a"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:069f56a7bf71d286a6ff932a9e6fb878f151c998ebb2519a9f6d1cee4bffdba3"}, + {file = "regex-2026.1.15-cp39-cp39-win32.whl", hash = "sha256:ea4e6b3566127fda5e007e90a8fd5a4169f0cf0619506ed426db647f19c8454a"}, + {file = "regex-2026.1.15-cp39-cp39-win_amd64.whl", hash = "sha256:cda1ed70d2b264952e88adaa52eea653a33a1b98ac907ae2f86508eb44f65cdc"}, + {file = "regex-2026.1.15-cp39-cp39-win_arm64.whl", hash = "sha256:b325d4714c3c48277bfea1accd94e193ad6ed42b4bad79ad64f3b8f8a31260a5"}, + {file = "regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5"}, +] + [[package]] name = "reportlab" version = "4.4.4" @@ -6478,6 +7505,7 @@ files = [ certifi = ">=2017.4.17" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" +PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7", optional = true, markers = "extra == \"socks\""} urllib3 = ">=1.21.1,<3" [package.extras] @@ -6536,7 +7564,7 @@ version = "14.1.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, @@ -6835,14 +7863,14 @@ files = [ [[package]] name = "s3transfer" -version = "0.13.1" +version = "0.14.0" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"}, - {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"}, + {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, + {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, ] [package.dependencies] @@ -6853,34 +7881,34 @@ crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "safety" -version = "3.2.9" -description = "Checks installed dependencies for known vulnerabilities and licenses." +version = "3.7.0" +description = "Scan dependencies for known vulnerabilities and licenses." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "safety-3.2.9-py3-none-any.whl", hash = "sha256:5e199c057550dc6146c081084274279dfb98c17735193b028db09a55ea508f1a"}, - {file = "safety-3.2.9.tar.gz", hash = "sha256:494bea752366161ac9e0742033d2a82e4dc51d7c788be42e0ecf5f3ef36b8071"}, + {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"}, + {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"}, ] [package.dependencies] -Authlib = ">=1.2.0" -Click = ">=8.0.2" -dparse = ">=0.6.4b0" -filelock = ">=3.12.2,<3.13.0" +authlib = ">=1.2.0" +click = ">=8.0.2" +dparse = ">=0.6.4" +filelock = ">=3.16.1,<4.0" +httpx = "*" jinja2 = ">=3.1.0" marshmallow = ">=3.15.0" +nltk = ">=3.9" packaging = ">=21.0" -psutil = ">=6.0.0,<6.1.0" -pydantic = ">=1.10.12" +pydantic = ">=2.6.0" requests = "*" -rich = "*" -"ruamel.yaml" = ">=0.17.21" -safety-schemas = ">=0.0.4" -setuptools = ">=65.5.1" -typer = "*" +ruamel-yaml = ">=0.17.21" +safety-schemas = "0.0.16" +tenacity = ">=8.1.0" +tomlkit = "*" +typer = ">=0.16.0" typing-extensions = ">=4.7.1" -urllib3 = ">=1.26.5" [package.extras] github = ["pygithub (>=1.43.3)"] @@ -6889,23 +7917,55 @@ spdx = ["spdx-tools (>=0.8.2)"] [[package]] name = "safety-schemas" -version = "0.0.5" +version = "0.0.16" description = "Schemas for Safety tools" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "safety_schemas-0.0.5-py3-none-any.whl", hash = "sha256:6ac9eb71e60f0d4e944597c01dd48d6d8cd3d467c94da4aba3702a05a3a6ab4f"}, - {file = "safety_schemas-0.0.5.tar.gz", hash = "sha256:0de5fc9a53d4423644a8ce9a17a2e474714aa27e57f3506146e95a41710ff104"}, + {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"}, + {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"}, ] [package.dependencies] -dparse = ">=0.6.4b0" +dparse = ">=0.6.4" packaging = ">=21.0" -pydantic = "*" +pydantic = ">=2.6.0" ruamel-yaml = ">=0.17.21" typing-extensions = ">=4.7.1" +[[package]] +name = "scaleway" +version = "2.10.3" +description = "Scaleway SDK for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "scaleway-2.10.3-py3-none-any.whl", hash = "sha256:dbf381440d6caf37c878cf16445a63f4969a4aac2257c9b72c744d10ff223a0c"}, + {file = "scaleway-2.10.3.tar.gz", hash = "sha256:b1f9dd1b1450767205234c6f5a345e5e25dc039c780253d698893b5c344ce594"}, +] + +[package.dependencies] +scaleway-core = "2.10.3" + +[[package]] +name = "scaleway-core" +version = "2.10.3" +description = "Scaleway SDK for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "scaleway_core-2.10.3-py3-none-any.whl", hash = "sha256:fd4112144554d6adae22ff737555eeb0e38cb1063250b3e88c9aebc1b957793b"}, + {file = "scaleway_core-2.10.3.tar.gz", hash = "sha256:56432f755d694669429de51d51c1d0b3361b28dc2f939b28e4cb954610ee76be"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.2,<3.0.0" +PyYAML = ">=6.0,<7.0" +requests = ">=2.28.1,<3.0.0" + [[package]] name = "schema" version = "0.7.5" @@ -7006,7 +8066,7 @@ version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -7045,18 +8105,18 @@ files = [ [[package]] name = "slack-sdk" -version = "3.34.0" +version = "3.39.0" description = "The Slack API Platform SDK for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa"}, - {file = "slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d"}, + {file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"}, + {file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"}, ] [package.extras] -optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<15)"] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"] [[package]] name = "sniffio" @@ -7064,7 +8124,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -7086,6 +8146,18 @@ files = [ dev = ["build"] doc = ["sphinx"] +[[package]] +name = "statsd" +version = "4.0.1" +description = "A simple statsd client." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "statsd-4.0.1-py2.py3-none-any.whl", hash = "sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093"}, + {file = "statsd-4.0.1.tar.gz", hash = "sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128"}, +] + [[package]] name = "std-uritemplate" version = "2.0.5" @@ -7134,7 +8206,7 @@ version = "9.1.2" description = "Retry code until it succeeds" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, @@ -7206,7 +8278,7 @@ version = "0.16.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9"}, {file = "typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614"}, @@ -7218,6 +8290,21 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" +[[package]] +name = "types-aiobotocore-ecr" +version = "3.0.0" +description = "Type annotations for aiobotocore ECR 3.0.0 service generated with mypy-boto3-builder 8.12.0" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "types_aiobotocore_ecr-3.0.0-py3-none-any.whl", hash = "sha256:06288369b9ddf78661224ac99a61aefe3c8a49e872d5c7a1626435ea848a817e"}, + {file = "types_aiobotocore_ecr-3.0.0.tar.gz", hash = "sha256:a9f49aa3c83c6b6ab1cc7f10cc887ca35a549e0a29dfcdab40b285ce0846d06c"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.12\""} + [[package]] name = "typing-extensions" version = "4.14.1" @@ -7230,21 +8317,6 @@ files = [ {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] -[[package]] -name = "typing-inspection" -version = "0.4.1" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - [[package]] name = "tzdata" version = "2025.2" @@ -7563,6 +8635,21 @@ files = [ [package.dependencies] lxml = ">=3.8" +[[package]] +name = "xmltodict" +version = "1.0.2" +description = "Makes working with XML feel like you are working with JSON" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d"}, + {file = "xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649"}, +] + +[package.extras] +test = ["pytest", "pytest-cov"] + [[package]] name = "yarl" version = "1.20.1" @@ -7766,141 +8853,179 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [[package]] name = "zstd" -version = "1.5.7.2" +version = "1.5.7.3" description = "ZSTD Bindings for Python" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "zstd-1.5.7.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e17104d0e88367a7571dde4286e233126c8551691ceff11f9ae2e3a3ac1bb483"}, - {file = "zstd-1.5.7.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:d6ee5dfada4c8fa32f43cc092fcf7d8482da6ad242c22fdf780f7eebd0febcc7"}, - {file = "zstd-1.5.7.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:ae1100776cb400100e2d2f427b50dc983c005c38cd59502eb56d2cfea3402ad5"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:489a0ff15caf7640851e63f85b680c4279c99094cd500a29c7ed3ab82505fce0"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:92590cf54318849d492445c885f1a42b9dbb47cdc070659c7cb61df6e8531047"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux_2_4_i686.whl", hash = "sha256:2bc21650f7b9c058a3c4cb503e906fe9cce293941ec1b48bc5d005c3b4422b42"}, - {file = "zstd-1.5.7.2-cp27-cp27mu-manylinux_2_4_x86_64.whl", hash = "sha256:7b13e7eef9aa192804d38bf413924d347c6f6c6ac07f5a0c1ae4a6d7b3af70f0"}, - {file = "zstd-1.5.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3f14c5c405ea353b68fe105236780494eb67c756ecd346fd295498f5eab6d24"}, - {file = "zstd-1.5.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07d2061df22a3efc06453089e6e8b96e58f5bb7a0c4074dcfd0b0ce243ddde72"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:27e55aa2043ba7d8a08aba0978c652d4d5857338a8188aa84522569f3586c7bb"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e97933addfd71ea9608306f18dc18e7d2a5e64212ba2bb9a4ccb6d714f9f280"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:27e2ed58b64001c9ef0a8e028625477f1a6ed4ca949412ff6548544945cc59c2"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:92f072819fc0c7e8445f51a232c9ad76642027c069d2f36470cdb5e663839cdb"}, - {file = "zstd-1.5.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2a653cdd2c52d60c28e519d44bde8d759f2c1837f0ff8e8e1b0045ca62fcf70e"}, - {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:047803d87d910f4905f48d99aeff1e0539ec2e4f4bf17d077701b5d0b2392a95"}, - {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0d8c1dc947e5ccea3bd81043080213685faf1d43886c27c51851fabf325f05c0"}, - {file = "zstd-1.5.7.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8291d393321fac30604c6bbf40067103fee315aa476647a5eaecf877ee53496f"}, - {file = "zstd-1.5.7.2-cp310-cp310-win32.whl", hash = "sha256:6922ceac5f2d60bb57a7875168c8aa442477b83e8951f2206cf1e9be788b0a6e"}, - {file = "zstd-1.5.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:346d1e4774d89a77d67fc70d53964bfca57c0abecfd885a4e00f87fd7c71e074"}, - {file = "zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b"}, - {file = "zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a"}, - {file = "zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806"}, - {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58"}, - {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7"}, - {file = "zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29"}, - {file = "zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b"}, - {file = "zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218"}, - {file = "zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734"}, - {file = "zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583"}, - {file = "zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac"}, - {file = "zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9"}, - {file = "zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50"}, - {file = "zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38"}, - {file = "zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06"}, - {file = "zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb"}, - {file = "zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71"}, - {file = "zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174"}, - {file = "zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a"}, - {file = "zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6"}, - {file = "zstd-1.5.7.2-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:a6105b8fa21dbc59e05b6113e8e5d5aaf56c5d2886aa5778d61030af3256bbb7"}, - {file = "zstd-1.5.7.2-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:d0b0ca097efb5f67157c61a744c926848dcccf6e913df2f814e719aa78197a4b"}, - {file = "zstd-1.5.7.2-cp34-cp34m-manylinux_2_4_i686.whl", hash = "sha256:a371274668182ae06be2e321089b207fa0a75a58ae2fd4dfb7eafded9e041b2f"}, - {file = "zstd-1.5.7.2-cp34-cp34m-manylinux_2_4_x86_64.whl", hash = "sha256:74c3f006c9a3a191ed454183f0fb78172444f5cb431be04d85044a27f1b58c7b"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:f19a3e658d92b6b52020c4c6d4c159480bcd3b47658773ea0e8d343cee849f33"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d9d1bcb6441841c599883139c1b0e47bddb262cce04b37dc2c817da5802c1158"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:bb1cb423fc40468cc9b7ab51a5b33c618eefd2c910a5bffed6ed76fe1cbb20b0"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux_2_14_x86_64.whl", hash = "sha256:e2476ba12597e58c5fc7a3ae547ee1bef9dd6b9d5ea80cf8d4034930c5a336e0"}, - {file = "zstd-1.5.7.2-cp35-cp35m-manylinux_2_4_i686.whl", hash = "sha256:2bf6447373782a2a9df3015121715f6d0b80a49a884c2d7d4518c9571e9fca16"}, - {file = "zstd-1.5.7.2-cp35-cp35m-win32.whl", hash = "sha256:a59a136a9eaa1849d715c004e30344177e85ad6e7bc4a5d0b6ad2495c5402675"}, - {file = "zstd-1.5.7.2-cp35-cp35m-win_amd64.whl", hash = "sha256:114115af8c68772a3205414597f626b604c7879f6662a2a79c88312e0f50361f"}, - {file = "zstd-1.5.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f576ec00e99db124309dac1e1f34bc320eb69624189f5fdaf9ebe1dc81581a84"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f97d8593da0e23a47f148a1cb33300dccd513fb0df9f7911c274e228a8c1a300"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:a130243e875de5aeda6099d12b11bc2fcf548dce618cf6b17f731336ba5338e4"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:73cec37649fda383348dc8b3b5fba535f1dbb1bbaeb60fd36f4c145820208619"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux_2_14_x86_64.whl", hash = "sha256:883e7b77a3124011b8badd0c7c9402af3884700a3431d07877972e157d85afb8"}, - {file = "zstd-1.5.7.2-cp36-cp36m-manylinux_2_4_i686.whl", hash = "sha256:b5af6aa041b5515934afef2ef4af08566850875c3c890109088eedbe190eeefb"}, - {file = "zstd-1.5.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:53abf577aec7b30afa3c024143f4866676397c846b44f1b30d8097b5e4f5c7d7"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:660945ba16c16957c94dafc40aff1db02a57af0489aa3a896866239d47bb44b0"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3e220d2d7005822bb72a52e76410ca4634f941d8062c08e8e3285733c63b1db7"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_4_i686.whl", hash = "sha256:7e998f86a9d1e576c0158bf0b0a6a5c4685679d74ba0053a2e87f684f9bdc8eb"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_4_x86_64.whl", hash = "sha256:70d0c4324549073e05aa72e9eb6a593f89cba59da804b946d325d68467b93ad5"}, - {file = "zstd-1.5.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:b9518caabf59405eddd667bbb161d9ae7f13dbf96967fd998d095589c8d41c86"}, - {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:30d339d8e5c4b14c2015b50371fcdb8a93b451ca6d3ef813269ccbb8b3b3ef7d"}, - {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:6f5539a10b838ee576084870eed65b63c13845e30a5b552cfe40f7e6b621e61a"}, - {file = "zstd-1.5.7.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:5540ce1c99fa0b59dad2eff771deb33872754000da875be50ac8c2beab42b433"}, - {file = "zstd-1.5.7.2-cp37-cp37m-win32.whl", hash = "sha256:56c4b8cd0a88fd721213661c28b87b64fbd14b6019df39b21b0117a68162b0f2"}, - {file = "zstd-1.5.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:594f256fa72852ade60e3acb909f983d5cf6839b9fc79728dd4b48b31112058f"}, - {file = "zstd-1.5.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dc05618eb0abceb296b77e5f608669c12abc69cbf447d08151bcb14d290ab07"}, - {file = "zstd-1.5.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:70231ba799d681b6fc17456c3e39895c493b5dff400aa7842166322a952b7f2a"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5a73f0f20f71d4eef970a3fed7baac64d9a2a00b238acc4eca2bd7172bd7effb"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0a470f8938f69f632b8f88b96578a5e8825c18ddbbea7de63493f74874f963ef"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_4_i686.whl", hash = "sha256:d104f1cb2a7c142007c29a2a62dfe633155c648317a465674e583c295e5f792d"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_4_x86_64.whl", hash = "sha256:70f29e0504fc511d4b9f921e69637fca79c050e618ba23732a3f75c044814d89"}, - {file = "zstd-1.5.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:a62c2f6f7b8fc69767392084828740bd6faf35ff54d4ccb2e90e199327c64140"}, - {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f2dda0c76f87723fb7f75d7ad3bbd90f7fb47b75051978d22535099325111b41"}, - {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9cf09c2aa6f67750fe9f33fdd122f021b1a23bf7326064a8e21f7af7e77faee"}, - {file = "zstd-1.5.7.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:910bd9eac2488439f597504756b03c74aa63ed71b21e5d0aa2c7e249b3f1c13f"}, - {file = "zstd-1.5.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9838ec7eb9f1beb2f611b9bcac7a169cb3de708ccf779aead29787e4482fe232"}, - {file = "zstd-1.5.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:83a36bb1fd574422a77b36ccf3315ab687aef9a802b0c3312ca7006b74eeb109"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:6f8189bc58415758bbbd419695012194f5e5e22c34553712d9a3eb009c09808d"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:632e3c1b7e1ebb0580f6d92b781a8f7901d367cf72725d5642e6d3a32e404e45"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_4_i686.whl", hash = "sha256:df8083c40fdbfe970324f743f0b5ecc244c37736e5f3ad2670de61dde5e0b024"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_4_x86_64.whl", hash = "sha256:300db1ede4d10f8b9b3b99ca52b22f0e2303dc4f1cf6994d1f8345ce22dd5a7e"}, - {file = "zstd-1.5.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:97b908ccb385047b0c020ce3dc55e6f51078c9790722fdb3620c076be4a69ecf"}, - {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c59218bd36a7431a40591504f299de836ea0d63bc68ea76d58c4cf5262f0fa3c"}, - {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4d5a85344193ec967d05da8e2c10aed400e2d83e16041d2fdfb713cfc8caceeb"}, - {file = "zstd-1.5.7.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebf6c1d7f0ceb0af5a383d2a1edc8ab9ace655e62a41c8a4ed5a031ee2ef8006"}, - {file = "zstd-1.5.7.2-cp39-cp39-win32.whl", hash = "sha256:44a5142123d59a0dbbd9ba9720c23521be57edbc24202223a5e17405c3bdd4a6"}, - {file = "zstd-1.5.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:8dc542a9818712a9fb37563fa88cdbbbb2b5f8733111d412b718fa602b83ba45"}, - {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:24371a7b0475eef7d933c72067d363c5dc17282d2aa5d4f5837774378718509e"}, - {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:c21d44981b068551f13097be3809fadb7f81617d0c21b2c28a7d04653dde958f"}, - {file = "zstd-1.5.7.2-pp27-pypy_73-manylinux_2_14_x86_64.whl", hash = "sha256:b011bf4cfad78cdf9116d6731234ff181deb9560645ffdcc8d54861ae5d1edfc"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:426e5c6b7b3e2401b734bfd08050b071e17c15df5e3b31e63651d1fd9ba4c751"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:53375b23f2f39359ade944169bbd88f8895eed91290ee608ccbc28810ac360ba"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:1b301b2f9dbb0e848093127fb10cbe6334a697dc3aea6740f0bb726450ee9a34"}, - {file = "zstd-1.5.7.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5414c9ae27069ab3ec8420fe8d005cb1b227806cbc874a7b4c73a96b4697a633"}, - {file = "zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:9714d5642867fceb22e4ab74aebf81a2e62dc9206184d603cb39277b752d5885"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:6584fd081a6e7d92dffa8e7373d1fced6b3cbf473154b82c17a99438c5e1de51"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:52f27a198e2a72632bae12ec63ebaa31b10e3d5f3dd3df2e01376979b168e2e6"}, - {file = "zstd-1.5.7.2-pp36-pypy36_pp73-win32.whl", hash = "sha256:3b14793d2a2cb3a7ddd1cf083321b662dd20bc11143abc719456e9bfd22a32aa"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:faf3fd38ba26167c5a085c04b8c931a216f1baf072709db7a38e61dea52e316e"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:d17ac6d2584168247796174e599d4adbee00153246287e68881efaf8d48a6970"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9a24d492c63555b55e6bc73a9e82a38bf7c3e8f7cde600f079210ed19cb061f2"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c6abf4ab9a9d1feb14bc3cbcc32d723d340ce43b79b1812805916f3ac069b073"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d7131bb4e55d075cb7847555a1e17fca5b816a550c9b9ac260c01799b6f8e8d9"}, - {file = "zstd-1.5.7.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:a03608499794148f39c932c508d4eb3622e79ca2411b1d0438a2ee8cafdc0111"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:86e64c71b4d00bf28be50e4941586e7874bdfa74858274d9f7571dd5dda92086"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0f79492bf86aef6e594b11e29c5589ddd13253db3ada0c7a14fb176b132fb65e"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:8c3f4bb8508bc54c00532931da4a5261f08493363da14a5526c986765973e35d"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:787bcf55cefc08d27aca34c6dcaae1a24940963d1a73d4cec894ee458c541ac4"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f97f872cb78a4fd60b6c1024a65a4c52a971e9d991f33c7acd833ee73050f85"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5e530b75452fdcff4ea67268d9e7cb37a38e7abbac84fa845205f0b36da81aaf"}, - {file = "zstd-1.5.7.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7c1cc65fc2789dd97a98202df840537de186ed04fd1804a17fcb15d1232442c4"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:05604a693fa53b60ca083992324b08dafd15a4ac37ac4cffe4b43b9eb93d4440"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:baf4e8b46d8934d4e85373f303eb048c63897fc4191d8ab301a1bbdf30b7a3cc"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:8cc35cc25e2d4a0f68020f05cba96912a2881ebaca890d990abe37aa3aa27045"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:ceae57e369e1b821b8f2b4c59bc08acd27d8e4bf9687bfa5211bc4cdb080fe7b"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5189fb44c44ab9b6c45f734bd7093a67686193110dc90dcfaf0e3a31b2385f38"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:f51a965871b25911e06d421212f9be7f7bcd3cedc43ea441a8a73fad9952baa0"}, - {file = "zstd-1.5.7.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:624022851c51dd6d6b31dbfd793347c4bd6339095e8383e2f74faf4f990b04c6"}, - {file = "zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a"}, + {file = "zstd-1.5.7.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e72b353870286648a63261437b75f297e2967a26f210da4dfa4c08949935de7a"}, + {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:26aff5f24caeffde35f1b757499e935bc60a8e0d9e1ea8bde05dcf7d53df9325"}, + {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:586a820fbd06e3d9a9d9def572e779254bf8dee7406b8c6dc44eff6807d60c6d"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:35a147b10fd16ebb3a2595e361780388feb8f336d70772a05dfb7a8348a47bfd"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c2a80c51e2175ffcd6f08b2a4c9fbc121aad69fbbcebb3364e783a96d0488fda"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_i686.whl", hash = "sha256:5f20f74a782f3296d1585d9bbc49d422e339b154c66398c74537e433446c51ba"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_x86_64.whl", hash = "sha256:2550c2e6bfbff0904f28821005f176bfdaec1872d60053665a284fb0254a10e7"}, + {file = "zstd-1.5.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76f3535616887a1a38e8c6d0de693a23c5bb1f190651eb20d96bfc8e4ab706a0"}, + {file = "zstd-1.5.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67507937e8e4c2a8dfed8e7fa77f4043ec9e6e831a5faebf0f99138b1a25ccbd"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd0a2309c524608ce7b940abcc9f8eb5447c6ea2c834a630e0081211ab9d40ec"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2b497306580d544406b5414c8485c4037a9283ad2ca6ae4ccdf3732c9563141d"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:e9939a98ea946d1f9e8f9fecc940ae939b8e9e5ef9d71b104f7843567d764f30"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:d32c0fe8f6b805b7cbeaade462b094a843e84d893d8c6f66ab705e8777cc1850"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8aa33b1ef24602b2ef1e8aa67ea3c8f821854a4dbf70c3c8c46b96b54b6ceb5d"}, + {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1bd69fa9c4c97fd04206c919dedbf9f75f544ebb77880db51a13c1e3802cd655"}, + {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aee96742a64ede2e35dc0316ef0cd1e50089e889ce77e82ca8edf40174a1439c"}, + {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ac207573d2815a51f4f4fd4e255408396491729a01f690b9f5fb672d39e5610"}, + {file = "zstd-1.5.7.3-cp310-cp310-win32.whl", hash = "sha256:04e62e4f9eba79699d072d3c96731ed4aff99f1d334eb967489b091186a6078f"}, + {file = "zstd-1.5.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:0794b23b9950af240888087d2bd5943aa4be67273ba32cdafabdc5704778b90e"}, + {file = "zstd-1.5.7.3-cp310-cp310-win_arm64.whl", hash = "sha256:7827fd4901f3e71a7a755d26719549658f08e04fdf0870a952ed08e71b484435"}, + {file = "zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a"}, + {file = "zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5"}, + {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb"}, + {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc"}, + {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e"}, + {file = "zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d"}, + {file = "zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3"}, + {file = "zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527"}, + {file = "zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203"}, + {file = "zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8"}, + {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41"}, + {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328"}, + {file = "zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3"}, + {file = "zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c"}, + {file = "zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f"}, + {file = "zstd-1.5.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2455e56f1d265dacbd450510b8c2f632a5d8d92c23282e7723fb04af37001a2"}, + {file = "zstd-1.5.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3486dc4f1b4e52bb059f8eec1f31daa3e540062c0f522f221782cf132a8bc9a8"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_i686.whl", hash = "sha256:1cb47bf10ffcb6a782edacfe758da2c94879f7e89c6628feb3f1254daf8cc596"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:07b1378d1230ddeea8773f99d7518a3060e6468c76edd502057cb795fe278d7e"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ee34317f013e3405108f5baea53502159809cfc4510598d614257525500c70d"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c19127ca2c79855376a34a2d7a6969408094b25c1f44485b0373eba4be851b98"}, + {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e79cae70dd08cb247391312463085c624c0302e8c860d13f87f4c76502d8202"}, + {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e83e91e5daf89037c737f5529da0f80da80a78a6ad0b1d70a09860eb267dea4"}, + {file = "zstd-1.5.7.3-cp313-cp313-win32.whl", hash = "sha256:2283f3bb910c028e1b9fe76b834016012ab021025a0ea197e27a1333f85e3031"}, + {file = "zstd-1.5.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ad5fe4c36bab5dfa5a4b8d050bd07c50c1e69f94d381bc65337ab14cd69e5b1"}, + {file = "zstd-1.5.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e878172b0eb69ac2edc6576eb862e00747c7c25e638fb354630a1ea7cfddf49"}, + {file = "zstd-1.5.7.3-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:7e0a7e94d5b63b4cacf2396079ca9584d11f49f87cb4e5aa21f126a8f6b83446"}, + {file = "zstd-1.5.7.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5412c86c34cbaf6906433ef3f2c96c407f208782f06cd3e5f01f066788adb3b8"}, + {file = "zstd-1.5.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f94246befb1e473211a298c96e5768f3c63eaad814ac14d160d79ae9858e1d03"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31050e17a1a546fb82c90eee8ee3c30d22b9d0594b5937e69d38b7a5084af2a2"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ba8ec5dfd48c86d19f880713246f85d09ee06e8cd17141956258650878000d6"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_i686.whl", hash = "sha256:3005540ba406157f3e205c998709ab5f8e68b390c658c7c238eb8986092089d5"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:3934b54a3b7df039fcd4cf7b0f0a38c86ce44d26321255ffc3fac73d6cdcc59d"}, + {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e9230cd3e9153e2bed16f332558f8f3f7d869f4d15e8fa3f9c360bfa163a8b4a"}, + {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bffba70af539f14f9df5367b1add9119f14d5e35b658aef7b765417ea461e0e"}, + {file = "zstd-1.5.7.3-cp314-cp314-win32.whl", hash = "sha256:a006e70c88ab67bb56989e11d820adc7601a6a7ad5558b3c6c690b19a1dadc5b"}, + {file = "zstd-1.5.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:cb4957c330c7b94b0546c7b9529723b49e865608683b9503a251fe793da9d4db"}, + {file = "zstd-1.5.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:a785426081ab7cafe4522876ac771d701766deea9a6d8352e87744da00e6637f"}, + {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_i686.whl", hash = "sha256:b52ef154793be0399befd742328ec6f5dff95154248d6d18dd65851cf22a1a5f"}, + {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:8024a8ba9156b1b2e64e69d147df5ddedeaed107f9da02a3428fd7baf3e5b920"}, + {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_i686.whl", hash = "sha256:31ac7fbacca4759aad4b6abc13bbc05e68788e9e85a968255f7624b3b8db31df"}, + {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_x86_64.whl", hash = "sha256:d03b2927c5843ded4d1319836a33a9c21675d2f86f916a2f234a060d4c67d87c"}, + {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_i686.whl", hash = "sha256:5dfbf2564eb574fc1f45613ecf28036a82533c3dd70e7bb1c9854168c638da7a"}, + {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_x86_64.whl", hash = "sha256:7f2f5776b902f41daf7b63e75a9384b0d7c855f824f14dabefc67814b8fa5611"}, + {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_i686.whl", hash = "sha256:ffbeabcabcb644d29289277f9023aa51c04de71935695f5388da9c8428c81e0f"}, + {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_x86_64.whl", hash = "sha256:0b891ca9ad84562941367ab7be817b8748df75eb6b7ced23d5b082b4602c1c6e"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:925f83e2e749cd7109985bc96835cd2fd814435d74f0d9a1d7c8506166e97592"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:57d2ff6b96886aaec2aa4721f7c8e890a8b43b5c4ae4f3737a0733b55cd82daa"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:8cd516ba02e0f9e6df1b4a6dc0cd5e66ac6eeb55b15833a70d529aa32eddaa91"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_14_x86_64.whl", hash = "sha256:9f6ea980866f43ff7ef5e41eac54b94f9159b9807f32f691b02ca381b50b76af"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_4_i686.whl", hash = "sha256:3e650ed68b655d55556099aa62f168a352396139a879a94312322a1d02502491"}, + {file = "zstd-1.5.7.3-cp35-cp35m-win32.whl", hash = "sha256:da88b288a2844f04713df89a514dd9dc0e925ee63e119c845aef14ccbcc9183e"}, + {file = "zstd-1.5.7.3-cp35-cp35m-win_amd64.whl", hash = "sha256:96c949e8508f2d4dced3444a3bfb99d51653ac6f28ef0aa1561f5758adc8afed"}, + {file = "zstd-1.5.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7509b11b5f8313e87cce16269e222f89e7e49b51f1e6a3e7454b7c7b599d3211"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fb8aafd47ba73ff50a7994668dbec5c97f26ddcd28c03242d8f8b4138d8c723c"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:586efc62d7e93d52d0b3951ef48a4b5181866152061bda1bef49f7ea85ec0d7f"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5030d51631a09a0d7b3e47f928b6234bd78ce8b897a255fc1146e8cf772a8f4d"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_14_x86_64.whl", hash = "sha256:a8d1ee9faa89b21ff03ae3fe8d969e850c60b8c3f8a1389fa585c10eddaa2bb4"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_4_i686.whl", hash = "sha256:4504ba7a9ddd1919e919f81d3ec541313e6826f1f3cad8e3a7ebe29a3ae5cda6"}, + {file = "zstd-1.5.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:aca7d1fef13f412168ac524307586f0d57f96a89bd7e0620b2f60df3b0066c8d"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:12d2925424d02add2f835c7549106151ece9eae262e96aee34af5d84178ba824"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:30512cce4108b26ede395ac521c0997c340bd19f177a1c0260bbffcb64861d30"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_i686.whl", hash = "sha256:2e6caf5f3084e6473a6dfd15285c47122ba92f4fb97ecfca855adf415603532a"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_x86_64.whl", hash = "sha256:927c95b991e81f39b02e42c9b391f2b3569e6dbe29d7fc2dce6ca778475c0934"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2174fd7f588b2eb95a402c3d40f4676370eb50292362a0995295084b8f5d521e"}, + {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:3b05817bfdfc395999b6b3c9ea4f7c05e91bceafc3fc819906d5f0445afa4335"}, + {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:c67f0fcf4348343d25ecd35a44d33b6d31814e9ab3ee8676039de809579905a4"}, + {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:40195c0056841aad6553172963adecf31b6ae1fdb9778d657ce9a2493d1791ee"}, + {file = "zstd-1.5.7.3-cp37-cp37m-win32.whl", hash = "sha256:b6ac3ae562758184fc1570399ea9d269163b488dbb0c4a44701e89f61ca6d1d6"}, + {file = "zstd-1.5.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e9f059d9c9f6f13ae78bfa9778755462b3ea53e4a5185941169422dd97c9fd22"}, + {file = "zstd-1.5.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:99e92b97c97d83e403615c12b644e8616fc7e8a8b4fa0c0558bcb9980baf5c92"}, + {file = "zstd-1.5.7.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a6b4ff0d5704994eb0d7ba2ea0b25acd749bb78a1c325289a8cba7651f0cbbff"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:edf4b595ab29a980f6f60fa71c64ab029d9ced97fb9c7c9ae555fe1159d8379d"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3cd48ec1dce8a8a06a3978225b20f28b7764e4191c436277e0abc60539e040da"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_i686.whl", hash = "sha256:1380ecc510a3885fad326863a7f42b3391560b471aeea60b04f9c1ece439b198"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_x86_64.whl", hash = "sha256:5fdff5190698e6d48a3facb58085a6c33b62be610f40e80299d975dbc75b32c8"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:595d6495e96744fa5c9b78f38e8379f9eebfb97ae4f7ecc2639af4fd51459e07"}, + {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9bc3d6b7f2dec391b7539a0f43deb07bca1d68867082a07a286c2237f16390fd"}, + {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b8e62d533281946100c023a1168bd8935db6452bdd0f0b776afe8e80255e74c3"}, + {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3a5dcc7ddcd56f131bee612b5feadd9b65e3996c0f4c6a485e2b2f20e7a324de"}, + {file = "zstd-1.5.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dbb497482dd63abe72a209345dbafa52817bd484c1d08139da080c14b1dadc7b"}, + {file = "zstd-1.5.7.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a599489d4e7e794981536521ee5dcfa61b0a641996409669b9aba5400b5cff83"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4a7ec28ca27fc347d7325eeb06d66cd2649846d5bfe77b18beed38d1870dd876"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:703481b41e5b3d33cd4e6a0b7116e8bc33a712aba1526d5fcad3e4303dd70fa1"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_i686.whl", hash = "sha256:61b0707c090d59ba879eac4b475562c5b9c1b375d0419d78fb398f156037f7df"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_x86_64.whl", hash = "sha256:7090ac97b14dea2969ba1ed427b38efe137efcdf556dc8740d3e035b04cbc8b4"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5204bf9f3f2936ee3a28bfe43a57b78f88439c1777197295a0661d6de38caa80"}, + {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:431d4fecf764c305f29c1b9117d0d2ec5eb5523fc81516f1ee82509cb3b8e088"}, + {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2f213a32ab5e90bf165717f05fc1e3c214eeca7b6a33311e2397d89879c2f87"}, + {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f87d617dac84b571bb74dc9d6905c66906dca982143adbe8e497ba2ce888cca"}, + {file = "zstd-1.5.7.3-cp39-cp39-win32.whl", hash = "sha256:9511957b5b8b5c0d4e737dff3a330a445a44005e09278bb8c799a76eb7f99d90"}, + {file = "zstd-1.5.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:9389848cc8297199b0fe2cd2985e5944f611ed518aa508136065ea0159051904"}, + {file = "zstd-1.5.7.3-cp39-cp39-win_arm64.whl", hash = "sha256:0cdf00f53cd38ce1f9edc79f68727150b9e65f4b33a3e8b59d94d0886cf43dbf"}, + {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:c5ac39836233356d32d0fe3d2f9525373c47c19f75fde68c16cf2293b7648b86"}, + {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:62fe5b560f389fdb40384a1711b7737bd9e27861f248cb89f19fed90a4cf0830"}, + {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux_2_14_x86_64.whl", hash = "sha256:55fb8ac423800811f8b0c896b9617ecc91a1d4da15f66fb42ba162bfa5aa5a2d"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b9ec4d5ba8c170d3fdf21ae5da3c15eaea2beef9c419a5f3274a6f9e03c412a"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7ab69fc4d90eeb64b98a567751f8e48373f4bcf301597fca344b8e8342e1d5e"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da70f0918bf739bc75d7770410c9b94ea0dcb6f02d7ef70598b464bd5fcb193a"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dd5c069d0409284f1963b0b6b119f21b1da9e22a503e88933eb0696249d87d3"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46ca4a075f36f118e2ce07ba07d9ece7aeda193cea6f50b82aaee635df7b5fc2"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:4a521cb7615fc61bfe9514bea182e224894b5987fc7843b6d6da20a61206ef24"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71ea22c953a164f34eb4b8c2c3b97eaa22da6a75296ea80b3ba4473187f15046"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:781ec2644a3ce84c1cc19b0e057e1e8ea45260a8871eb6524614be75c9b432b9"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:ab74f37f2832d4a7c89d877ed9a70b1ef988fc2353678a122427039eb1dc6e36"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:521a3072fedcce025515d99242e346318d1815789033b7c0108796e151c42deb"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-win32.whl", hash = "sha256:94d404fd56765ff2952053cb2f6f980b88e3384a71af147c3ede9f6c6bea32d6"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:33f7e24d626938234c3c33df1988b79846628cf08dfab216bb19f85e7fcad65b"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c0c84fd4a87f28b8bed01cbaf128d33dfa209f03df2890dbc8c01e17a109c2d4"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0e334e45becf5a4844c8d64593eb358585e1553a7355f2172c865efc639ac051"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:15523e289509d7792418edb8c255cc1dacc65cda000428424c988208a682b8be"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2924befc3cb1a2310e1c03bd93469a2de8f0703e8805fe1f40367fbc2cece472"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:173680156dbe959c80d72a1f15ef2034fd414b9d1ee507df152e416bc37665ef"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31d66b73a9861ee61bc6486fb9d1d33eabc86e506e49a210f30a91a241b8e643"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:a820a67491c1cf7a66698478a28b7d2517b0ae2e2775d834ca4f2624ba859e72"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c385f92c37f4275d477388e46af8941580d7eeaad4c524c8f9aa50d016acbc7e"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:cecce78a3d639a3c439b1e355791e0f1ddbe8ed63d94f34c7973e92d384e6fc0"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e769fc830f5e2079612a27d6540e4147cd8dc8beacfaf73a48152f30a191e979"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:37a6750c25b561b05110313fdde4acd51246075a317e1c7a2491c96d2d863282"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0e95265e22f07cea6675baab762c9c4577a40d47824b01e0dcdf1a18b46aa041"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:878d859a7e1ebc078e0a575c05bcf3b0682b77cabd65bdbdd5e93c137ff1799b"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7efcf83189be9d842b9392ffd821b317cbd9447a49c590659abd3311e82c1676"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:a75dfdbca7dc01e7b35ca9b22e5b9792037b1515857e67b34bd737b213e49432"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5235dde49df717e5ca58f689e110bf1c4ed578170ab59e77f8a7a5055e4d8c07"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f876acad51d2184269ee6fd7e4c4aad9b7a0eca174d7d8db981ea079b57cbaf4"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2920e90ef200c7b2cbc73b4271c2271abf6195877b813ede0b5b76289e32fc8e"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1f6dd0f2845a9817f0d0920eb0efd2d8a0168b71b8d8c85d2655d9d997f127ba"}, + {file = "zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495"}, ] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "c40ff4d1cb06db047a7a39c35f6c765c259583c203da549146a0ed2e6d17a727" +content-hash = "15b849034c0969b944c45f341169a42160efe2525fe60a4cadd8a8909202e9e3" diff --git a/api/pyproject.toml b/api/pyproject.toml index 889a0eb519..4b3d9195d3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "celery[pytest] (>=5.4.0,<6.0.0)", "dj-rest-auth[with_social,jwt] (==7.0.1)", "django (==5.1.15)", - "django-allauth[saml] (>=65.8.0,<66.0.0)", + "django-allauth[saml] (>=65.13.0,<66.0.0)", "django-celery-beat (>=2.7.0,<3.0.0)", "django-celery-results (>=2.5.1,<3.0.0)", "django-cors-headers==4.4.0", @@ -36,6 +36,8 @@ dependencies = [ "drf-simple-apikey (==2.2.1)", "matplotlib (>=3.10.6,<4.0.0)", "reportlab (>=4.4.4,<5.0.0)", + "neo4j (<6.0.0)", + "cartography @ git+https://github.com/prowler-cloud/cartography@master", "gevent (>=25.9.1,<26.0.0)", "werkzeug (>=3.1.4)", "sqlparse (>=0.5.4)", @@ -47,7 +49,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.18.0" +version = "1.19.0" [project.scripts] celery = "src.backend.config.settings.celery" @@ -68,6 +70,7 @@ pytest-env = "1.1.3" pytest-randomly = "3.15.0" pytest-xdist = "3.6.1" ruff = "0.5.0" -safety = "3.2.9" -tqdm = "4.67.1" +safety = "3.7.0" +filelock = "3.20.3" vulture = "2.14" +tqdm = "4.67.1" diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index add97cf376..543c10ab88 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -30,16 +30,48 @@ class ApiConfig(AppConfig): def ready(self): from api import schema_extensions # noqa: F401 from api import signals # noqa: F401 - from api.compliance import load_prowler_compliance + from api.attack_paths import database as graph_database # Generate required cryptographic keys if not present, but only if: - # `"manage.py" not in sys.argv`: If an external server (e.g., Gunicorn) is running the app + # `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app # `os.environ.get("RUN_MAIN")`: If it's not a Django command or using `runserver`, # only the main process will do it - if "manage.py" not in sys.argv or os.environ.get("RUN_MAIN"): + if (len(sys.argv) >= 1 and "manage.py" not in sys.argv[0]) or os.environ.get( + "RUN_MAIN" + ): self._ensure_crypto_keys() - load_prowler_compliance() + # Commands that don't need Neo4j + SKIP_NEO4J_DJANGO_COMMANDS = [ + "makemigrations", + "migrate", + "pgpartition", + "check", + "help", + "showmigrations", + "check_and_fix_socialaccount_sites_migration", + ] + + # Skip Neo4j initialization during tests, some Django commands, and Celery + if getattr(settings, "TESTING", False) or ( + len(sys.argv) > 1 + and ( + ( + "manage.py" in sys.argv[0] + and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS + ) + or "celery" in sys.argv[0] + ) + ): + logger.info( + "Skipping Neo4j initialization because tests, some Django commands or Celery" + ) + + else: + graph_database.init_driver() + + # Neo4j driver is initialized at API startup (see api.attack_paths.database) + # It remains lazy for Celery workers and selected Django commands def _ensure_crypto_keys(self): """ @@ -54,7 +86,7 @@ class ApiConfig(AppConfig): global _keys_initialized # Skip key generation if running tests - if hasattr(settings, "TESTING") and settings.TESTING: + if getattr(settings, "TESTING", False): return # Skip if already initialized in this process diff --git a/api/src/backend/api/attack_paths/__init__.py b/api/src/backend/api/attack_paths/__init__.py new file mode 100644 index 0000000000..2c3ea4c5d8 --- /dev/null +++ b/api/src/backend/api/attack_paths/__init__.py @@ -0,0 +1,13 @@ +from api.attack_paths.query_definitions import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, + get_queries_for_provider, + get_query_by_id, +) + +__all__ = [ + "AttackPathsQueryDefinition", + "AttackPathsQueryParameterDefinition", + "get_queries_for_provider", + "get_query_by_id", +] diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py new file mode 100644 index 0000000000..08b5552054 --- /dev/null +++ b/api/src/backend/api/attack_paths/database.py @@ -0,0 +1,161 @@ +import atexit +import logging +import threading +from contextlib import contextmanager +from typing import Iterator +from uuid import UUID + +import neo4j +import neo4j.exceptions +from django.conf import settings + +from api.attack_paths.retryable_session import RetryableSession + +# Without this Celery goes crazy with Neo4j logging +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +SERVICE_UNAVAILABLE_MAX_RETRIES = 3 + +# Module-level process-wide driver singleton +_driver: neo4j.Driver | None = None +_lock = threading.Lock() + +# Base Neo4j functions + + +def get_uri() -> str: + host = settings.DATABASES["neo4j"]["HOST"] + port = settings.DATABASES["neo4j"]["PORT"] + return f"bolt://{host}:{port}" + + +def init_driver() -> neo4j.Driver: + global _driver + if _driver is not None: + return _driver + + with _lock: + if _driver is None: + uri = get_uri() + config = settings.DATABASES["neo4j"] + + _driver = neo4j.GraphDatabase.driver( + uri, + auth=(config["USER"], config["PASSWORD"]), + keep_alive=True, + max_connection_lifetime=7200, + connection_acquisition_timeout=120, + max_connection_pool_size=50, + ) + _driver.verify_connectivity() + + # Register cleanup handler (only runs once since we're inside the _driver is None block) + atexit.register(close_driver) + + return _driver + + +def get_driver() -> neo4j.Driver: + return init_driver() + + +def close_driver() -> None: # TODO: Use it + global _driver + with _lock: + if _driver is not None: + try: + _driver.close() + + finally: + _driver = None + + +@contextmanager +def get_session(database: str | None = None) -> Iterator[RetryableSession]: + session_wrapper: RetryableSession | None = None + + try: + session_wrapper = RetryableSession( + session_factory=lambda: get_driver().session(database=database), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + raise GraphDatabaseQueryException(message=exc.message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + +def create_database(database: str) -> None: + query = "CREATE DATABASE $database IF NOT EXISTS" + parameters = {"database": database} + + with get_session() as session: + session.run(query, parameters) + + +def drop_database(database: str) -> None: + query = f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA" + + with get_session() as session: + session.run(query) + + +def drop_subgraph(database: str, root_node_label: str, root_node_id: str) -> int: + query = """ + MATCH (a:__ROOT_NODE_LABEL__ {id: $root_node_id}) + CALL apoc.path.subgraphNodes(a, {}) + YIELD node + DETACH DELETE node + RETURN COUNT(node) AS deleted_nodes_count + """.replace("__ROOT_NODE_LABEL__", root_node_label) + parameters = {"root_node_id": root_node_id} + + with get_session(database) as session: + result = session.run(query, parameters) + + try: + return result.single()["deleted_nodes_count"] + + except neo4j.exceptions.ResultConsumedError: + return 0 # As there are no nodes to delete, the result is empty + + +def clear_cache(database: str) -> None: + query = "CALL db.clearQueryCaches()" + + try: + with get_session(database) as session: + session.run(query) + + except GraphDatabaseQueryException as exc: + logging.warning(f"Failed to clear query cache for database `{database}`: {exc}") + + +# Neo4j functions related to Prowler + Cartography +DATABASE_NAME_TEMPLATE = "db-{attack_paths_scan_id}" + + +def get_database_name(attack_paths_scan_id: UUID) -> str: + attack_paths_scan_id_str = str(attack_paths_scan_id).lower() + return DATABASE_NAME_TEMPLATE.format(attack_paths_scan_id=attack_paths_scan_id_str) + + +# Exceptions + + +class GraphDatabaseQueryException(Exception): + def __init__(self, message: str, code: str | None = None) -> None: + super().__init__(message) + self.message = message + self.code = code + + def __str__(self) -> str: + if self.code: + return f"{self.code}: {self.message}" + + return self.message diff --git a/api/src/backend/api/attack_paths/query_definitions.py b/api/src/backend/api/attack_paths/query_definitions.py new file mode 100644 index 0000000000..7e0f2068da --- /dev/null +++ b/api/src/backend/api/attack_paths/query_definitions.py @@ -0,0 +1,690 @@ +from dataclasses import dataclass, field + + +# Dataclases for handling API's Attack Path query definitions and their parameters +@dataclass +class AttackPathsQueryParameterDefinition: + """ + Metadata describing a parameter that must be provided to an Attack Paths query. + """ + + name: str + label: str + data_type: str = "string" + cast: type = str + description: str | None = None + placeholder: str | None = None + + +@dataclass +class AttackPathsQueryDefinition: + """ + Immutable representation of an Attack Path query. + """ + + id: str + name: str + description: str + provider: str + cypher: str + parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list) + + +# Accessor functions for API's Attack Paths query definitions +def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]: + return _QUERY_DEFINITIONS.get(provider, []) + + +def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None: + return _QUERIES_BY_ID.get(query_id) + + +# API's Attack Paths query definitions +_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { + "aws": [ + # Custom query for detecting internet-exposed EC2 instances with sensitive S3 access + AttackPathsQueryDefinition( + id="aws-internet-exposed-ec2-sensitive-s3-access", + name="Identify internet-exposed EC2 instances with sensitive S3 access", + description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'}) + YIELD node AS internet + + MATCH path_s3 = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket)--(t:AWSTag) + WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value) + + MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound) + WHERE ec2.exposed_internet = true + AND ipi.toport = 22 + + MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name) + AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*') + + MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2) + YIELD rel AS can_access + + UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="tag_key", + label="Tag key", + description="Tag key to filter the S3 bucket, e.g. DataClassification.", + placeholder="DataClassification", + ), + AttackPathsQueryParameterDefinition( + name="tag_value", + label="Tag value", + description="Tag value to filter the S3 bucket, e.g. Sensitive.", + placeholder="Sensitive", + ), + ], + ), + # Regular Cartography Attack Paths queries + AttackPathsQueryDefinition( + id="aws-rds-instances", + name="Identify provisioned RDS instances", + description="List the selected AWS account alongside the RDS instances it owns.", + provider="aws", + cypher=""" + MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance) + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-rds-unencrypted-storage", + name="Identify RDS instances without storage encryption", + description="Find RDS instances with storage encryption disabled within the selected account.", + provider="aws", + cypher=""" + MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance) + WHERE rds.storage_encrypted = false + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-s3-anonymous-access-buckets", + name="Identify S3 buckets with anonymous access", + description="Find S3 buckets that allow anonymous access within the selected account.", + provider="aws", + cypher=""" + MATCH path = (aws:AWSAccount {id: $provider_uid})--(s3:S3Bucket) + WHERE s3.anonymous_access = true + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-iam-statements-allow-all-actions", + name="Identify IAM statements that allow all actions", + description="Find IAM policy statements that allow all actions via '*' within the selected account.", + provider="aws", + cypher=""" + MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = '*') + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-iam-statements-allow-delete-policy", + name="Identify IAM statements that allow iam:DeletePolicy", + description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.", + provider="aws", + cypher=""" + MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-iam-statements-allow-create-actions", + name="Identify IAM statements that allow create actions", + description="Find IAM policy statements that allow actions containing 'create' within the selected account.", + provider="aws", + cypher=""" + MATCH path = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = "Allow" + AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-ec2-instances-internet-exposed", + name="Identify internet-exposed EC2 instances", + description="Find EC2 instances flagged as exposed to the internet within the selected account.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'}) + YIELD node AS internet + + MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance) + WHERE ec2.exposed_internet = true + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-security-groups-open-internet-facing", + name="Identify internet-facing resources with open security groups", + description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'}) + YIELD node AS internet + + // Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0) + MATCH path_ec2 = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange) + WHERE ec2.exposed_internet = true + AND ir.range = "0.0.0.0/0" + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, ec2) + YIELD rel AS can_access + + UNWIND nodes(path_ec2) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-classic-elb-internet-exposed", + name="Identify internet-exposed Classic Load Balancers", + description="Find Classic Load Balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'}) + YIELD node AS internet + + MATCH path = (aws:AWSAccount {id: $provider_uid})--(elb:LoadBalancer)--(listener:ELBListener) + WHERE elb.exposed_internet = true + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, elb) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-elbv2-internet-exposed", + name="Identify internet-exposed ELBv2 load balancers", + description="Find ELBv2 load balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'}) + YIELD node AS internet + + MATCH path = (aws:AWSAccount {id: $provider_uid})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener) + WHERE elbv2.exposed_internet = true + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, elbv2) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-public-ip-resource-lookup", + name="Identify resources by public IP address", + description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['Internet'], {id: 'Internet', name: 'Internet'}) + YIELD node AS internet + + CALL () { + MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:EC2PrivateIp)-[q]-(y) + WHERE x.public_ip = $ip + RETURN path, x + + UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:EC2Instance)-[q]-(y) + WHERE x.publicipaddress = $ip + RETURN path, x + + UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:NetworkInterface)-[q]-(y) + WHERE x.public_ip = $ip + RETURN path, x + + UNION MATCH path = (aws:AWSAccount {id: $provider_uid})-[r]-(x:ElasticIPAddress)-[q]-(y) + WHERE x.public_ip = $ip + RETURN path, x + } + + WITH path, x, internet + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {}, x) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="ip", + label="IP address", + description="Public IP address, e.g. 192.0.2.0.", + placeholder="192.0.2.0", + ), + ], + ), + # Privilege Escalation Queries (based on pathfinding.cloud research): https://github.com/DataDog/pathfinding.cloud + AttackPathsQueryDefinition( + id="aws-iam-privesc-passrole-ec2", + name="Privilege Escalation: iam:PassRole + ec2:RunInstances", + description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service. This is a new-passrole escalation path (pathfinding.cloud: ec2-001).", + provider="aws", + cypher=""" + // Create a single shared virtual EC2 instance node + CALL apoc.create.vNode(['EC2Instance'], { + id: 'potential-ec2-passrole', + name: 'New EC2 Instance', + description: 'Attacker-controlled EC2 with privileged role' + }) + YIELD node AS ec2_node + + // Create a single shared virtual escalation outcome node (styled like a finding) + CALL apoc.create.vNode(['PrivilegeEscalation'], { + id: 'effective-administrator-passrole-ec2', + check_title: 'Privilege Escalation', + name: 'Effective Administrator', + status: 'FAIL', + severity: 'critical' + }) + YIELD node AS escalation_outcome + + WITH ec2_node, escalation_outcome + + // Find principals in the account + MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal) + + // Find statements granting iam:PassRole + MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find statements granting ec2:RunInstances + MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) + WHERE stmt_ec2.effect = 'Allow' + AND any(action IN stmt_ec2.action WHERE + toLower(action) = 'ec2:runinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2) + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE target_role.arn CONTAINS $provider_uid + // Check if principal can pass this role + AND any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + // Check if target role has elevated permissions (optional, for severity assessment) + OPTIONAL MATCH (target_role)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement) + WHERE role_stmt.effect = 'Allow' + AND ( + any(action IN role_stmt.action WHERE action = '*') + OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*') + ) + + CALL apoc.create.vRelationship(principal, 'CAN_LAUNCH', { + via: 'ec2:RunInstances + iam:PassRole' + }, ec2_node) + YIELD rel AS launch_rel + + CALL apoc.create.vRelationship(ec2_node, 'ASSUMES_ROLE', {}, target_role) + YIELD rel AS assumes_rel + + CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', { + reference: 'https://pathfinding.cloud/paths/ec2-001' + }, escalation_outcome) + YIELD rel AS grants_rel + + UNWIND nodes(path_principal) + nodes(path_passrole) + nodes(path_ec2) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path_principal, path_passrole, path_ec2, path_target, + ec2_node, escalation_outcome, launch_rel, assumes_rel, grants_rel, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-dev-endpoint", + name="Privilege Escalation: Glue Dev Endpoint with PassRole", + description="Detect principals that can escalate privileges by passing a role to a Glue development endpoint. The attacker creates a dev endpoint with an arbitrary role attached, then accesses those credentials through the endpoint.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['PrivilegeEscalation'], { + id: 'effective-administrator-glue', + check_title: 'Privilege Escalation', + name: 'Effective Administrator (Glue)', + status: 'FAIL', + severity: 'critical' + }) + YIELD node AS escalation_outcome + + WITH escalation_outcome + + // Find principals in the account + MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal) + + // Principal can assume roles (up to 2 hops) + OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole) + WITH escalation_outcome, principal, path_principal, path_assume, + CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal + + // Find iam:PassRole permission + MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement) + WHERE passrole_stmt.effect = 'Allow' + AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*') + + // Find Glue CreateDevEndpoint permission + MATCH (effective_principal)--(glue_policy:AWSPolicy)--(glue_stmt:AWSPolicyStatement) + WHERE glue_stmt.effect = 'Allow' + AND any(action IN glue_stmt.action WHERE toLower(action) = 'glue:createdevendpoint' OR action = '*' OR toLower(action) = 'glue:*') + + // Find target role with elevated permissions + MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement) + WHERE target_stmt.effect = 'Allow' + AND ( + any(action IN target_stmt.action WHERE action = '*') + OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*') + ) + + // Deduplicate before creating virtual nodes + WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role + + // Create virtual Glue endpoint node (one per unique principal->target pair) + CALL apoc.create.vNode(['GlueDevEndpoint'], { + name: 'New Dev Endpoint', + description: 'Glue endpoint with target role attached', + id: effective_principal.arn + '->' + target_role.arn + }) + YIELD node AS glue_endpoint + + CALL apoc.create.vRelationship(effective_principal, 'CREATES_ENDPOINT', { + permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'], + technique: 'new-passrole' + }, glue_endpoint) + YIELD rel AS create_rel + + CALL apoc.create.vRelationship(glue_endpoint, 'RUNS_AS', {}, target_role) + YIELD rel AS runs_rel + + CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', { + reference: 'https://pathfinding.cloud/paths/glue-001' + }, escalation_outcome) + YIELD rel AS grants_rel + + // Re-match paths for visualization + MATCH path_principal = (aws)--(principal) + MATCH path_target = (aws)--(target_role) + + RETURN path_principal, path_target, + glue_endpoint, escalation_outcome, create_rel, runs_rel, grants_rel + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-assume-role", + name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole", + description="Detect principals who can both attach policies to roles AND assume those roles. This two-step attack allows modifying a role's permissions then assuming it to gain elevated access. This is a principal-access escalation path (pathfinding.cloud: iam-014).", + provider="aws", + cypher=""" + // Create a virtual escalation outcome node (styled like a finding) + CALL apoc.create.vNode(['PrivilegeEscalation'], { + id: 'effective-administrator', + check_title: 'Privilege Escalation', + name: 'Effective Administrator', + status: 'FAIL', + severity: 'critical' + }) + YIELD node AS admin_outcome + + WITH admin_outcome + + // Find principals in the account + MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal) + + // Find statements granting iam:AttachRolePolicy + MATCH path_attach = (principal)--(attach_policy:AWSPolicy)--(stmt_attach:AWSPolicyStatement) + WHERE stmt_attach.effect = 'Allow' + AND any(action IN stmt_attach.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find statements granting sts:AssumeRole + MATCH path_assume = (principal)--(assume_policy:AWSPolicy)--(stmt_assume:AWSPolicyStatement) + WHERE stmt_assume.effect = 'Allow' + AND any(action IN stmt_assume.action WHERE + toLower(action) = 'sts:assumerole' + OR toLower(action) = 'sts:*' + OR action = '*' + ) + + // Find target roles that the principal can both modify AND assume + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE target_role.arn CONTAINS $provider_uid + // Can attach policy to this role + AND any(resource IN stmt_attach.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + // Can assume this role + AND any(resource IN stmt_assume.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + // Deduplicate before creating virtual relationships + WITH DISTINCT admin_outcome, aws, principal, target_role + + // Create virtual relationships showing the attack path + CALL apoc.create.vRelationship(principal, 'CAN_MODIFY', { + via: 'iam:AttachRolePolicy' + }, target_role) + YIELD rel AS modify_rel + + CALL apoc.create.vRelationship(target_role, 'LEADS_TO', { + technique: 'iam:AttachRolePolicy + sts:AssumeRole', + via: 'sts:AssumeRole', + reference: 'https://pathfinding.cloud/paths/iam-014' + }, admin_outcome) + YIELD rel AS escalation_rel + + // Re-match paths for visualization + MATCH path_principal = (aws)--(principal) + MATCH path_target = (aws)--(target_role) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding) + WHERE pf.status = 'FAIL' + + RETURN path_principal, path_target, + admin_outcome, modify_rel, escalation_rel, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], + ), + AttackPathsQueryDefinition( + id="aws-bedrock-privesc-passrole-code-interpreter", + name="Privilege Escalation: Bedrock Code Interpreter with PassRole", + description="Detect principals that can escalate privileges by passing a role to a Bedrock AgentCore Code Interpreter. The attacker creates a code interpreter with an arbitrary role, then invokes it to execute code with those credentials.", + provider="aws", + cypher=""" + CALL apoc.create.vNode(['PrivilegeEscalation'], { + id: 'effective-administrator-bedrock', + check_title: 'Privilege Escalation', + name: 'Effective Administrator (Bedrock)', + status: 'FAIL', + severity: 'critical' + }) + YIELD node AS escalation_outcome + + WITH escalation_outcome + + // Find principals in the account + MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal) + + // Principal can assume roles (up to 2 hops) + OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole) + WITH escalation_outcome, aws, principal, path_principal, path_assume, + CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal + + // Find iam:PassRole permission + MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement) + WHERE passrole_stmt.effect = 'Allow' + AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*') + + // Find Bedrock AgentCore permissions + MATCH (effective_principal)--(bedrock_policy:AWSPolicy)--(bedrock_stmt:AWSPolicyStatement) + WHERE bedrock_stmt.effect = 'Allow' + AND ( + any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:createcodeinterpreter' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*') + ) + AND ( + any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:startsession' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*') + ) + AND ( + any(action IN bedrock_stmt.action WHERE toLower(action) = 'bedrock-agentcore:invoke' OR action = '*' OR toLower(action) = 'bedrock-agentcore:*') + ) + + // Find target roles with elevated permissions that could be passed + MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement) + WHERE target_stmt.effect = 'Allow' + AND ( + any(action IN target_stmt.action WHERE action = '*') + OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*') + ) + + // Deduplicate per (principal, target_role) pair + WITH DISTINCT escalation_outcome, aws, principal, target_role + + // Group by principal, collect target_roles + WITH escalation_outcome, aws, principal, + collect(DISTINCT target_role) AS target_roles, + count(DISTINCT target_role) AS target_count + + // Create single virtual Bedrock node per principal + CALL apoc.create.vNode(['BedrockCodeInterpreter'], { + name: 'New Code Interpreter', + description: toString(target_count) + ' admin role(s) can be passed', + id: principal.arn, + target_role_count: target_count + }) + YIELD node AS bedrock_agent + + // Connect from principal (not effective_principal) to keep graph connected + CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', { + permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'], + technique: 'new-passrole' + }, bedrock_agent) + YIELD rel AS create_rel + + // UNWIND target_roles to show which roles can be passed + UNWIND target_roles AS target_role + + CALL apoc.create.vRelationship(bedrock_agent, 'PASSES_ROLE', {}, target_role) + YIELD rel AS pass_rel + + CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', { + reference: 'https://pathfinding.cloud/paths/bedrock-001' + }, escalation_outcome) + YIELD rel AS grants_rel + + // Re-match path for visualization + MATCH path_principal = (aws)--(principal) + + RETURN path_principal, + bedrock_agent, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count + """, + parameters=[], + ), + ], +} + +_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { + definition.id: definition + for definitions in _QUERY_DEFINITIONS.values() + for definition in definitions +} diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py new file mode 100644 index 0000000000..2c70bc6a8e --- /dev/null +++ b/api/src/backend/api/attack_paths/retryable_session.py @@ -0,0 +1,92 @@ +import logging + +from collections.abc import Callable +from typing import Any + +import neo4j +import neo4j.exceptions + +logger = logging.getLogger(__name__) + + +class RetryableSession: + """ + Wrapper around `neo4j.Session` that retries `neo4j.exceptions.ServiceUnavailable` errors. + """ + + def __init__( + self, + session_factory: Callable[[], neo4j.Session], + max_retries: int, + ) -> None: + self._session_factory = session_factory + self._max_retries = max(0, max_retries) + self._session = self._session_factory() + + def close(self) -> None: + if self._session is not None: + self._session.close() + self._session = None + + def __enter__(self) -> "RetryableSession": + return self + + def __exit__( + self, _: Any, __: Any, ___: Any + ) -> None: # Unused args: exc_type, exc, exc_tb + self.close() + + def run(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("run", *args, **kwargs) + + def write_transaction(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("write_transaction", *args, **kwargs) + + def read_transaction(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("read_transaction", *args, **kwargs) + + def execute_write(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("execute_write", *args, **kwargs) + + def execute_read(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("execute_read", *args, **kwargs) + + def __getattr__(self, item: str) -> Any: + return getattr(self._session, item) + + def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any: + attempt = 0 + last_exc: Exception | None = None + + while attempt <= self._max_retries: + try: + method = getattr(self._session, method_name) + return method(*args, **kwargs) + + except ( + BrokenPipeError, + ConnectionResetError, + neo4j.exceptions.ServiceUnavailable, + ) as exc: # pragma: no cover - depends on infra + last_exc = exc + attempt += 1 + + if attempt > self._max_retries: + raise + + logger.warning( + f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..." + ) + self._refresh_session() + + raise last_exc if last_exc else RuntimeError("Unexpected retry loop exit") + + def _refresh_session(self) -> None: + if self._session is not None: + try: + self._session.close() + except Exception: + # Best-effort close; failures just mean we open a new session below + pass + + self._session = self._session_factory() diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py new file mode 100644 index 0000000000..7418a0302e --- /dev/null +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -0,0 +1,143 @@ +import logging + +from typing import Any + +from rest_framework.exceptions import APIException, ValidationError + +from api.attack_paths import database as graph_database, AttackPathsQueryDefinition +from api.models import AttackPathsScan +from config.custom_logging import BackendLogger + +logger = logging.getLogger(BackendLogger.API) + + +def normalize_run_payload(raw_data): + if not isinstance(raw_data, dict): # Let the serializer handle this + return raw_data + + if "data" in raw_data and isinstance(raw_data.get("data"), dict): + data_section = raw_data.get("data") or {} + attributes = data_section.get("attributes") or {} + payload = { + "id": attributes.get("id", data_section.get("id")), + "parameters": attributes.get("parameters"), + } + + # Remove `None` parameters to allow defaults downstream + if payload.get("parameters") is None: + payload.pop("parameters") + return payload + + return raw_data + + +def prepare_query_parameters( + definition: AttackPathsQueryDefinition, + provided_parameters: dict[str, Any], + provider_uid: str, +) -> dict[str, Any]: + parameters = dict(provided_parameters or {}) + expected_names = {parameter.name for parameter in definition.parameters} + provided_names = set(parameters.keys()) + + unexpected = provided_names - expected_names + if unexpected: + raise ValidationError( + {"parameters": f"Unknown parameter(s): {', '.join(sorted(unexpected))}"} + ) + + missing = expected_names - provided_names + if missing: + raise ValidationError( + { + "parameters": f"Missing required parameter(s): {', '.join(sorted(missing))}" + } + ) + + clean_parameters = { + "provider_uid": str(provider_uid), + } + + for definition_parameter in definition.parameters: + raw_value = provided_parameters[definition_parameter.name] + + try: + casted_value = definition_parameter.cast(raw_value) + + except (ValueError, TypeError) as exc: + raise ValidationError( + { + "parameters": ( + f"Invalid value for parameter `{definition_parameter.name}`: {str(exc)}" + ) + } + ) + + clean_parameters[definition_parameter.name] = casted_value + + return clean_parameters + + +def execute_attack_paths_query( + attack_paths_scan: AttackPathsScan, + definition: AttackPathsQueryDefinition, + parameters: dict[str, Any], +) -> dict[str, Any]: + try: + with graph_database.get_session(attack_paths_scan.graph_database) as session: + result = session.run(definition.cypher, parameters) + return _serialize_graph(result.graph()) + + except graph_database.GraphDatabaseQueryException as exc: + logger.error(f"Query failed for Attack Paths query `{definition.id}`: {exc}") + raise APIException( + "Attack Paths query execution failed due to a database error" + ) + + +def _serialize_graph(graph): + nodes = [] + for node in graph.nodes: + nodes.append( + { + "id": node.element_id, + "labels": list(node.labels), + "properties": _serialize_properties(node._properties), + }, + ) + + relationships = [] + for relationship in graph.relationships: + relationships.append( + { + "id": relationship.element_id, + "label": relationship.type, + "source": relationship.start_node.element_id, + "target": relationship.end_node.element_id, + "properties": _serialize_properties(relationship._properties), + }, + ) + + return { + "nodes": nodes, + "relationships": relationships, + } + + +def _serialize_properties(properties: dict[str, Any]) -> dict[str, Any]: + """Convert Neo4j property values into JSON-serializable primitives.""" + + def _serialize_value(value: Any) -> Any: + # Neo4j temporal and spatial values expose `to_native` returning Python primitives + if hasattr(value, "to_native") and callable(value.to_native): + return _serialize_value(value.to_native()) + + if isinstance(value, (list, tuple)): + return [_serialize_value(item) for item in value] + + if isinstance(value, dict): + return {key: _serialize_value(val) for key, val in value.items()} + + return value + + return {key: _serialize_value(val) for key, val in properties.items()} diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index da39fc23bb..1705ed2e8f 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,15 +1,99 @@ -from types import MappingProxyType +from collections.abc import Iterable, Mapping from api.models import Provider from prowler.config.config import get_available_compliance_frameworks from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.models import CheckMetadata -PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = {} -PROWLER_CHECKS = {} AVAILABLE_COMPLIANCE_FRAMEWORKS = {} +class LazyComplianceTemplate(Mapping): + """Lazy-load compliance templates per provider on first access.""" + + def __init__(self, provider_types: Iterable[str] | None = None) -> None: + if provider_types is None: + provider_types = Provider.ProviderChoices.values + self._provider_types = tuple(provider_types) + self._provider_types_set = set(self._provider_types) + self._cache: dict[str, dict] = {} + + def _load_provider(self, provider_type: str) -> dict: + if provider_type not in self._provider_types_set: + raise KeyError(provider_type) + cached = self._cache.get(provider_type) + if cached is not None: + return cached + _ensure_provider_loaded(provider_type) + return self._cache[provider_type] + + def __getitem__(self, key: str) -> dict: + return self._load_provider(key) + + def __iter__(self): + return iter(self._provider_types) + + def __len__(self) -> int: + return len(self._provider_types) + + def __contains__(self, key: object) -> bool: + return key in self._provider_types_set + + def get(self, key: str, default=None): + if key not in self._provider_types_set: + return default + return self._load_provider(key) + + def __repr__(self) -> str: # pragma: no cover - debugging helper + loaded = ", ".join(sorted(self._cache)) + return f"{self.__class__.__name__}(loaded=[{loaded}])" + + +class LazyChecksMapping(Mapping): + """Lazy-load checks mapping per provider on first access.""" + + def __init__(self, provider_types: Iterable[str] | None = None) -> None: + if provider_types is None: + provider_types = Provider.ProviderChoices.values + self._provider_types = tuple(provider_types) + self._provider_types_set = set(self._provider_types) + self._cache: dict[str, dict] = {} + + def _load_provider(self, provider_type: str) -> dict: + if provider_type not in self._provider_types_set: + raise KeyError(provider_type) + cached = self._cache.get(provider_type) + if cached is not None: + return cached + _ensure_provider_loaded(provider_type) + return self._cache[provider_type] + + def __getitem__(self, key: str) -> dict: + return self._load_provider(key) + + def __iter__(self): + return iter(self._provider_types) + + def __len__(self) -> int: + return len(self._provider_types) + + def __contains__(self, key: object) -> bool: + return key in self._provider_types_set + + def get(self, key: str, default=None): + if key not in self._provider_types_set: + return default + return self._load_provider(key) + + def __repr__(self) -> str: # pragma: no cover - debugging helper + loaded = ", ".join(sorted(self._cache)) + return f"{self.__class__.__name__}(loaded=[{loaded}])" + + +PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = LazyComplianceTemplate() +PROWLER_CHECKS = LazyChecksMapping() + + def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]: """ Retrieve and cache the list of available compliance frameworks for a specific cloud provider. @@ -70,28 +154,35 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> return Compliance.get_bulk(provider_type) -def load_prowler_compliance(): - """ - Load and initialize the Prowler compliance data and checks for all provider types. - - This function retrieves compliance data for all supported provider types, - generates a compliance overview template, and populates the global variables - `PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE` and `PROWLER_CHECKS` with read-only mappings - of the compliance templates and checks, respectively. - """ - global PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE - global PROWLER_CHECKS - - prowler_compliance = { - provider_type: get_prowler_provider_compliance(provider_type) - for provider_type in Provider.ProviderChoices.values - } - template = generate_compliance_overview_template(prowler_compliance) - PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE = MappingProxyType(template) - PROWLER_CHECKS = MappingProxyType(load_prowler_checks(prowler_compliance)) +def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]: + prowler_compliance = {provider_type: get_prowler_provider_compliance(provider_type)} + template = generate_compliance_overview_template( + prowler_compliance, provider_types=[provider_type] + ) + checks = load_prowler_checks(prowler_compliance, provider_types=[provider_type]) + return template.get(provider_type, {}), checks.get(provider_type, {}) -def load_prowler_checks(prowler_compliance): +def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None: + if ( + provider_type in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE._cache + and provider_type in PROWLER_CHECKS._cache + ): + return + template_cached = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE._cache.get(provider_type) + checks_cached = PROWLER_CHECKS._cache.get(provider_type) + if template_cached is not None and checks_cached is not None: + return + template, checks = _load_provider_assets(provider_type) + if template_cached is None: + PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE._cache[provider_type] = template + if checks_cached is None: + PROWLER_CHECKS._cache[provider_type] = checks + + +def load_prowler_checks( + prowler_compliance, provider_types: Iterable[str] | None = None +): """ Generate a mapping of checks to the compliance frameworks that include them. @@ -100,21 +191,25 @@ def load_prowler_checks(prowler_compliance): of compliance names that include that check. Args: - prowler_compliance (dict): The compliance data for all provider types, + prowler_compliance (dict): The compliance data for provider types, as returned by `get_prowler_provider_compliance`. + provider_types (Iterable[str] | None): Optional subset of provider types to + process. Defaults to all providers. Returns: dict: A nested dictionary where the first-level keys are provider types, and the values are dictionaries mapping check IDs to sets of compliance names. """ checks = {} - for provider_type in Provider.ProviderChoices.values: + if provider_types is None: + provider_types = Provider.ProviderChoices.values + for provider_type in provider_types: checks[provider_type] = { check_id: set() for check_id in get_prowler_provider_checks(provider_type) } - for compliance_name, compliance_data in prowler_compliance[ - provider_type - ].items(): + for compliance_name, compliance_data in prowler_compliance.get( + provider_type, {} + ).items(): for requirement in compliance_data.Requirements: for check in requirement.Checks: try: @@ -163,7 +258,9 @@ def generate_scan_compliance( ] += 1 -def generate_compliance_overview_template(prowler_compliance: dict): +def generate_compliance_overview_template( + prowler_compliance: dict, provider_types: Iterable[str] | None = None +): """ Generate a compliance overview template for all provider types. @@ -173,17 +270,21 @@ def generate_compliance_overview_template(prowler_compliance: dict): counts for requirements status. Args: - prowler_compliance (dict): The compliance data for all provider types, + prowler_compliance (dict): The compliance data for provider types, as returned by `get_prowler_provider_compliance`. + provider_types (Iterable[str] | None): Optional subset of provider types to + process. Defaults to all providers. Returns: dict: A nested dictionary representing the compliance overview template, structured by provider type and compliance framework. """ template = {} - for provider_type in Provider.ProviderChoices.values: + if provider_types is None: + provider_types = Provider.ProviderChoices.values + for provider_type in provider_types: provider_compliance = template.setdefault(provider_type, {}) - compliance_data_dict = prowler_compliance[provider_type] + compliance_data_dict = prowler_compliance.get(provider_type, {}) for compliance_name, compliance_data in compliance_data_dict.items(): compliance_requirements = {} diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index c6fcaeb43a..b719d4b736 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -12,7 +12,6 @@ from django.contrib.auth.models import BaseUserManager from django.db import ( DEFAULT_DB_ALIAS, OperationalError, - connection, connections, models, transaction, @@ -450,7 +449,7 @@ def create_index_on_partitions( all_partitions=True ) """ - with connection.cursor() as cursor: + with schema_editor.connection.cursor() as cursor: cursor.execute( """ SELECT inhrelid::regclass::text @@ -462,6 +461,7 @@ def create_index_on_partitions( partitions = [row[0] for row in cursor.fetchall()] where_sql = f" WHERE {where}" if where else "" + conn = schema_editor.connection for partition in partitions: if _should_create_index_on_partition(partition, all_partitions): idx_name = f"{partition.replace('.', '_')}_{index_name}" @@ -470,7 +470,12 @@ def create_index_on_partitions( f"ON {partition} USING {method} ({columns})" f"{where_sql};" ) - schema_editor.execute(sql) + old_autocommit = conn.connection.autocommit + conn.connection.autocommit = True + try: + schema_editor.execute(sql) + finally: + conn.connection.autocommit = old_autocommit def drop_index_on_partitions( @@ -486,7 +491,8 @@ def drop_index_on_partitions( parent_table: The name of the root table (e.g. "findings"). index_name: The same short name used when creating them. """ - with connection.cursor() as cursor: + conn = schema_editor.connection + with conn.cursor() as cursor: cursor.execute( """ SELECT inhrelid::regclass::text @@ -500,7 +506,12 @@ def drop_index_on_partitions( for partition in partitions: idx_name = f"{partition.replace('.', '_')}_{index_name}" sql = f"DROP INDEX CONCURRENTLY IF EXISTS {idx_name};" - schema_editor.execute(sql) + old_autocommit = conn.connection.autocommit + conn.connection.autocommit = True + try: + schema_editor.execute(sql) + finally: + conn.connection.autocommit = old_autocommit def generate_api_key_prefix(): diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 73af170f94..78f8c64c7d 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -107,3 +107,105 @@ class ConflictException(APIException): error_detail["source"] = {"pointer": pointer} super().__init__(detail=[error_detail]) + + +# Upstream Provider Errors (for external API calls like CloudTrail) +# These indicate issues with the provider, not with the user's API authentication + + +class UpstreamAuthenticationError(APIException): + """Provider credentials are invalid or expired (502 Bad Gateway). + + Used when AWS/Azure/GCP credentials fail to authenticate with the upstream + provider. This is NOT the user's API authentication failing. + """ + + status_code = status.HTTP_502_BAD_GATEWAY + default_detail = ( + "Provider credentials are invalid or expired. Please reconnect the provider." + ) + default_code = "upstream_auth_failed" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + +class UpstreamAccessDeniedError(APIException): + """Provider credentials lack required permissions (502 Bad Gateway). + + Used when credentials are valid but don't have the IAM permissions + needed for the requested operation (e.g., cloudtrail:LookupEvents). + This is 502 (not 403) because it's an upstream/gateway error - the USER + authenticated fine, but the PROVIDER's credentials are misconfigured. + """ + + status_code = status.HTTP_502_BAD_GATEWAY + default_detail = ( + "Access denied. The provider credentials do not have the required permissions." + ) + default_code = "upstream_access_denied" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + +class UpstreamServiceUnavailableError(APIException): + """Provider service is unavailable (503 Service Unavailable). + + Used when the upstream provider API returns an error or is unreachable. + """ + + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = "Unable to communicate with the provider. Please try again later." + default_code = "service_unavailable" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + +class UpstreamInternalError(APIException): + """Unexpected error communicating with provider (500 Internal Server Error). + + Used as a catch-all for unexpected errors during provider communication. + """ + + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = ( + "An unexpected error occurred while communicating with the provider." + ) + default_code = "internal_error" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index fdb2282e12..bf34950156 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -29,6 +29,7 @@ from api.models import ( Finding, Integration, Invitation, + AttackPathsScan, LighthouseProviderConfiguration, LighthouseProviderModels, Membership, @@ -45,6 +46,7 @@ from api.models import ( Role, Scan, ScanCategorySummary, + ScanGroupSummary, ScanSummary, SeverityChoices, StateChoices, @@ -214,6 +216,9 @@ class CommonFindingFilters(FilterSet): category = CharFilter(method="filter_category") category__in = CharInFilter(field_name="categories", lookup_expr="overlap") + resource_groups = CharFilter(field_name="resource_groups", lookup_expr="exact") + resource_groups__in = CharInFilter(field_name="resource_groups", lookup_expr="in") + # Temporarily disabled until we implement tag filtering in the UI # resource_tag_key = CharFilter(field_name="resources__tags__key") # resource_tag_key__in = CharInFilter( @@ -392,6 +397,23 @@ class ScanFilter(ProviderRelationshipFilterSet): } +class AttackPathsScanFilter(ProviderRelationshipFilterSet): + inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") + completed_at = DateFilter(field_name="completed_at", lookup_expr="date") + started_at = DateFilter(field_name="started_at", lookup_expr="date") + state = ChoiceFilter(choices=StateChoices.choices) + state__in = ChoiceInFilter( + field_name="state", choices=StateChoices.choices, lookup_expr="in" + ) + + class Meta: + model = AttackPathsScan + fields = { + "provider": ["exact", "in"], + "scan": ["exact", "in"], + } + + class TaskFilter(FilterSet): name = CharFilter(field_name="task_runner_task__task_name", lookup_expr="exact") name__icontains = CharFilter( @@ -431,6 +453,8 @@ class ResourceTagFilter(FilterSet): class ResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") @@ -439,6 +463,8 @@ class ResourceFilter(ProviderRelationshipFilterSet): updated_at = DateFilter(field_name="updated_at", lookup_expr="date") scan = UUIDFilter(field_name="provider__scan", lookup_expr="exact") scan__in = UUIDInFilter(field_name="provider__scan", lookup_expr="in") + groups = CharFilter(method="filter_groups") + groups__in = CharInFilter(field_name="groups", lookup_expr="overlap") class Meta: model = Resource @@ -453,6 +479,9 @@ class ResourceFilter(ProviderRelationshipFilterSet): "updated_at": ["gte", "lte"], } + def filter_groups(self, queryset, name, value): + return queryset.filter(groups__contains=[value]) + def filter_queryset(self, queryset): if not (self.data.get("scan") or self.data.get("scan__in")) and not ( self.data.get("updated_at") @@ -513,10 +542,14 @@ class ResourceFilter(ProviderRelationshipFilterSet): class LatestResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") tags = CharFilter(method="filter_tag") + groups = CharFilter(method="filter_groups") + groups__in = CharInFilter(field_name="groups", lookup_expr="overlap") class Meta: model = Resource @@ -529,6 +562,9 @@ class LatestResourceFilter(ProviderRelationshipFilterSet): "type": ["exact", "icontains", "in"], } + def filter_groups(self, queryset, name, value): + return queryset.filter(groups__contains=[value]) + def filter_tag_key(self, queryset, name, value): return queryset.filter(Q(tags__key=value) | Q(tags__key__icontains=value)) @@ -1154,6 +1190,26 @@ class CategoryOverviewFilter(BaseScanProviderFilter): class Meta(BaseScanProviderFilter.Meta): model = ScanCategorySummary + fields = {} + + +class ResourceGroupOverviewFilter(FilterSet): + provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in") + provider_type = ChoiceFilter( + field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices + ) + provider_type__in = ChoiceInFilter( + field_name="scan__provider__provider", + choices=Provider.ProviderChoices.choices, + lookup_expr="in", + ) + resource_group = CharFilter(field_name="resource_group", lookup_expr="exact") + resource_group__in = CharInFilter(field_name="resource_group", lookup_expr="in") + + class Meta: + model = ScanGroupSummary + fields = {} class ComplianceWatchlistFilter(BaseProviderFilter): diff --git a/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json b/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json new file mode 100644 index 0000000000..fdf310458a --- /dev/null +++ b/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json @@ -0,0 +1,41 @@ +[ + { + "model": "api.attackpathsscan", + "pk": "a7f0f6de-6f8e-4b3a-8cbe-3f6dd9012345", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "provider": "b85601a8-4b45-4194-8135-03fb980ef428", + "scan": "01920573-aa9c-73c9-bcda-f2e35c9b19d2", + "state": "completed", + "progress": 100, + "update_tag": 1693586667, + "graph_database": "db-a7f0f6de-6f8e-4b3a-8cbe-3f6dd9012345", + "is_graph_database_deleted": false, + "task": null, + "inserted_at": "2024-09-01T17:24:37Z", + "updated_at": "2024-09-01T17:44:37Z", + "started_at": "2024-09-01T17:34:37Z", + "completed_at": "2024-09-01T17:44:37Z", + "duration": 269, + "ingestion_exceptions": {} + } + }, + { + "model": "api.attackpathsscan", + "pk": "4a2fb2af-8a60-4d7d-9cae-4ca65e098765", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "provider": "15fce1fa-ecaa-433f-a9dc-62553f3a2555", + "scan": "01929f3b-ed2e-7623-ad63-7c37cd37828f", + "state": "executing", + "progress": 48, + "update_tag": 1697625000, + "graph_database": "db-4a2fb2af-8a60-4d7d-9cae-4ca65e098765", + "is_graph_database_deleted": false, + "task": null, + "inserted_at": "2024-10-18T10:55:57Z", + "updated_at": "2024-10-18T10:56:15Z", + "started_at": "2024-10-18T10:56:05Z" + } + } +] diff --git a/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py new file mode 100644 index 0000000000..932a2a6c85 --- /dev/null +++ b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py @@ -0,0 +1,126 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.db_utils +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0067_tenant_compliance_summary"), + ] + + operations = [ + migrations.AddField( + model_name="finding", + name="resource_groups", + field=models.TextField( + blank=True, + help_text="Resource group from check metadata for efficient filtering", + null=True, + ), + ), + migrations.CreateModel( + name="ScanGroupSummary", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.tenant", + ), + ), + ( + "inserted_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="resource_group_summaries", + related_query_name="resource_group_summary", + to="api.scan", + ), + ), + ( + "resource_group", + models.CharField(max_length=50), + ), + ( + "severity", + api.db_utils.SeverityEnumField( + choices=[ + ("critical", "Critical"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("informational", "Informational"), + ], + ), + ), + ( + "total_findings", + models.IntegerField( + default=0, help_text="Non-muted findings (PASS + FAIL)" + ), + ), + ( + "failed_findings", + models.IntegerField( + default=0, + help_text="Non-muted FAIL findings (subset of total_findings)", + ), + ), + ( + "new_failed_findings", + models.IntegerField( + default=0, + help_text="Non-muted FAIL with delta='new' (subset of failed_findings)", + ), + ), + ( + "resources_count", + models.IntegerField( + default=0, help_text="Count of distinct resource_uid values" + ), + ), + ], + options={ + "db_table": "scan_resource_group_summaries", + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="scangroupsummary", + index=models.Index( + fields=["tenant_id", "scan"], name="srgs_tenant_scan_idx" + ), + ), + migrations.AddConstraint( + model_name="scangroupsummary", + constraint=models.UniqueConstraint( + fields=("tenant_id", "scan_id", "resource_group", "severity"), + name="unique_resource_group_severity_per_scan", + ), + ), + migrations.AddConstraint( + model_name="scangroupsummary", + constraint=api.rls.RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_scangroupsummary", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0069_resource_resource_group.py b/api/src/backend/api/migrations/0069_resource_resource_group.py new file mode 100644 index 0000000000..14a26995c2 --- /dev/null +++ b/api/src/backend/api/migrations/0069_resource_resource_group.py @@ -0,0 +1,21 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0068_finding_resource_group_scangroupsummary"), + ] + + operations = [ + migrations.AddField( + model_name="resource", + name="groups", + field=ArrayField( + models.CharField(max_length=100), + blank=True, + help_text="Groups for categorization (e.g., compute, storage, IAM)", + null=True, + ), + ), + ] diff --git a/api/src/backend/api/migrations/0070_attack_paths_scan.py b/api/src/backend/api/migrations/0070_attack_paths_scan.py new file mode 100644 index 0000000000..3e63d3353b --- /dev/null +++ b/api/src/backend/api/migrations/0070_attack_paths_scan.py @@ -0,0 +1,154 @@ +# Generated by Django 5.1.13 on 2025-11-06 16:20 + +import django.db.models.deletion + +from django.db import migrations, models +from uuid6 import uuid7 + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0069_resource_resource_group"), + ] + + operations = [ + migrations.CreateModel( + name="AttackPathsScan", + fields=[ + ( + "id", + models.UUIDField( + default=uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "state", + api.db_utils.StateEnumField( + choices=[ + ("available", "Available"), + ("scheduled", "Scheduled"), + ("executing", "Executing"), + ("completed", "Completed"), + ("failed", "Failed"), + ("cancelled", "Cancelled"), + ], + default="available", + ), + ), + ("progress", models.IntegerField(default=0)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "duration", + models.IntegerField( + blank=True, help_text="Duration in seconds", null=True + ), + ), + ( + "update_tag", + models.BigIntegerField( + blank=True, + help_text="Cartography update tag (epoch)", + null=True, + ), + ), + ( + "graph_database", + models.CharField(blank=True, max_length=63, null=True), + ), + ( + "is_graph_database_deleted", + models.BooleanField(default=False), + ), + ( + "ingestion_exceptions", + models.JSONField(blank=True, default=dict, null=True), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + to="api.provider", + ), + ), + ( + "scan", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + to="api.scan", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + to="api.task", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "attack_paths_scans", + "abstract": False, + "indexes": [ + models.Index( + fields=["tenant_id", "provider_id", "-inserted_at"], + name="aps_prov_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "state", "-inserted_at"], + name="aps_state_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "scan_id"], + name="aps_scan_lookup_idx", + ), + models.Index( + fields=["tenant_id", "provider_id"], + name="aps_active_graph_idx", + include=["graph_database", "id"], + condition=models.Q(("is_graph_database_deleted", False)), + ), + models.Index( + fields=["tenant_id", "provider_id", "-completed_at"], + name="aps_completed_graph_idx", + include=["graph_database", "id"], + condition=models.Q( + ("state", "completed"), + ("is_graph_database_deleted", False), + ), + ), + ], + }, + ), + migrations.AddConstraint( + model_name="attackpathsscan", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_attackpathsscan", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py b/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py new file mode 100644 index 0000000000..e1b1e192ad --- /dev/null +++ b/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py @@ -0,0 +1,41 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + """ + Drop unused indexes on partitioned tables (findings, resource_finding_mappings). + + NOTE: RemoveIndexConcurrently cannot be used on partitioned tables in PostgreSQL. + Standard RemoveIndex drops the parent index, which cascades to all partitions. + """ + + dependencies = [ + ("api", "0070_attack_paths_scan"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="finding", + name="gin_findings_search_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="gin_find_service_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="gin_find_region_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="gin_find_rtype_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="find_delta_new_idx", + ), + migrations.RemoveIndex( + model_name="resourcefindingmapping", + name="rfm_tenant_finding_idx", + ), + ] diff --git a/api/src/backend/api/migrations/0072_drop_unused_indexes.py b/api/src/backend/api/migrations/0072_drop_unused_indexes.py new file mode 100644 index 0000000000..81f1f69c0d --- /dev/null +++ b/api/src/backend/api/migrations/0072_drop_unused_indexes.py @@ -0,0 +1,91 @@ +""" +Drop unused indexes on non-partitioned tables. + +These tables are not partitioned, so RemoveIndexConcurrently can be used safely. +""" + +from uuid import uuid4 + +from django.contrib.postgres.operations import RemoveIndexConcurrently +from django.db import migrations, models + + +def drop_resource_scan_summary_resource_id_index(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + SELECT idx_ns.nspname, idx.relname + FROM pg_class tbl + JOIN pg_namespace tbl_ns ON tbl_ns.oid = tbl.relnamespace + JOIN pg_index i ON i.indrelid = tbl.oid + JOIN pg_class idx ON idx.oid = i.indexrelid + JOIN pg_namespace idx_ns ON idx_ns.oid = idx.relnamespace + JOIN pg_attribute a + ON a.attrelid = tbl.oid + AND a.attnum = (i.indkey::int[])[0] + WHERE tbl_ns.nspname = ANY (current_schemas(false)) + AND tbl.relname = %s + AND i.indnatts = 1 + AND a.attname = %s + """, + ["resource_scan_summaries", "resource_id"], + ) + row = cursor.fetchone() + + if not row: + return + + schema_name, index_name = row + quote_name = schema_editor.connection.ops.quote_name + qualified_name = f"{quote_name(schema_name)}.{quote_name(index_name)}" + schema_editor.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified_name};") + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0071_drop_partitioned_indexes"), + ] + + operations = [ + RemoveIndexConcurrently( + model_name="resource", + name="gin_resources_search_idx", + ), + RemoveIndexConcurrently( + model_name="resourcetag", + name="gin_resource_tags_search_idx", + ), + RemoveIndexConcurrently( + model_name="scansummary", + name="ss_tenant_scan_service_idx", + ), + RemoveIndexConcurrently( + model_name="complianceoverview", + name="comp_ov_cp_id_idx", + ), + RemoveIndexConcurrently( + model_name="complianceoverview", + name="comp_ov_req_fail_idx", + ), + RemoveIndexConcurrently( + model_name="complianceoverview", + name="comp_ov_cp_id_req_fail_idx", + ), + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython( + drop_resource_scan_summary_resource_id_index, + reverse_code=migrations.RunPython.noop, + ), + ], + state_operations=[ + migrations.AlterField( + model_name="resourcescansummary", + name="resource_id", + field=models.UUIDField(default=uuid4), + ), + ], + ), + ] diff --git a/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py new file mode 100644 index 0000000000..671fdf5ef6 --- /dev/null +++ b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py @@ -0,0 +1,31 @@ +from functools import partial + +from django.db import migrations + +from api.db_utils import create_index_on_partitions, drop_index_on_partitions + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0072_drop_unused_indexes"), + ] + + operations = [ + migrations.RunPython( + partial( + create_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_fail_new_idx", + columns="tenant_id, scan_id", + where="status = 'FAIL' AND delta = 'new'", + all_partitions=True, + ), + reverse_code=partial( + drop_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_fail_new_idx", + ), + ) + ] diff --git a/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py b/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py new file mode 100644 index 0000000000..a889ba0ed4 --- /dev/null +++ b/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py @@ -0,0 +1,54 @@ +from django.db import migrations, models + +INDEX_NAME = "find_tenant_scan_fail_new_idx" +PARENT_TABLE = "findings" + + +def create_parent_and_attach(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + f"CREATE INDEX {INDEX_NAME} ON ONLY {PARENT_TABLE} " + f"USING btree (tenant_id, scan_id) " + f"WHERE status = 'FAIL' AND delta = 'new'" + ) + cursor.execute( + "SELECT inhrelid::regclass::text " + "FROM pg_inherits " + "WHERE inhparent = %s::regclass", + [PARENT_TABLE], + ) + for (partition,) in cursor.fetchall(): + child_idx = f"{partition.replace('.', '_')}_{INDEX_NAME}" + cursor.execute(f"ALTER INDEX {INDEX_NAME} ATTACH PARTITION {child_idx}") + + +def drop_parent_index(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute(f"DROP INDEX IF EXISTS {INDEX_NAME}") + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0073_findings_fail_new_index_partitions"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AddIndex( + model_name="finding", + index=models.Index( + condition=models.Q(status="FAIL", delta="new"), + fields=["tenant_id", "scan_id"], + name=INDEX_NAME, + ), + ), + ], + database_operations=[ + migrations.RunPython( + create_parent_and_attach, + reverse_code=drop_parent_index, + ), + ], + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index aa568bd17c..fdd52cb8d1 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -12,7 +12,6 @@ from cryptography.fernet import Fernet, InvalidToken from django.conf import settings from django.contrib.auth.models import AbstractBaseUser from django.contrib.postgres.fields import ArrayField -from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVector, SearchVectorField from django.contrib.sites.models import Site from django.core.exceptions import ValidationError @@ -626,6 +625,101 @@ class Scan(RowLevelSecurityProtectedModel): resource_name = "scans" +class AttackPathsScan(RowLevelSecurityProtectedModel): + objects = ActiveProviderManager() + all_objects = models.Manager() + + id = models.UUIDField(primary_key=True, default=uuid7, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE) + progress = models.IntegerField(default=0) + + # Timing + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + duration = models.IntegerField( + null=True, blank=True, help_text="Duration in seconds" + ) + + # Relationship to the provider and optional prowler Scan and celery Task + provider = models.ForeignKey( + "Provider", + on_delete=models.CASCADE, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + ) + scan = models.ForeignKey( + "Scan", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + ) + task = models.ForeignKey( + "Task", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + ) + + # Cartography specific metadata + update_tag = models.BigIntegerField( + null=True, blank=True, help_text="Cartography update tag (epoch)" + ) + graph_database = models.CharField(max_length=63, null=True, blank=True) + is_graph_database_deleted = models.BooleanField(default=False) + ingestion_exceptions = models.JSONField(default=dict, null=True, blank=True) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "attack_paths_scans" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "provider_id", "-inserted_at"], + name="aps_prov_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "state", "-inserted_at"], + name="aps_state_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "scan_id"], + name="aps_scan_lookup_idx", + ), + models.Index( + fields=["tenant_id", "provider_id"], + name="aps_active_graph_idx", + include=["graph_database", "id"], + condition=Q(is_graph_database_deleted=False), + ), + models.Index( + fields=["tenant_id", "provider_id", "-completed_at"], + name="aps_completed_graph_idx", + include=["graph_database", "id"], + condition=Q( + state=StateChoices.COMPLETED, + is_graph_database_deleted=False, + ), + ), + ] + + class JSONAPIMeta: + resource_name = "attack-paths-scans" + + class ResourceTag(RowLevelSecurityProtectedModel): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) @@ -646,10 +740,6 @@ class ResourceTag(RowLevelSecurityProtectedModel): class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "resource_tags" - indexes = [ - GinIndex(fields=["text_search"], name="gin_resource_tags_search_idx"), - ] - constraints = [ models.UniqueConstraint( fields=("tenant_id", "key", "value"), @@ -704,6 +794,12 @@ class Resource(RowLevelSecurityProtectedModel): metadata = models.TextField(blank=True, null=True) details = models.TextField(blank=True, null=True) partition = models.TextField(blank=True, null=True) + groups = ArrayField( + models.CharField(max_length=100), + blank=True, + null=True, + help_text="Groups for categorization (e.g., compute, storage, IAM)", + ) failed_findings_count = models.IntegerField(default=0) @@ -752,7 +848,6 @@ class Resource(RowLevelSecurityProtectedModel): fields=["tenant_id", "service", "region", "type"], name="resource_tenant_metadata_idx", ), - GinIndex(fields=["text_search"], name="gin_resources_search_idx"), models.Index(fields=["tenant_id", "id"], name="resources_tenant_id_idx"), models.Index( fields=["tenant_id", "provider_id"], @@ -890,6 +985,11 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): null=True, help_text="Categories from check metadata for efficient filtering", ) + resource_groups = models.TextField( + blank=True, + null=True, + help_text="Resource group from check metadata for efficient filtering", + ) # Relationships scan = models.ForeignKey(to=Scan, related_name="findings", on_delete=models.CASCADE) @@ -932,23 +1032,19 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): indexes = [ models.Index(fields=["tenant_id", "id"], name="findings_tenant_and_id_idx"), - GinIndex(fields=["text_search"], name="gin_findings_search_idx"), models.Index(fields=["tenant_id", "scan_id"], name="find_tenant_scan_idx"), models.Index( fields=["tenant_id", "scan_id", "id"], name="find_tenant_scan_id_idx" ), models.Index( - fields=["tenant_id", "id"], - condition=Q(delta="new"), - name="find_delta_new_idx", + condition=models.Q(status=StatusChoices.FAIL, delta="new"), + fields=["tenant_id", "scan_id"], + name="find_tenant_scan_fail_new_idx", ), models.Index( fields=["tenant_id", "uid", "-inserted_at"], name="find_tenant_uid_inserted_idx", ), - GinIndex(fields=["resource_services"], name="gin_find_service_idx"), - GinIndex(fields=["resource_regions"], name="gin_find_region_idx"), - GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"), models.Index( fields=["tenant_id", "scan_id", "check_id"], name="find_tenant_scan_check_idx", @@ -1016,10 +1112,6 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected # - id indexes = [ - models.Index( - fields=["tenant_id", "finding_id"], - name="rfm_tenant_finding_idx", - ), models.Index( fields=["tenant_id", "resource_id"], name="rfm_tenant_resource_idx", @@ -1336,14 +1428,6 @@ class ComplianceOverview(RowLevelSecurityProtectedModel): statements=["SELECT", "INSERT", "DELETE"], ), ] - indexes = [ - models.Index(fields=["compliance_id"], name="comp_ov_cp_id_idx"), - models.Index(fields=["requirements_failed"], name="comp_ov_req_fail_idx"), - models.Index( - fields=["compliance_id", "requirements_failed"], - name="comp_ov_cp_id_req_fail_idx", - ), - ] class JSONAPIMeta: resource_name = "compliance-overviews" @@ -1509,10 +1593,6 @@ class ScanSummary(RowLevelSecurityProtectedModel): fields=["tenant_id", "scan_id"], name="scan_summaries_tenant_scan_idx", ), - models.Index( - fields=["tenant_id", "scan_id", "service"], - name="ss_tenant_scan_service_idx", - ), models.Index( fields=["tenant_id", "scan_id", "severity"], name="ss_tenant_scan_severity_idx", @@ -1927,7 +2007,7 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel): class ResourceScanSummary(RowLevelSecurityProtectedModel): scan_id = models.UUIDField(default=uuid7, db_index=True) - resource_id = models.UUIDField(default=uuid4, db_index=True) + resource_id = models.UUIDField(default=uuid4) service = models.CharField(max_length=100) region = models.CharField(max_length=100) resource_type = models.CharField(max_length=100) @@ -2032,6 +2112,67 @@ class ScanCategorySummary(RowLevelSecurityProtectedModel): resource_name = "scan-category-summaries" +class ScanGroupSummary(RowLevelSecurityProtectedModel): + """ + Pre-aggregated resource group metrics per scan by severity. + + Stores one row per (resource_group, severity) combination per scan for efficient + overview queries. Resource groups come from check_metadata.Group. + + Count relationships (each is a subset of the previous): + - total_findings >= failed_findings >= new_failed_findings + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="resource_group_summaries", + related_query_name="resource_group_summary", + ) + + resource_group = models.CharField(max_length=50) + severity = SeverityEnumField(choices=SeverityChoices) + + total_findings = models.IntegerField( + default=0, help_text="Non-muted findings (PASS + FAIL)" + ) + failed_findings = models.IntegerField( + default=0, help_text="Non-muted FAIL findings (subset of total_findings)" + ) + new_failed_findings = models.IntegerField( + default=0, + help_text="Non-muted FAIL with delta='new' (subset of failed_findings)", + ) + resources_count = models.IntegerField( + default=0, help_text="Count of distinct resource_uid values" + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "scan_resource_group_summaries" + + indexes = [ + models.Index(fields=["tenant_id", "scan"], name="srgs_tenant_scan_idx"), + ] + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "scan_id", "resource_group", "severity"), + name="unique_resource_group_severity_per_scan", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + class JSONAPIMeta: + resource_name = "scan-resource-group-summaries" + + class LighthouseConfiguration(RowLevelSecurityProtectedModel): """ Stores configuration and API keys for LLM services. diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 2e238a5ad4..5acfad545c 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.18.0 + version: 1.19.0 description: |- Prowler API specification. @@ -280,6 +280,439 @@ paths: schema: $ref: '#/components/schemas/OpenApiResponseResponse' description: API key was successfully revoked + /api/v1/attack-paths-scans: + get: + operationId: attack_paths_scans_list + description: Retrieve Attack Paths scans for the tenant with support for filtering, + ordering, and pagination. + summary: List Attack Paths scans + parameters: + - in: query + name: fields[attack-paths-scans] + schema: + type: array + items: + type: string + enum: + - state + - progress + - provider + - provider_alias + - provider_type + - provider_uid + - scan + - task + - inserted_at + - started_at + - completed_at + - duration + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[completed_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 684bf4173d2b754f + enum: + - alibabacloud + - aws + - azure + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 684bf4173d2b754f + enum: + - alibabacloud + - aws + - azure + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[started_at] + schema: + type: string + format: date + - in: query + name: filter[state] + schema: + type: string + x-spec-enum-id: d38ba07264e1ed34 + enum: + - available + - cancelled + - completed + - executing + - failed + - scheduled + description: |- + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + - in: query + name: filter[state__in] + schema: + type: array + items: + type: string + x-spec-enum-id: d38ba07264e1ed34 + enum: + - available + - cancelled + - completed + - executing + - failed + - scheduled + description: |- + Multiple values may be separated by commas. + + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + explode: false + style: form + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + - scan + - task + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - inserted_at + - -inserted_at + - started_at + - -started_at + explode: false + tags: + - Attack Paths + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedAttackPathsScanList' + description: '' + /api/v1/attack-paths-scans/{id}: + get: + operationId: attack_paths_scans_retrieve + description: Fetch full details for a specific Attack Paths scan. + summary: Retrieve Attack Paths scan details + parameters: + - in: query + name: fields[attack-paths-scans] + schema: + type: array + items: + type: string + enum: + - state + - progress + - provider + - provider_alias + - provider_type + - provider_uid + - scan + - task + - inserted_at + - started_at + - completed_at + - duration + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this attack paths scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + - scan + - task + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Attack Paths + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/AttackPathsScanResponse' + description: '' + /api/v1/attack-paths-scans/{id}/queries: + get: + operationId: attack_paths_scans_queries_retrieve + description: Retrieve the catalog of Attack Paths queries available for this + Attack Paths scan. + summary: List attack paths queries + parameters: + - in: query + name: fields[attack-paths-scans] + schema: + type: array + items: + type: string + enum: + - state + - progress + - provider + - provider_alias + - provider_type + - provider_uid + - scan + - task + - inserted_at + - started_at + - completed_at + - duration + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this attack paths scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + - scan + - task + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Attack Paths + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedAttackPathsQueryList' + description: '' + '404': + description: No queries found for the selected provider + /api/v1/attack-paths-scans/{id}/queries/run: + post: + operationId: attack_paths_scans_queries_run_create + description: Execute the selected Attack Paths query against the Attack Paths + graph and return the resulting subgraph. + summary: Execute an Attack Paths query + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this attack paths scan. + required: true + tags: + - Attack Paths + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest' + required: true + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OpenApiResponseResponse' + description: '' + '400': + description: Bad request (e.g., Unknown Attack Paths query for the selected + provider) + '404': + description: No attack paths found for the given query and parameters + '500': + description: Attack Paths query execution failed due to a database error /api/v1/compliance-overviews: get: operationId: compliance_overviews_list @@ -712,6 +1145,7 @@ paths: - check_id - check_metadata - categories + - resource_groups - raw_result - inserted_at - updated_at @@ -988,6 +1422,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -1239,6 +1686,7 @@ paths: - check_id - check_metadata - categories + - resource_groups - raw_result - inserted_at - updated_at @@ -1560,6 +2008,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -1789,6 +2250,7 @@ paths: - check_id - check_metadata - categories + - resource_groups - raw_result - inserted_at - updated_at @@ -2040,6 +2502,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -2251,6 +2726,7 @@ paths: - regions - resource_types - categories + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false @@ -2518,6 +2994,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -2742,6 +3231,7 @@ paths: - regions - resource_types - categories + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false @@ -2984,6 +3474,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -4968,12 +5471,12 @@ paths: description: '' /api/v1/overviews/compliance-watchlist: get: - operationId: overviews_compliance_watchlist_retrieve - description: |- - Get compliance watchlist overview with FAIL-dominant aggregation. - - Without filters: uses pre-aggregated TenantComplianceSummary (~70 rows). - With provider filters: queries ProviderComplianceScore with FAIL-dominant logic. + operationId: overviews_compliance_watchlist_list + description: 'Retrieve compliance metrics with FAIL-dominant aggregation. Without + filters: uses pre-aggregated TenantComplianceSummary. With provider filters: + queries ProviderComplianceScore with FAIL-dominant logic where any FAIL in + a requirement marks it as failed.' + summary: Get compliance watchlist overview parameters: - in: query name: fields[compliance-watchlist-overviews] @@ -4991,6 +5494,119 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - alibabacloud + - aws + - azure + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - compliance_id + - -compliance_id + - requirements_passed + - -requirements_passed + - requirements_failed + - -requirements_failed + - requirements_manual + - -requirements_manual + - total_requirements + - -total_requirements + explode: false tags: - Overview security: @@ -5000,7 +5616,7 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ComplianceWatchlistOverviewResponse' + $ref: '#/components/schemas/PaginatedComplianceWatchlistOverviewList' description: '' /api/v1/overviews/findings: get: @@ -5797,6 +6413,170 @@ paths: schema: $ref: '#/components/schemas/OverviewRegionResponse' description: '' + /api/v1/overviews/resource-groups: + get: + operationId: overviews_resource_groups_list + description: Retrieve aggregated resource group metrics from latest completed + scans per provider. Returns one row per resource group with total, failed, + and new failed findings counts, plus a severity breakdown showing failed findings + per severity level, and a count of distinct resources evaluated per group. + summary: Get resource group overview + parameters: + - in: query + name: fields[resource-group-overviews] + schema: + type: array + items: + type: string + enum: + - id + - total_findings + - failed_findings + - new_failed_findings + - resources_count + - severity + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 684bf4173d2b754f + enum: + - alibabacloud + - aws + - azure + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 684bf4173d2b754f + enum: + - alibabacloud + - aws + - azure + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + explode: false + style: form + - in: query + name: filter[resource_group] + schema: + type: string + - in: query + name: filter[resource_group__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - total_findings + - -total_findings + - failed_findings + - -failed_findings + - new_failed_findings + - -new_failed_findings + - resources_count + - -resources_count + - severity + - -severity + explode: false + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedResourceGroupOverviewList' + description: '' /api/v1/overviews/services: get: operationId: overviews_services_retrieve @@ -7333,10 +8113,24 @@ paths: - metadata - details - partition + - groups - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[inserted_at] schema: @@ -7392,6 +8186,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -7673,6 +8482,7 @@ paths: - metadata - details - partition + - groups - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -7707,6 +8517,64 @@ paths: schema: $ref: '#/components/schemas/ResourceResponse' description: '' + /api/v1/resources/{id}/events: + get: + operationId: resources_events_list + description: |- + Retrieve events showing modification history for a resource. Returns who modified the resource and when. Currently only available for AWS resources. + + **Note:** Some events may not appear due to CloudTrail indexing limitations. Not all AWS API calls record the resource identifier in a searchable format. + summary: Get events for a resource + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this resource. + required: true + - in: query + name: include_read_events + schema: + type: boolean + description: 'Include read-only events (Describe*, Get*, List*, etc.). Default: + false. Set to true to include all events.' + - in: query + name: lookback_days + schema: + type: integer + description: 'Number of days to look back (default: 90, min: 1, max: 90).' + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - in: query + name: page[size] + schema: + type: integer + description: 'Maximum number of events to return (default: 50, min: 1, max: + 50).' + tags: + - Resource + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedResourceEventList' + description: '' + '400': + description: Invalid provider or parameters + '500': + description: Unexpected error retrieving events + '502': + description: Provider credentials invalid, expired, or lack required permissions + '503': + description: Provider service unavailable /api/v1/resources/latest: get: operationId: resources_latest_retrieve @@ -7735,10 +8603,24 @@ paths: - metadata - details - partition + - groups - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[name] schema: @@ -7779,6 +8661,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -8003,9 +8900,23 @@ paths: - services - regions - types + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[inserted_at] schema: @@ -8061,6 +8972,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -8306,9 +9232,23 @@ paths: - services - regions - types + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[name] schema: @@ -8349,6 +9289,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -11321,6 +12276,349 @@ paths: description: '' components: schemas: + AttackPathsNode: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-result-nodes + attributes: + type: object + properties: + id: + type: string + labels: + type: array + items: + type: string + properties: + type: object + additionalProperties: {} + required: + - id + - labels + - properties + AttackPathsQuery: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-queries + id: {} + attributes: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + provider: + type: string + parameters: + type: array + items: + $ref: '#/components/schemas/AttackPathsQueryParameter' + required: + - id + - name + - description + - provider + - parameters + AttackPathsQueryParameter: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-parameters + id: {} + attributes: + type: object + properties: + name: + type: string + label: + type: string + data_type: + type: string + default: string + description: + type: string + nullable: true + placeholder: + type: string + nullable: true + required: + - name + - label + AttackPathsQueryResult: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-results + attributes: + type: object + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/AttackPathsNode' + relationships: + type: array + items: + $ref: '#/components/schemas/AttackPathsRelationship' + required: + - nodes + - relationships + AttackPathsQueryRunRequestRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-run-requests + attributes: + type: object + properties: + id: + type: string + minLength: 1 + parameters: + type: object + additionalProperties: {} + required: + - id + required: + - data + AttackPathsRelationship: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-result-relationships + attributes: + type: object + properties: + id: + type: string + label: + type: string + source: + type: string + target: + type: string + properties: + type: object + additionalProperties: {} + required: + - id + - label + - source + - target + - properties + AttackPathsScan: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-scans + id: + type: string + format: uuid + attributes: + type: object + properties: + state: + enum: + - available + - scheduled + - executing + - completed + - failed + - cancelled + type: string + description: |- + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + x-spec-enum-id: d38ba07264e1ed34 + readOnly: true + progress: + type: integer + maximum: 2147483647 + minimum: -2147483648 + provider_alias: + type: string + readOnly: true + provider_type: + type: string + readOnly: true + provider_uid: + type: string + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + duration: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + description: Duration in seconds + relationships: + type: object + properties: + provider: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - providers + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + scan: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - scans + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + nullable: true + task: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - tasks + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + nullable: true + required: + - provider + AttackPathsScanResponse: + type: object + properties: + data: + $ref: '#/components/schemas/AttackPathsScan' + required: + - data AttackSurfaceOverview: type: object required: @@ -11573,13 +12871,6 @@ components: - requirements_failed - requirements_manual - total_requirements - ComplianceWatchlistOverviewResponse: - type: object - properties: - data: - $ref: '#/components/schemas/ComplianceWatchlistOverview' - required: - - data Finding: type: object required: @@ -11654,6 +12945,10 @@ components: maxLength: 100 nullable: true description: Categories from check metadata for efficient filtering + resource_groups: + type: string + nullable: true + description: Resource group from check metadata for efficient filtering raw_result: {} inserted_at: type: string @@ -11808,6 +13103,10 @@ components: type: array items: type: string + groups: + type: array + items: + type: string required: - services - regions @@ -14515,6 +15814,24 @@ components: $ref: '#/components/schemas/OverviewSeverity' required: - data + PaginatedAttackPathsQueryList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AttackPathsQuery' + required: + - data + PaginatedAttackPathsScanList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AttackPathsScan' + required: + - data PaginatedAttackSurfaceOverviewList: type: object properties: @@ -14560,6 +15877,15 @@ components: $ref: '#/components/schemas/ComplianceOverview' required: - data + PaginatedComplianceWatchlistOverviewList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComplianceWatchlistOverview' + required: + - data PaginatedFindingList: type: object properties: @@ -14677,6 +16003,24 @@ components: $ref: '#/components/schemas/ProviderSecret' required: - data + PaginatedResourceEventList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ResourceEvent' + required: + - data + PaginatedResourceGroupOverviewList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ResourceGroupOverview' + required: + - data PaginatedResourceList: type: object properties: @@ -18681,6 +20025,14 @@ components: type: string readOnly: true nullable: true + groups: + type: array + items: + type: string + maxLength: 100 + readOnly: true + nullable: true + description: Groups for categorization (e.g., compute, storage, IAM) type: type: string readOnly: true @@ -18742,6 +20094,101 @@ components: readOnly: true required: - provider + ResourceEvent: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - resource-events + id: {} + attributes: + type: object + properties: + id: + type: string + event_time: + type: string + format: date-time + event_name: + type: string + event_source: + type: string + actor: + type: string + actor_uid: + type: string + nullable: true + actor_type: + type: string + nullable: true + source_ip_address: + type: string + nullable: true + user_agent: + type: string + nullable: true + request_data: + nullable: true + response_data: + nullable: true + error_code: + type: string + nullable: true + error_message: + type: string + nullable: true + required: + - id + - event_time + - event_name + - event_source + - actor + ResourceGroupOverview: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - resource-group-overviews + id: {} + attributes: + type: object + properties: + id: + type: string + total_findings: + type: integer + failed_findings: + type: integer + new_failed_findings: + type: integer + resources_count: + type: integer + severity: + description: 'Severity breakdown: {informational, low, medium, high, + critical}' + required: + - id + - total_findings + - failed_findings + - new_failed_findings + - resources_count + - severity ResourceMetadata: type: object required: @@ -18772,10 +20219,15 @@ components: type: array items: type: string + groups: + type: array + items: + type: string required: - services - regions - types + - groups ResourceMetadataResponse: type: object properties: @@ -20795,6 +22247,8 @@ tags: revoking tasks that have not started. - name: Scan description: Endpoints for triggering manual scans and viewing scan results. +- name: Attack Paths + description: Endpoints for Attack Paths scan status and executing Attack Paths queries. - name: Schedule description: Endpoints for managing scan schedules, allowing configuration of automated scans with different scheduling options. diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 1509705894..712bc33882 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -1,10 +1,13 @@ import os +import sys +import types from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from django.conf import settings +import api import api.apps as api_apps_module from api.apps import ( ApiConfig, @@ -150,3 +153,82 @@ def test_ensure_crypto_keys_skips_when_env_vars(monkeypatch, tmp_path): # Assert: orchestrator did not trigger generation when env present assert called["ensure"] is False + + +@pytest.fixture(autouse=True) +def stub_api_modules(): + """Provide dummy modules imported during ApiConfig.ready().""" + created = [] + for name in ("api.schema_extensions", "api.signals"): + if name not in sys.modules: + sys.modules[name] = types.ModuleType(name) + created.append(name) + + yield + + for name in created: + sys.modules.pop(name, None) + + +def _set_argv(monkeypatch, argv): + monkeypatch.setattr(sys, "argv", argv, raising=False) + + +def _set_testing(monkeypatch, value): + monkeypatch.setattr(settings, "TESTING", value, raising=False) + + +def _make_app(): + return ApiConfig("api", api) + + +def test_ready_initializes_driver_for_api_process(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["gunicorn"]) + _set_testing(monkeypatch, False) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_called_once() + + +def test_ready_skips_driver_for_celery(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["celery", "-A", "api"]) + _set_testing(monkeypatch, False) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_not_called() + + +def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["manage.py", "migrate"]) + _set_testing(monkeypatch, False) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_not_called() + + +def test_ready_skips_driver_when_testing(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["gunicorn"]) + _set_testing(monkeypatch, True) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_not_called() diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py new file mode 100644 index 0000000000..2c4e1484f8 --- /dev/null +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -0,0 +1,172 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from rest_framework.exceptions import APIException, ValidationError + +from api.attack_paths import database as graph_database +from api.attack_paths import views_helpers + + +def test_normalize_run_payload_extracts_attributes_section(): + payload = { + "data": { + "id": "ignored", + "attributes": { + "id": "aws-rds", + "parameters": {"ip": "192.0.2.0"}, + }, + } + } + + result = views_helpers.normalize_run_payload(payload) + + assert result == {"id": "aws-rds", "parameters": {"ip": "192.0.2.0"}} + + +def test_normalize_run_payload_passthrough_for_non_dict(): + sentinel = "not-a-dict" + assert views_helpers.normalize_run_payload(sentinel) is sentinel + + +def test_prepare_query_parameters_includes_provider_and_casts( + attack_paths_query_definition_factory, +): + definition = attack_paths_query_definition_factory(cast_type=int) + result = views_helpers.prepare_query_parameters( + definition, + {"limit": "5"}, + provider_uid="123456789012", + ) + + assert result["provider_uid"] == "123456789012" + assert result["limit"] == 5 + + +@pytest.mark.parametrize( + "provided,expected_message", + [ + ({}, "Missing required parameter"), + ({"limit": 10, "extra": True}, "Unknown parameter"), + ], +) +def test_prepare_query_parameters_validates_names( + attack_paths_query_definition_factory, provided, expected_message +): + definition = attack_paths_query_definition_factory() + + with pytest.raises(ValidationError) as exc: + views_helpers.prepare_query_parameters(definition, provided, provider_uid="1") + + assert expected_message in str(exc.value) + + +def test_prepare_query_parameters_validates_cast( + attack_paths_query_definition_factory, +): + definition = attack_paths_query_definition_factory(cast_type=int) + + with pytest.raises(ValidationError) as exc: + views_helpers.prepare_query_parameters( + definition, + {"limit": "not-an-int"}, + provider_uid="1", + ) + + assert "Invalid value" in str(exc.value) + + +def test_execute_attack_paths_query_serializes_graph( + attack_paths_query_definition_factory, attack_paths_graph_stub_classes +): + definition = attack_paths_query_definition_factory( + id="aws-rds", + name="RDS", + description="", + cypher="MATCH (n) RETURN n", + parameters=[], + ) + parameters = {"provider_uid": "123"} + attack_paths_scan = SimpleNamespace(graph_database="tenant-db") + + node = attack_paths_graph_stub_classes.Node( + element_id="node-1", + labels=["AWSAccount"], + properties={ + "name": "account", + "complex": { + "items": [ + attack_paths_graph_stub_classes.NativeValue("value"), + {"nested": 1}, + ] + }, + }, + ) + relationship = attack_paths_graph_stub_classes.Relationship( + element_id="rel-1", + rel_type="OWNS", + start_node=node, + end_node=attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance"], {}), + properties={"weight": 1}, + ) + graph = SimpleNamespace(nodes=[node], relationships=[relationship]) + + run_result = MagicMock() + run_result.graph.return_value = graph + + session = MagicMock() + session.run.return_value = run_result + + session_ctx = MagicMock() + session_ctx.__enter__.return_value = session + session_ctx.__exit__.return_value = False + + with patch( + "api.attack_paths.views_helpers.graph_database.get_session", + return_value=session_ctx, + ) as mock_get_session: + result = views_helpers.execute_attack_paths_query( + attack_paths_scan, definition, parameters + ) + + mock_get_session.assert_called_once_with("tenant-db") + session.run.assert_called_once_with(definition.cypher, parameters) + assert result["nodes"][0]["id"] == "node-1" + assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value" + assert result["relationships"][0]["label"] == "OWNS" + + +def test_execute_attack_paths_query_wraps_graph_errors( + attack_paths_query_definition_factory, +): + definition = attack_paths_query_definition_factory( + id="aws-rds", + name="RDS", + description="", + cypher="MATCH (n) RETURN n", + parameters=[], + ) + attack_paths_scan = SimpleNamespace(graph_database="tenant-db") + parameters = {"provider_uid": "123"} + + class ExplodingContext: + def __enter__(self): + raise graph_database.GraphDatabaseQueryException("boom") + + def __exit__(self, exc_type, exc, tb): + return False + + with ( + patch( + "api.attack_paths.views_helpers.graph_database.get_session", + return_value=ExplodingContext(), + ), + patch("api.attack_paths.views_helpers.logger") as mock_logger, + ): + with pytest.raises(APIException): + views_helpers.execute_attack_paths_query( + attack_paths_scan, definition, parameters + ) + + mock_logger.error.assert_called_once() diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py new file mode 100644 index 0000000000..46ba101c4a --- /dev/null +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -0,0 +1,303 @@ +""" +Tests for Neo4j database lazy initialization. + +The Neo4j driver connects on first use by default. API processes may +eagerly initialize the driver during app startup, while Celery workers +remain lazy. These tests validate the database module behavior itself. +""" + +import threading +from unittest.mock import MagicMock, patch + +import pytest + + +class TestLazyInitialization: + """Test that Neo4j driver is initialized lazily on first use.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + def test_driver_not_initialized_at_import(self): + """Driver should be None after module import (no eager connection).""" + import api.attack_paths.database as db_module + + assert db_module._driver is None + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_init_driver_creates_connection_on_first_call( + self, mock_driver_factory, mock_settings + ): + """init_driver() should create connection only when called.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + assert db_module._driver is None + + result = db_module.init_driver() + + mock_driver_factory.assert_called_once() + mock_driver.verify_connectivity.assert_called_once() + assert result is mock_driver + assert db_module._driver is mock_driver + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_init_driver_returns_cached_driver_on_subsequent_calls( + self, mock_driver_factory, mock_settings + ): + """Subsequent calls should return cached driver without reconnecting.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + first_result = db_module.init_driver() + second_result = db_module.init_driver() + third_result = db_module.init_driver() + + # Only one connection attempt + assert mock_driver_factory.call_count == 1 + assert mock_driver.verify_connectivity.call_count == 1 + + # All calls return same instance + assert first_result is second_result is third_result + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_get_driver_delegates_to_init_driver( + self, mock_driver_factory, mock_settings + ): + """get_driver() should use init_driver() for lazy initialization.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + result = db_module.get_driver() + + assert result is mock_driver + mock_driver_factory.assert_called_once() + + +class TestAtexitRegistration: + """Test that atexit cleanup handler is registered correctly.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.atexit.register") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_atexit_registered_on_first_init( + self, mock_driver_factory, mock_atexit_register, mock_settings + ): + """atexit.register should be called on first initialization.""" + import api.attack_paths.database as db_module + + mock_driver_factory.return_value = MagicMock() + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + db_module.init_driver() + + mock_atexit_register.assert_called_once_with(db_module.close_driver) + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.atexit.register") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_atexit_registered_only_once( + self, mock_driver_factory, mock_atexit_register, mock_settings + ): + """atexit.register should only be called once across multiple inits. + + The double-checked locking on _driver ensures the atexit registration + block only executes once (when _driver is first created). + """ + import api.attack_paths.database as db_module + + mock_driver_factory.return_value = MagicMock() + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + db_module.init_driver() + db_module.init_driver() + db_module.init_driver() + + # Only registered once because subsequent calls hit the fast path + assert mock_atexit_register.call_count == 1 + + +class TestCloseDriver: + """Test driver cleanup functionality.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + def test_close_driver_closes_and_clears_driver(self): + """close_driver() should close the driver and set it to None.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + db_module._driver = mock_driver + + db_module.close_driver() + + mock_driver.close.assert_called_once() + assert db_module._driver is None + + def test_close_driver_handles_none_driver(self): + """close_driver() should handle case where driver is None.""" + import api.attack_paths.database as db_module + + db_module._driver = None + + # Should not raise + db_module.close_driver() + + assert db_module._driver is None + + def test_close_driver_clears_driver_even_on_close_error(self): + """Driver should be cleared even if close() raises an exception.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver.close.side_effect = Exception("Connection error") + db_module._driver = mock_driver + + with pytest.raises(Exception, match="Connection error"): + db_module.close_driver() + + # Driver should still be cleared + assert db_module._driver is None + + +class TestThreadSafety: + """Test thread-safe initialization.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_concurrent_init_creates_single_driver( + self, mock_driver_factory, mock_settings + ): + """Multiple threads calling init_driver() should create only one driver.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + results = [] + errors = [] + + def call_init(): + try: + result = db_module.init_driver() + results.append(result) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=call_init) for _ in range(10)] + + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Threads raised errors: {errors}" + + # Only one driver created + assert mock_driver_factory.call_count == 1 + + # All threads got the same driver instance + assert all(r is mock_driver for r in results) + assert len(results) == 10 diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 7312335921..8774f33787 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -6,7 +6,6 @@ from api.compliance import ( get_prowler_provider_checks, get_prowler_provider_compliance, load_prowler_checks, - load_prowler_compliance, ) from api.models import Provider @@ -35,55 +34,6 @@ class TestCompliance: assert compliance_data == mock_compliance.get_bulk.return_value mock_compliance.get_bulk.assert_called_once_with(provider_type) - @patch("api.models.Provider.ProviderChoices") - @patch("api.compliance.get_prowler_provider_compliance") - @patch("api.compliance.generate_compliance_overview_template") - @patch("api.compliance.load_prowler_checks") - def test_load_prowler_compliance( - self, - mock_load_prowler_checks, - mock_generate_compliance_overview_template, - mock_get_prowler_provider_compliance, - mock_provider_choices, - ): - mock_provider_choices.values = ["aws", "azure"] - - compliance_data_aws = {"compliance_aws": MagicMock()} - compliance_data_azure = {"compliance_azure": MagicMock()} - - compliance_data_dict = { - "aws": compliance_data_aws, - "azure": compliance_data_azure, - } - - def mock_get_compliance(provider_type): - return compliance_data_dict[provider_type] - - mock_get_prowler_provider_compliance.side_effect = mock_get_compliance - - mock_generate_compliance_overview_template.return_value = { - "template_key": "template_value" - } - - mock_load_prowler_checks.return_value = {"checks_key": "checks_value"} - - load_prowler_compliance() - - from api.compliance import PROWLER_CHECKS, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE - - assert PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE == { - "template_key": "template_value" - } - assert PROWLER_CHECKS == {"checks_key": "checks_value"} - - expected_prowler_compliance = compliance_data_dict - mock_get_prowler_provider_compliance.assert_any_call("aws") - mock_get_prowler_provider_compliance.assert_any_call("azure") - mock_generate_compliance_overview_template.assert_called_once_with( - expected_prowler_compliance - ) - mock_load_prowler_checks.assert_called_once_with(expected_prowler_compliance) - @patch("api.compliance.get_prowler_provider_checks") @patch("api.models.Provider.ProviderChoices") def test_load_prowler_checks( diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index f2a0c299f2..8d4fdabcbe 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -32,6 +32,10 @@ from django_celery_results.models import TaskResult from rest_framework import status from rest_framework.response import Response +from api.attack_paths import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) from api.compliance import get_compliance_frameworks from api.db_router import MainRouter from api.models import ( @@ -3602,6 +3606,423 @@ class TestTaskViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST +@pytest.mark.django_db +class TestAttackPathsScanViewSet: + @staticmethod + def _run_payload(query_id="aws-rds", parameters=None): + return { + "data": { + "type": "attack-paths-query-run-requests", + "attributes": { + "id": query_id, + "parameters": parameters or {}, + }, + } + } + + def test_attack_paths_scans_list_returns_latest_entry_per_provider( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + other_provider = providers_fixture[1] + + older_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.AVAILABLE, + progress=10, + ) + latest_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + progress=95, + ) + other_provider_scan = create_attack_paths_scan( + other_provider, + scan=scans_fixture[2], + state=StateChoices.FAILED, + progress=50, + ) + + response = authenticated_client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + ids = {item["id"] for item in data} + assert ids == {str(latest_scan.id), str(other_provider_scan.id)} + assert str(older_scan.id) not in ids + + provider_entry = next( + item + for item in data + if item["relationships"]["provider"]["data"]["id"] == str(provider.id) + ) + + first_attributes = provider_entry["attributes"] + assert first_attributes["provider_alias"] == provider.alias + assert first_attributes["provider_type"] == provider.provider + assert first_attributes["provider_uid"] == provider.uid + + def test_attack_paths_scans_list_respects_provider_group_visibility( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + create_attack_paths_scan, + ): + 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[0] + denied_provider = providers_fixture[1] + + allowed_scan = create_attack_paths_scan(allowed_provider) + create_attack_paths_scan(denied_provider) + + provider_group = ProviderGroup.objects.create( + name="limited-group", + tenant_id=tenant.id, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + limited_role = limited_user.roles.first() + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_role, + provider_group=provider_group, + ) + + response = client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == str(allowed_scan.id) + + def test_attack_paths_scan_retrieve( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + progress=80, + ) + + response = authenticated_client.get( + reverse("attack-paths-scans-detail", kwargs={"pk": attack_paths_scan.id}) + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["id"] == str(attack_paths_scan.id) + assert data["relationships"]["provider"]["data"]["id"] == str(provider.id) + assert data["attributes"]["state"] == StateChoices.COMPLETED + + def test_attack_paths_scan_retrieve_not_found_for_foreign_tenant( + self, authenticated_client, create_attack_paths_scan + ): + other_tenant = Tenant.objects.create(name="Foreign AttackPaths Tenant") + foreign_provider = Provider.objects.create( + provider="aws", + uid="333333333333", + alias="foreign", + tenant_id=other_tenant.id, + ) + foreign_scan = create_attack_paths_scan(foreign_provider) + + response = authenticated_client.get( + reverse("attack-paths-scans-detail", kwargs={"pk": foreign_scan.id}) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_attack_paths_queries_returns_catalog( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + ) + + definitions = [ + AttackPathsQueryDefinition( + id="aws-rds", + name="RDS inventory", + description="List account RDS assets", + provider=provider.provider, + cypher="MATCH (n) RETURN n", + parameters=[ + AttackPathsQueryParameterDefinition(name="ip", label="IP address") + ], + ) + ] + + with patch( + "api.v1.views.get_queries_for_provider", return_value=definitions + ) as mock_get_queries: + response = authenticated_client.get( + reverse( + "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id} + ) + ) + + assert response.status_code == status.HTTP_200_OK + mock_get_queries.assert_called_once_with(provider.provider) + payload = response.json()["data"] + assert len(payload) == 1 + assert payload[0]["id"] == "aws-rds" + assert payload[0]["attributes"]["name"] == "RDS inventory" + assert payload[0]["attributes"]["parameters"][0]["name"] == "ip" + + def test_attack_paths_queries_returns_404_when_catalog_missing( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan(provider, scan=scans_fixture[0]) + + with patch("api.v1.views.get_queries_for_provider", return_value=[]): + response = authenticated_client.get( + reverse( + "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id} + ) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "No queries found" in str(response.json()) + + def test_run_attack_paths_query_returns_graph( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_database="tenant-db", + ) + query_definition = AttackPathsQueryDefinition( + id="aws-rds", + name="RDS inventory", + description="List account RDS assets", + provider=provider.provider, + cypher="MATCH (n) RETURN n", + parameters=[], + ) + prepared_parameters = {"provider_uid": provider.uid} + graph_payload = { + "nodes": [ + { + "id": "node-1", + "labels": ["AWSAccount"], + "properties": {"name": "root"}, + } + ], + "relationships": [ + { + "id": "rel-1", + "label": "OWNS", + "source": "node-1", + "target": "node-2", + "properties": {}, + } + ], + } + + with ( + patch( + "api.v1.views.get_query_by_id", return_value=query_definition + ) as mock_get_query, + patch( + "api.v1.views.attack_paths_views_helpers.prepare_query_parameters", + return_value=prepared_parameters, + ) as mock_prepare, + patch( + "api.v1.views.attack_paths_views_helpers.execute_attack_paths_query", + return_value=graph_payload, + ) as mock_execute, + patch("api.v1.views.graph_database.clear_cache") as mock_clear_cache, + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._run_payload("aws-rds"), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_200_OK + mock_get_query.assert_called_once_with("aws-rds") + mock_prepare.assert_called_once_with( + query_definition, + {}, + attack_paths_scan.provider.uid, + ) + mock_execute.assert_called_once_with( + attack_paths_scan, + query_definition, + prepared_parameters, + ) + mock_clear_cache.assert_called_once_with(attack_paths_scan.graph_database) + result = response.json()["data"] + attributes = result["attributes"] + assert attributes["nodes"] == graph_payload["nodes"] + assert attributes["relationships"] == graph_payload["relationships"] + + def test_run_attack_paths_query_requires_completed_scan( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.EXECUTING, + ) + + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id} + ), + data=self._run_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "must be completed" in response.json()["errors"][0]["detail"] + + def test_run_attack_paths_query_requires_graph_database( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_database=None, + ) + + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id} + ), + data=self._run_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "does not reference a graph database" in str(response.json()) + + def test_run_attack_paths_query_unknown_query( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + ) + + with patch("api.v1.views.get_query_by_id", return_value=None): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._run_payload("unknown-query"), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Unknown Attack Paths query" in response.json()["errors"][0]["detail"] + + def test_run_attack_paths_query_returns_404_when_no_nodes_found( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + ) + query_definition = AttackPathsQueryDefinition( + id="aws-empty", + name="empty", + description="", + provider=provider.provider, + cypher="MATCH (n) RETURN n", + ) + + with ( + patch("api.v1.views.get_query_by_id", return_value=query_definition), + patch( + "api.v1.views.attack_paths_views_helpers.prepare_query_parameters", + return_value={"provider_uid": provider.uid}, + ), + patch( + "api.v1.views.attack_paths_views_helpers.execute_attack_paths_query", + return_value={"nodes": [], "relationships": []}, + ), + patch("api.v1.views.graph_database.clear_cache"), + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._run_payload("aws-empty"), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + payload = response.json() + if "data" in payload: + attributes = payload["data"].get("attributes", {}) + assert attributes.get("nodes") == [] + assert attributes.get("relationships") == [] + else: + assert "errors" in payload + + @pytest.mark.django_db class TestResourceViewSet: def test_resources_list_none(self, authenticated_client): @@ -3625,6 +4046,7 @@ class TestResourceViewSet: assert "metadata" in response.json()["data"][0]["attributes"] assert "details" in response.json()["data"][0]["attributes"] assert "partition" in response.json()["data"][0]["attributes"] + assert "groups" in response.json()["data"][0]["attributes"] @pytest.mark.parametrize( "include_values, expected_resources", @@ -3699,6 +4121,10 @@ class TestResourceViewSet: # full text search on resource tags ("search", "multi word", 1), ("search", "key2", 2), + # groups filter (ArrayField) + ("groups", "compute", 2), + ("groups", "storage", 1), + ("groups.in", "compute,storage", 3), ] ), ) @@ -3845,12 +4271,14 @@ class TestResourceViewSet: expected_services = {"ec2", "s3"} expected_regions = {"us-east-1", "eu-west-1"} expected_resource_types = {"prowler-test"} + expected_groups = {"compute", "storage"} assert data["data"]["type"] == "resources-metadata" assert data["data"]["id"] is None assert set(data["data"]["attributes"]["services"]) == expected_services assert set(data["data"]["attributes"]["regions"]) == expected_regions assert set(data["data"]["attributes"]["types"]) == expected_resource_types + assert set(data["data"]["attributes"]["groups"]) == expected_groups def test_resources_metadata_resource_filter_retrieve( self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture @@ -3886,6 +4314,7 @@ class TestResourceViewSet: assert data["data"]["attributes"]["services"] == [] assert data["data"]["attributes"]["regions"] == [] assert data["data"]["attributes"]["types"] == [] + assert data["data"]["attributes"]["groups"] == [] def test_resources_metadata_invalid_date(self, authenticated_client): response = authenticated_client.get( @@ -3925,6 +4354,826 @@ class TestResourceViewSet: assert attributes["services"] == [latest_scan_resource.service] assert attributes["regions"] == [latest_scan_resource.region] assert attributes["types"] == [latest_scan_resource.type] + assert "groups" in attributes + + def test_resources_latest_filter_by_provider_id( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id__in filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in_multiple( + self, authenticated_client, providers_fixture + ): + """Test that provider_id__in filter works with multiple provider IDs.""" + provider1, provider2 = providers_fixture[0], providers_fixture[1] + tenant_id = str(provider1.tenant_id) + + # Create completed scans for both providers + Scan.objects.create( + name="scan for provider 1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + Scan.objects.create( + name="scan for provider 2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + + # Create resources for each provider + resource1 = Resource.objects.create( + tenant_id=tenant_id, + provider=provider1, + uid="resource_provider_1", + name="Resource Provider 1", + region="us-east-1", + service="ec2", + type="instance", + ) + Resource.objects.create( + tenant_id=tenant_id, + provider=provider2, + uid="resource_provider_2", + name="Resource Provider 2", + region="us-west-2", + service="s3", + type="bucket", + ) + + # Test filtering by both providers + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + # Test filtering by single provider returns only that provider's resource + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid + + def test_resources_latest_filter_by_provider_id_no_match( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter returns empty when no match.""" + non_existent_id = str(uuid4()) + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": non_existent_id}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + + # Events endpoint tests + def test_events_non_aws_provider(self, authenticated_client, providers_fixture): + """Test events endpoint rejects non-AWS providers.""" + from api.models import Resource + + azure_provider = providers_fixture[4] # Azure provider from fixture + + resource = Resource.objects.create( + uid="test-resource-id", + name="Test Resource", + type="test-type", + region="us-east-1", + service="test-service", + provider=azure_provider, + tenant_id=azure_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "invalid_provider" + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["pointer"] == "/data/attributes/provider" + assert "AWS" in error["detail"] + + @pytest.mark.parametrize( + "lookback_days,expected_status,expected_code,expected_detail_contains", + [ + ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"), + ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ("91", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ("-5", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ], + ) + def test_events_invalid_lookback_days( + self, + authenticated_client, + providers_fixture, + lookback_days, + expected_status, + expected_code, + expected_detail_contains, + ): + """Test events endpoint validates lookback_days with JSON:API compliant errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"lookback_days": lookback_days}, + ) + + assert response.status_code == expected_status + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == expected_code + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["parameter"] == "lookback_days" + assert expected_detail_contains in error["detail"] + + @pytest.mark.parametrize( + "page_size,expected_status,expected_code,expected_detail_contains", + [ + ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"), + ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ("51", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ("-1", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ], + ) + def test_events_invalid_page_size( + self, + authenticated_client, + providers_fixture, + page_size, + expected_status, + expected_code, + expected_detail_contains, + ): + """Test events endpoint validates page[size] with JSON:API compliant errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-pagesize-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"page[size]": page_size}, + ) + + assert response.status_code == expected_status + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == expected_code + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["parameter"] == "page[size]" + assert expected_detail_contains in error["detail"] + + @pytest.mark.parametrize( + "invalid_params,expected_invalid_param", + [ + ({"filter[service]": "ec2"}, "filter[service]"), + ({"filter[region]": "us-east-1"}, "filter[region]"), + ({"sort": "-name"}, "sort"), + ({"unknown_param": "value"}, "unknown_param"), + ({"filter[servic]": "ec2"}, "filter[servic]"), # Typo in filter name + ], + ) + def test_events_invalid_query_parameter( + self, + authenticated_client, + providers_fixture, + invalid_params, + expected_invalid_param, + ): + """Test events endpoint rejects unknown query parameters with JSON:API compliant errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + invalid_params, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Verify JSON:API error structure + errors = response.json()["errors"] + assert len(errors) >= 1 + + # Find the error for our expected invalid param + error = next( + (e for e in errors if e["source"]["parameter"] == expected_invalid_param), + None, + ) + assert ( + error is not None + ), f"Expected error for parameter '{expected_invalid_param}'" + assert error["code"] == "invalid" + assert error["status"] == "400" # Must be string per JSON:API spec + assert expected_invalid_param in error["detail"] + + def test_events_multiple_invalid_query_parameters( + self, + authenticated_client, + providers_fixture, + ): + """Test events endpoint returns error for first unknown parameter.""" + from api.models import Resource + + aws_provider = providers_fixture[0] + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Send multiple invalid parameters - only first one triggers error + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"filter[service]": "ec2", "sort": "-name", "unknown": "value"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Should have one error for the first invalid parameter encountered + errors = response.json()["errors"] + assert len(errors) == 1 + assert errors[0]["code"] == "invalid" + assert errors[0]["status"] == "400" + assert errors[0]["source"]["parameter"] in { + "filter[service]", + "sort", + "unknown", + } + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_success( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test successful events retrieval.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + # Create test resource + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test123", + name="Test EC2 Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider session + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock CloudTrail timeline response - events need event_id for serializer + mock_timeline_instance = Mock() + mock_events = [ + { + "event_id": "event-1-id", + "event_time": "2024-01-15T10:30:00Z", + "event_name": "RunInstances", + "event_source": "ec2.amazonaws.com", + "actor": "admin@example.com", + "actor_type": "IAMUser", + "source_ip_address": "203.0.113.1", + "user_agent": "aws-cli/2.0.0", + }, + { + "event_id": "event-2-id", + "event_time": "2024-01-16T14:20:00Z", + "event_name": "StopInstances", + "event_source": "ec2.amazonaws.com", + "actor": "operator@example.com", + "actor_type": "IAMUser", + }, + ] + mock_timeline_instance.get_resource_timeline.return_value = mock_events + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + # Make request with lookback_days parameter + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"lookback_days": "30"}, + ) + + # Assertions - response is wrapped by JSON:API renderer + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + events = response_data["data"] + + assert len(events) == 2 + + # Verify JSON:API structure: type and id are present + assert events[0]["type"] == "resource-events" + assert events[0]["id"] == "event-1-id" + assert events[1]["type"] == "resource-events" + assert events[1]["id"] == "event-2-id" + + # Verify attributes + assert events[0]["attributes"]["event_name"] == "RunInstances" + assert events[0]["attributes"]["actor"] == "admin@example.com" + assert events[1]["attributes"]["event_name"] == "StopInstances" + + # Verify CloudTrail was called with correct parameters + mock_cloudtrail_timeline.assert_called_once_with( + session=mock_session, + lookback_days=30, + max_results=50, # Default page size + write_events_only=True, # Default: exclude read events + ) + mock_timeline_instance.get_resource_timeline.assert_called_once_with( + region=resource.region, + resource_uid=resource.uid, + ) + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_default_lookback_days( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events uses default lookback_days (90) when not provided.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:s3:::test-bucket", + name="Test Bucket", + type="bucket", + region="us-east-1", + service="s3", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider session + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock CloudTrail timeline response + mock_timeline_instance = Mock() + mock_timeline_instance.get_resource_timeline.return_value = [] + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify default lookback_days (90) was used + mock_cloudtrail_timeline.assert_called_once_with( + session=mock_session, + lookback_days=90, # Default + max_results=50, + write_events_only=True, + ) + + @patch("api.v1.views.initialize_prowler_provider") + def test_events_no_credentials_error( + self, mock_initialize_provider, authenticated_client, providers_fixture + ): + """Test events handles missing credentials errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:rds:us-west-2:123456789012:db:test-db", + name="Test Database", + type="db-instance", + region="us-west-2", + service="rds", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + mock_initialize_provider.side_effect = NoCredentialsError() + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # 502 because this is an upstream auth failure, not API auth failure + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_auth_failed" + assert error["status"] == "502" # Must be string per JSON:API spec + assert "detail" in error + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_access_denied_error( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events handles AccessDenied errors from AWS.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func", + name="Test Function", + type="function", + region="eu-west-1", + service="lambda", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock ClientError with AccessDenied + mock_timeline_instance = Mock() + mock_timeline_instance.get_resource_timeline.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, + "LookupEvents", + ) + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # AccessDenied returns 502 (upstream error, not user's fault) + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_access_denied" + assert error["status"] == "502" # Must be string per JSON:API spec + assert "detail" in error + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_service_unavailable_error( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events handles generic AWS API errors as 503.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func2", + name="Test Function 2", + type="function", + region="eu-west-1", + service="lambda", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock ClientError with non-AccessDenied error + mock_timeline_instance = Mock() + mock_timeline_instance.get_resource_timeline.side_effect = ClientError( + {"Error": {"Code": "ServiceUnavailable", "Message": "Service unavailable"}}, + "LookupEvents", + ) + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # Non-AccessDenied errors return 503 + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "service_unavailable" + assert error["status"] == "503" # Must be string per JSON:API spec + assert "detail" in error + + @patch("api.v1.views.initialize_prowler_provider") + def test_events_assume_role_access_denied( + self, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events handles AWSAssumeRoleError during provider init. + + This tests the scenario from CLOUD-API-3HJ where the API task role + cannot assume the customer's ProwlerScan role due to IAM permissions. + The error happens during initialize_prowler_provider, which wraps + the ClientError in AWSAssumeRoleError. + """ + from api.models import Resource + from prowler.providers.aws.exceptions.exceptions import AWSAssumeRoleError + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:lambda:eu-west-1:123456789012:function:assume-role-test", + name="AssumeRole Test Function", + type="function", + region="eu-west-1", + service="lambda", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock initialize_prowler_provider raising AWSAssumeRoleError + # (this is what aws_provider.py actually raises when AssumeRole fails) + original_error = ClientError( + { + "Error": { + "Code": "AccessDenied", + "Message": ( + "User: arn:aws:sts::123456789012:assumed-role/api-task-role/xxx " + "is not authorized to perform: sts:AssumeRole on resource: " + "arn:aws:iam::123456789012:role/ProwlerScan" + ), + } + }, + "AssumeRole", + ) + mock_initialize_provider.side_effect = AWSAssumeRoleError( + original_exception=original_error, + file="aws_provider.py", + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # AWSAssumeRoleError returns 502 (upstream auth failure) + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_access_denied" + assert error["status"] == "502" + assert "detail" in error + + def test_events_unauthenticated_returns_401(self, providers_fixture): + """Test events endpoint returns 401 when no credentials are provided. + + This ensures the endpoint follows API conventions where missing authentication + returns 401 Unauthorized, not 404 Not Found. + """ + from rest_framework.test import APIClient + + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-unauth-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Use unauthenticated client (no JWT token) + unauthenticated_client = APIClient() + + response = unauthenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # Must return 401 Unauthorized, not 404 Not Found + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"Expected 401 Unauthorized but got {response.status_code}. " + "Unauthenticated requests should return 401, not 404." + ) + + def test_events_cross_tenant_returns_404( + self, authenticated_client, tenants_fixture + ): + """Test events endpoint returns 404 for resources in other tenants (RLS). + + Users cannot access resources belonging to other tenants due to + Row-Level Security. The resource should appear to not exist. + """ + from api.models import Provider, Resource + + # tenant3 (tenants_fixture[2]) has no membership for the test user + isolated_tenant = tenants_fixture[2] + + # Create provider in the isolated tenant + other_tenant_provider = Provider.objects.create( + provider="aws", + uid="999999999999", + alias="other_tenant_aws", + tenant_id=isolated_tenant.id, + ) + + # Create resource in the OTHER tenant (not the authenticated user's tenant) + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:999999999999:instance/i-other-tenant", + name="Other Tenant Resource", + type="instance", + region="us-east-1", + service="ec2", + provider=other_tenant_provider, + tenant_id=isolated_tenant.id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # RLS hides resources from other tenants - should appear as not found + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_events_expired_token_returns_401(self, providers_fixture, tenants_fixture): + """Test events endpoint returns 401 when JWT token is expired. + + Expired tokens should return 401 Unauthorized, not 404 Not Found. + This ensures authentication errors are properly distinguished from + resource not found errors. + """ + from rest_framework.test import APIClient + + from api.models import Resource + + aws_provider = providers_fixture[0] + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-expired-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Create an expired JWT token + tenant = tenants_fixture[0] + expired_payload = { + "token_type": "access", + "exp": datetime.now(timezone.utc) + - timedelta(hours=1), # Expired 1 hour ago + "iat": datetime.now(timezone.utc) - timedelta(hours=2), + "jti": str(uuid4()), + "user_id": str(uuid4()), + "tenant_id": str(tenant.id), + } + expired_token = jwt.encode( + expired_payload, settings.SECRET_KEY, algorithm="HS256" + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}") + + response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) + + # Must return 401 Unauthorized, not 404 Not Found + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"Expected 401 Unauthorized but got {response.status_code}. " + "Expired tokens should return 401, not 404." + ) + + def test_events_invalid_token_returns_401(self, providers_fixture): + """Test events endpoint returns 401 when JWT token is completely invalid. + + Malformed or invalid tokens should return 401 Unauthorized, not 404 Not Found. + """ + from rest_framework.test import APIClient + + from api.models import Resource + + aws_provider = providers_fixture[0] + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-invalid-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + client = APIClient() + + # Test with completely malformed token + client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token") + response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) + assert ( + response.status_code == status.HTTP_401_UNAUTHORIZED + ), f"Expected 401 for malformed token but got {response.status_code}" + + # Test with empty bearer token + client.credentials(HTTP_AUTHORIZATION="Bearer ") + response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) + assert ( + response.status_code == status.HTTP_401_UNAUTHORIZED + ), f"Expected 401 for empty bearer token but got {response.status_code}" @pytest.mark.django_db @@ -4417,6 +5666,17 @@ class TestFindingViewSet: attributes = response.json()["data"]["attributes"] assert set(attributes["categories"]) == {"gen-ai", "iam"} + def test_findings_metadata_latest_groups( + self, authenticated_client, latest_scan_finding_with_categories + ): + response = authenticated_client.get( + reverse("finding-metadata_latest"), + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert "groups" in attributes + assert "ai_ml" in attributes["groups"] + def test_findings_filter_by_category( self, authenticated_client, findings_with_categories ): @@ -4463,6 +5723,49 @@ class TestFindingViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 0 + def test_findings_filter_by_resource_groups( + self, authenticated_client, findings_with_group + ): + finding = findings_with_group + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[resource_groups]": "storage", + "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["resource_groups"] == "storage" + + def test_findings_filter_by_resource_groups_in( + self, authenticated_client, findings_with_multiple_groups + ): + finding1, _ = findings_with_multiple_groups + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[resource_groups__in]": "storage,security", + "filter[inserted_at]": finding1.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + def test_findings_filter_by_resource_groups_no_match( + self, authenticated_client, findings_with_group + ): + finding = findings_with_group + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[resource_groups]": "nonexistent", + "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + @pytest.mark.django_db class TestJWTFields: @@ -8009,6 +9312,228 @@ class TestOverviewViewSet: assert data[0]["attributes"]["failed_findings"] == 13 assert data[0]["attributes"]["new_failed_findings"] == 5 + def test_overview_groups_no_data(self, authenticated_client): + response = authenticated_client.get(reverse("overview-resource-groups")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + + def test_overview_groups_aggregates_by_group_with_severity( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="resource-groups-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + # resources_count is group-level (same for all severities within a group) + create_scan_resource_group_summary( + tenant, + scan, + "storage", + "high", + total_findings=20, + failed_findings=10, + new_failed_findings=5, + resources_count=8, + ) + create_scan_resource_group_summary( + tenant, + scan, + "storage", + "medium", + total_findings=15, + failed_findings=7, + new_failed_findings=3, + resources_count=8, # Same as high - group-level count + ) + create_scan_resource_group_summary( + tenant, + scan, + "security", + "critical", + total_findings=10, + failed_findings=8, + new_failed_findings=2, + resources_count=4, + ) + + response = authenticated_client.get(reverse("overview-resource-groups")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + + storage_data = next(d for d in data if d["id"] == "storage") + security_data = next(d for d in data if d["id"] == "security") + + assert storage_data["attributes"]["total_findings"] == 35 + assert storage_data["attributes"]["failed_findings"] == 17 + assert storage_data["attributes"]["new_failed_findings"] == 8 + assert ( + storage_data["attributes"]["resources_count"] == 8 + ) # Group-level, not sum + assert security_data["attributes"]["total_findings"] == 10 + assert security_data["attributes"]["failed_findings"] == 8 + assert security_data["attributes"]["resources_count"] == 4 + + @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), + ], + ) + def test_overview_groups_provider_filters( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + filter_key, + filter_value_fn, + expected_total, + expected_failed, + ): + tenant = tenants_fixture[0] + provider1 = providers_fixture[0] # AWS + gcp_provider = providers_fixture[2] # GCP + + scan1 = Scan.objects.create( + name="aws-rg-scan", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="gcp-rg-scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_resource_group_summary( + tenant, scan1, "storage", "high", total_findings=10, failed_findings=5 + ) + create_scan_resource_group_summary( + tenant, scan2, "storage", "high", total_findings=15, failed_findings=7 + ) + + response = authenticated_client.get( + reverse("overview-resource-groups"), + {filter_key: filter_value_fn(provider1, gcp_provider)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["attributes"]["total_findings"] == expected_total + assert data[0]["attributes"]["failed_findings"] == expected_failed + + def test_overview_groups_group_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="rg-filter-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_resource_group_summary( + tenant, scan, "storage", "high", total_findings=10, failed_findings=5 + ) + create_scan_resource_group_summary( + tenant, scan, "compute", "medium", total_findings=20, failed_findings=8 + ) + create_scan_resource_group_summary( + tenant, scan, "security", "low", total_findings=15, failed_findings=3 + ) + + response = authenticated_client.get( + reverse("overview-resource-groups"), + {"filter[resource_group__in]": "storage,compute"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + group_ids = {item["id"] for item in data} + assert group_ids == {"storage", "compute"} + + def test_overview_groups_aggregates_multiple_providers( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = Scan.objects.create( + name="multi-provider-rg-scan-1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="multi-provider-rg-scan-2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_resource_group_summary( + tenant, + scan1, + "storage", + "high", + total_findings=10, + failed_findings=5, + new_failed_findings=2, + resources_count=4, + ) + create_scan_resource_group_summary( + tenant, + scan2, + "storage", + "high", + total_findings=15, + failed_findings=8, + new_failed_findings=3, + resources_count=6, + ) + + response = authenticated_client.get(reverse("overview-resource-groups")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == "storage" + assert data[0]["attributes"]["total_findings"] == 25 + assert data[0]["attributes"]["failed_findings"] == 13 + assert data[0]["attributes"]["new_failed_findings"] == 5 + assert data[0]["attributes"]["resources_count"] == 10 + def test_compliance_watchlist_no_filters_uses_tenant_summary( self, authenticated_client, tenant_compliance_summary_fixture ): @@ -9445,7 +10970,7 @@ class TestLighthouseConfigViewSet: "type": "lighthouse-configurations", "attributes": { "name": "OpenAI", - "api_key": "sk-test1234567890T3BlbkFJtest1234567890", + "api_key": "sk-fake-test-key-for-unit-testing-only", "model": "gpt-4o", "temperature": 0.7, "max_tokens": 4000, @@ -10907,7 +12432,7 @@ class TestLighthouseTenantConfigViewSet: provider_config = LighthouseProviderConfiguration.objects.create( tenant_id=tenants_fixture[0].id, provider_type="openai", - credentials=b'{"api_key": "sk-test1234567890T3BlbkFJtest1234567890"}', + credentials=b'{"api_key": "sk-fake-test-key-for-unit-testing-only"}', is_active=True, ) @@ -11043,7 +12568,7 @@ class TestLighthouseProviderConfigViewSet: "type": "lighthouse-providers", "attributes": { "provider_type": "testprovider", - "credentials": {"api_key": "sk-testT3BlbkFJkey"}, + "credentials": {"api_key": "sk-fake-test-key-1234"}, }, } } @@ -11075,7 +12600,7 @@ class TestLighthouseProviderConfigViewSet: "credentials", [ {}, # empty credentials - {"token": "sk-testT3BlbkFJkey"}, # wrong key name + {"token": "sk-fake-test-key-1234"}, # wrong key name {"api_key": "ks-invalid-format"}, # wrong format ], ) @@ -11099,7 +12624,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_valid_credentials_success(self, authenticated_client): """OpenAI provider with valid sk-xxx format should succeed""" - valid_key = "sk-abc123T3BlbkFJxyz456" + valid_key = "sk-fake-abc-test-key-xyz" payload = { "data": { "type": "lighthouse-providers", @@ -11124,7 +12649,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_provider_duplicate_per_tenant(self, authenticated_client): """If an OpenAI provider exists for tenant, creating again should error""" - valid_key = "sk-dup123T3BlbkFJdup456" + valid_key = "sk-fake-dup-test-key-456" payload = { "data": { "type": "lighthouse-providers", @@ -11153,7 +12678,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_patch_base_url_and_is_active(self, authenticated_client): """After creating, should be able to patch base_url and is_active""" - valid_key = "sk-patch123T3BlbkFJpatch456" + valid_key = "sk-fake-patch-test-key-456" create_payload = { "data": { "type": "lighthouse-providers", @@ -11193,7 +12718,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_patch_invalid_credentials(self, authenticated_client): """PATCH with invalid credentials.api_key should error (400)""" - valid_key = "sk-ok123T3BlbkFJok456" + valid_key = "sk-fake-ok-test-key-456" create_payload = { "data": { "type": "lighthouse-providers", @@ -11229,7 +12754,7 @@ class TestLighthouseProviderConfigViewSet: assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST def test_openai_get_masking_and_fields_filter(self, authenticated_client): - valid_key = "sk-get123T3BlbkFJget456" + valid_key = "sk-fake-get-test-key-456" create_payload = { "data": { "type": "lighthouse-providers", @@ -11275,7 +12800,7 @@ class TestLighthouseProviderConfigViewSet: provider = LighthouseProviderConfiguration.objects.create( tenant_id=tenant.id, provider_type="openai", - credentials=b'{"api_key":"sk-test123T3BlbkFJ"}', + credentials=b'{"api_key":"sk-fake-test-key-123"}', is_active=True, ) diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index bc203c1584..ca3e4db86b 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from datetime import datetime, timezone +from typing import TYPE_CHECKING from allauth.socialaccount.providers.oauth2.client import OAuth2Client from django.contrib.postgres.aggregates import ArrayAgg @@ -11,19 +14,25 @@ from api.exceptions import InvitationTokenExpiredException from api.models import Integration, Invitation, Processor, Provider, Resource from api.v1.serializers import FindingMetadataSerializer from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError -from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudProvider -from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.aws.lib.s3.s3 import S3 from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub -from prowler.providers.azure.azure_provider import AzureProvider from prowler.providers.common.models import Connection -from prowler.providers.gcp.gcp_provider import GcpProvider -from prowler.providers.github.github_provider import GithubProvider -from prowler.providers.iac.iac_provider import IacProvider -from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider -from prowler.providers.m365.m365_provider import M365Provider -from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider -from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider + +if TYPE_CHECKING: + from prowler.providers.alibabacloud.alibabacloud_provider import ( + AlibabacloudProvider, + ) + from prowler.providers.aws.aws_provider import AwsProvider + from prowler.providers.azure.azure_provider import AzureProvider + from prowler.providers.gcp.gcp_provider import GcpProvider + from prowler.providers.github.github_provider import GithubProvider + from prowler.providers.iac.iac_provider import IacProvider + from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider + from prowler.providers.m365.m365_provider import M365Provider + from prowler.providers.mongodbatlas.mongodbatlas_provider import ( + MongodbatlasProvider, + ) + from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider class CustomOAuth2Client(OAuth2Client): @@ -89,24 +98,52 @@ def return_prowler_provider( """ match provider.provider: case Provider.ProviderChoices.AWS.value: + from prowler.providers.aws.aws_provider import AwsProvider + prowler_provider = AwsProvider case Provider.ProviderChoices.GCP.value: + from prowler.providers.gcp.gcp_provider import GcpProvider + prowler_provider = GcpProvider case Provider.ProviderChoices.AZURE.value: + from prowler.providers.azure.azure_provider import AzureProvider + prowler_provider = AzureProvider case Provider.ProviderChoices.KUBERNETES.value: + from prowler.providers.kubernetes.kubernetes_provider import ( + KubernetesProvider, + ) + prowler_provider = KubernetesProvider case Provider.ProviderChoices.M365.value: + from prowler.providers.m365.m365_provider import M365Provider + prowler_provider = M365Provider case Provider.ProviderChoices.GITHUB.value: + from prowler.providers.github.github_provider import GithubProvider + prowler_provider = GithubProvider case Provider.ProviderChoices.MONGODBATLAS.value: + from prowler.providers.mongodbatlas.mongodbatlas_provider import ( + MongodbatlasProvider, + ) + prowler_provider = MongodbatlasProvider case Provider.ProviderChoices.IAC.value: + from prowler.providers.iac.iac_provider import IacProvider + prowler_provider = IacProvider case Provider.ProviderChoices.ORACLECLOUD.value: + from prowler.providers.oraclecloud.oraclecloud_provider import ( + OraclecloudProvider, + ) + prowler_provider = OraclecloudProvider case Provider.ProviderChoices.ALIBABACLOUD.value: + from prowler.providers.alibabacloud.alibabacloud_provider import ( + AlibabacloudProvider, + ) + prowler_provider = AlibabacloudProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") @@ -393,11 +430,21 @@ def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset): categories_set.update(categories_list) categories = sorted(categories_set) + # Aggregate groups from findings + groups = list( + filtered_queryset.exclude(resource_groups__isnull=True) + .exclude(resource_groups__exact="") + .values_list("resource_groups", flat=True) + .distinct() + .order_by("resource_groups") + ) + result = { "services": services, "regions": regions, "resource_types": resource_types, "categories": categories, + "groups": groups, } serializer = FindingMetadataSerializer(data=result) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 00c8c37dfb..fac8d1cd57 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -21,6 +21,7 @@ from rest_framework_simplejwt.tokens import RefreshToken from api.db_router import MainRouter from api.exceptions import ConflictException from api.models import ( + AttackPathsScan, Finding, Integration, IntegrationProviderRelationship, @@ -1132,6 +1133,109 @@ class ScanComplianceReportSerializer(BaseSerializerV1): fields = ["id", "name"] +class AttackPathsScanSerializer(RLSSerializer): + state = StateEnumSerializerField(read_only=True) + provider_alias = serializers.SerializerMethodField(read_only=True) + provider_type = serializers.SerializerMethodField(read_only=True) + provider_uid = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = AttackPathsScan + fields = [ + "id", + "state", + "progress", + "provider", + "provider_alias", + "provider_type", + "provider_uid", + "scan", + "task", + "inserted_at", + "started_at", + "completed_at", + "duration", + ] + + included_serializers = { + "provider": "api.v1.serializers.ProviderIncludeSerializer", + "scan": "api.v1.serializers.ScanIncludeSerializer", + "task": "api.v1.serializers.TaskSerializer", + } + + def get_provider_alias(self, obj): + provider = getattr(obj, "provider", None) + return provider.alias if provider else None + + def get_provider_type(self, obj): + provider = getattr(obj, "provider", None) + return provider.provider if provider else None + + def get_provider_uid(self, obj): + provider = getattr(obj, "provider", None) + return provider.uid if provider else None + + +class AttackPathsQueryParameterSerializer(BaseSerializerV1): + name = serializers.CharField() + label = serializers.CharField() + data_type = serializers.CharField(default="string") + description = serializers.CharField(allow_null=True, required=False) + placeholder = serializers.CharField(allow_null=True, required=False) + + class JSONAPIMeta: + resource_name = "attack-paths-query-parameters" + + +class AttackPathsQuerySerializer(BaseSerializerV1): + id = serializers.CharField() + name = serializers.CharField() + description = serializers.CharField() + provider = serializers.CharField() + parameters = AttackPathsQueryParameterSerializer(many=True) + + class JSONAPIMeta: + resource_name = "attack-paths-queries" + + +class AttackPathsQueryRunRequestSerializer(BaseSerializerV1): + id = serializers.CharField() + parameters = serializers.DictField( + child=serializers.JSONField(), allow_empty=True, required=False + ) + + class JSONAPIMeta: + resource_name = "attack-paths-query-run-requests" + + +class AttackPathsNodeSerializer(BaseSerializerV1): + id = serializers.CharField() + labels = serializers.ListField(child=serializers.CharField()) + properties = serializers.DictField(child=serializers.JSONField()) + + class JSONAPIMeta: + resource_name = "attack-paths-query-result-nodes" + + +class AttackPathsRelationshipSerializer(BaseSerializerV1): + id = serializers.CharField() + label = serializers.CharField() + source = serializers.CharField() + target = serializers.CharField() + properties = serializers.DictField(child=serializers.JSONField()) + + class JSONAPIMeta: + resource_name = "attack-paths-query-result-relationships" + + +class AttackPathsQueryResultSerializer(BaseSerializerV1): + nodes = AttackPathsNodeSerializer(many=True) + relationships = AttackPathsRelationshipSerializer(many=True) + + class JSONAPIMeta: + resource_name = "attack-paths-query-results" + + class ResourceTagSerializer(RLSSerializer): """ Serializer for the ResourceTag model @@ -1175,6 +1279,7 @@ class ResourceSerializer(RLSSerializer): "metadata", "details", "partition", + "groups", ] extra_kwargs = { "id": {"read_only": True}, @@ -1183,6 +1288,7 @@ class ResourceSerializer(RLSSerializer): "metadata": {"read_only": True}, "details": {"read_only": True}, "partition": {"read_only": True}, + "groups": {"read_only": True}, } included_serializers = { @@ -1276,6 +1382,7 @@ class ResourceMetadataSerializer(BaseSerializerV1): services = serializers.ListField(child=serializers.CharField(), allow_empty=True) regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) types = serializers.ListField(child=serializers.CharField(), allow_empty=True) + groups = serializers.ListField(child=serializers.CharField(), allow_empty=True) # Temporarily disabled until we implement tag filtering in the UI # tags = serializers.JSONField(help_text="Tags are described as key-value pairs.") @@ -1302,6 +1409,7 @@ class FindingSerializer(RLSSerializer): "check_id", "check_metadata", "categories", + "resource_groups", "raw_result", "inserted_at", "updated_at", @@ -1358,6 +1466,9 @@ class FindingMetadataSerializer(BaseSerializerV1): child=serializers.CharField(), allow_empty=True ) categories = serializers.ListField(child=serializers.CharField(), allow_empty=True) + groups = serializers.ListField( + child=serializers.CharField(), allow_empty=True, required=False, default=list + ) # Temporarily disabled until we implement tag filtering in the UI # tags = serializers.JSONField(help_text="Tags are described as key-value pairs.") @@ -2303,6 +2414,22 @@ class CategoryOverviewSerializer(BaseSerializerV1): resource_name = "category-overviews" +class ResourceGroupOverviewSerializer(BaseSerializerV1): + """Serializer for resource group overview aggregations.""" + + id = serializers.CharField(source="resource_group") + total_findings = serializers.IntegerField() + failed_findings = serializers.IntegerField() + new_failed_findings = serializers.IntegerField() + resources_count = serializers.IntegerField() + severity = serializers.JSONField( + help_text="Severity breakdown: {informational, low, medium, high, critical}" + ) + + class JSONAPIMeta: + resource_name = "resource-group-overviews" + + class ComplianceWatchlistOverviewSerializer(BaseSerializerV1): """Serializer for compliance watchlist overview with FAIL-dominant aggregation.""" @@ -3848,3 +3975,31 @@ class ThreatScoreSnapshotSerializer(RLSSerializer): if getattr(obj, "_aggregated", False): return "n/a" return str(obj.id) + + +# Resource Events Serializers + + +class ResourceEventSerializer(BaseSerializerV1): + """Serializer for resource events (CloudTrail modification history). + + NOTE: drf-spectacular auto-generates fields[resource-events] sparse fieldsets + parameter in the OpenAPI schema. This endpoint does not support sparse fieldsets. + """ + + id = serializers.CharField(source="event_id") + event_time = serializers.DateTimeField() + event_name = serializers.CharField() + event_source = serializers.CharField() + actor = serializers.CharField() + actor_uid = serializers.CharField(allow_null=True, required=False) + actor_type = serializers.CharField(allow_null=True, required=False) + source_ip_address = serializers.CharField(allow_null=True, required=False) + user_agent = serializers.CharField(allow_null=True, required=False) + request_data = serializers.JSONField(allow_null=True, required=False) + response_data = serializers.JSONField(allow_null=True, required=False) + error_code = serializers.CharField(allow_null=True, required=False) + error_message = serializers.CharField(allow_null=True, required=False) + + class Meta: + resource_name = "resource-events" diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index d879d1476b..840f027b42 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -4,6 +4,7 @@ from drf_spectacular.views import SpectacularRedocView from rest_framework_nested import routers from api.v1.views import ( + AttackPathsScanViewSet, ComplianceOverviewViewSet, CustomSAMLLoginView, CustomTokenObtainView, @@ -53,6 +54,9 @@ router.register(r"tenants", TenantViewSet, basename="tenant") router.register(r"providers", ProviderViewSet, basename="provider") router.register(r"provider-groups", ProviderGroupViewSet, basename="providergroup") router.register(r"scans", ScanViewSet, basename="scan") +router.register( + r"attack-paths-scans", AttackPathsScanViewSet, basename="attack-paths-scans" +) router.register(r"tasks", TaskViewSet, basename="task") router.register(r"resources", ResourceViewSet, basename="resource") router.register(r"findings", FindingViewSet, basename="finding") diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index ffdfc1005e..abb81d5a3d 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -41,8 +41,9 @@ from django.db.models import ( Sum, Value, When, + Window, ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, RowNumber from django.http import HttpResponse, QueryDict from django.shortcuts import redirect from django.urls import reverse @@ -73,6 +74,7 @@ from rest_framework.permissions import SAFE_METHODS from rest_framework_json_api.views import RelationshipView, Response from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from tasks.beat import schedule_provider_scan +from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.export import get_s3_client from tasks.tasks import ( backfill_compliance_summaries_task, @@ -89,6 +91,9 @@ from tasks.tasks import ( refresh_lighthouse_provider_models_task, ) +from api.attack_paths import database as graph_database +from api.attack_paths import get_queries_for_provider, get_query_by_id +from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, @@ -96,8 +101,15 @@ from api.compliance import ( ) from api.db_router import MainRouter from api.db_utils import rls_transaction -from api.exceptions import TaskFailedException +from api.exceptions import ( + TaskFailedException, + UpstreamAccessDeniedError, + UpstreamAuthenticationError, + UpstreamInternalError, + UpstreamServiceUnavailableError, +) from api.filters import ( + AttackPathsScanFilter, AttackSurfaceOverviewFilter, CategoryOverviewFilter, ComplianceOverviewFilter, @@ -119,6 +131,7 @@ from api.filters import ( ProviderGroupFilter, ProviderSecretFilter, ResourceFilter, + ResourceGroupOverviewFilter, RoleFilter, ScanFilter, ScanSummaryFilter, @@ -130,6 +143,7 @@ from api.filters import ( UserFilter, ) from api.models import ( + AttackPathsScan, AttackSurfaceOverview, ComplianceOverviewSummary, ComplianceRequirementOverview, @@ -160,6 +174,7 @@ from api.models import ( SAMLToken, Scan, ScanCategorySummary, + ScanGroupSummary, ScanSummary, SeverityChoices, StateChoices, @@ -176,11 +191,16 @@ from api.rls import Tenant from api.utils import ( CustomOAuth2Client, get_findings_metadata_no_aggregations, + initialize_prowler_provider, validate_invitation, ) from api.uuid_utils import datetime_to_uuid7, uuid7_start from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin from api.v1.serializers import ( + AttackPathsQueryResultSerializer, + AttackPathsQueryRunRequestSerializer, + AttackPathsQuerySerializer, + AttackPathsScanSerializer, AttackSurfaceOverviewSerializer, CategoryOverviewSerializer, ComplianceOverviewAttributesSerializer, @@ -233,6 +253,8 @@ from api.v1.serializers import ( ProviderSecretUpdateSerializer, ProviderSerializer, ProviderUpdateSerializer, + ResourceEventSerializer, + ResourceGroupOverviewSerializer, ResourceMetadataSerializer, ResourceSerializer, RoleCreateSerializer, @@ -262,6 +284,13 @@ from api.v1.serializers import ( UserSerializer, UserUpdateSerializer, ) +from prowler.providers.aws.exceptions.exceptions import ( + AWSAssumeRoleError, + AWSCredentialsError, +) +from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import ( + CloudTrailTimeline, +) logger = logging.getLogger(BackendLogger.API) @@ -363,7 +392,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.18.0" + spectacular_settings.VERSION = "1.19.0" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -405,6 +434,10 @@ class SchemaView(SpectacularAPIView): "name": "Scan", "description": "Endpoints for triggering manual scans and viewing scan results.", }, + { + "name": "Attack Paths", + "description": "Endpoints for Attack Paths scan status and executing Attack Paths queries.", + }, { "name": "Schedule", "description": "Endpoints for managing scan schedules, allowing configuration of automated " @@ -2155,6 +2188,12 @@ class ScanViewSet(BaseRLSViewSet): }, ) + attack_paths_db_utils.create_attack_paths_scan( + tenant_id=self.request.tenant_id, + scan_id=str(scan.id), + provider_id=str(scan.provider_id), + ) + prowler_task = Task.objects.get(id=task.id) scan.task_id = task.id scan.save(update_fields=["task_id"]) @@ -2235,6 +2274,188 @@ class TaskViewSet(BaseRLSViewSet): ) +@extend_schema_view( + list=extend_schema( + tags=["Attack Paths"], + summary="List Attack Paths scans", + description="Retrieve Attack Paths scans for the tenant with support for filtering, ordering, and pagination.", + ), + retrieve=extend_schema( + tags=["Attack Paths"], + summary="Retrieve Attack Paths scan details", + description="Fetch full details for a specific Attack Paths scan.", + ), + attack_paths_queries=extend_schema( + tags=["Attack Paths"], + summary="List attack paths queries", + description="Retrieve the catalog of Attack Paths queries available for this Attack Paths scan.", + responses={ + 200: OpenApiResponse(AttackPathsQuerySerializer(many=True)), + 404: OpenApiResponse( + description="No queries found for the selected provider" + ), + }, + ), + run_attack_paths_query=extend_schema( + tags=["Attack Paths"], + summary="Execute an Attack Paths query", + description="Execute the selected Attack Paths query against the Attack Paths graph and return the resulting subgraph.", + request=AttackPathsQueryRunRequestSerializer, + responses={ + 200: OpenApiResponse(AttackPathsQueryResultSerializer), + 400: OpenApiResponse( + description="Bad request (e.g., Unknown Attack Paths query for the selected provider)" + ), + 404: OpenApiResponse( + description="No attack paths found for the given query and parameters" + ), + 500: OpenApiResponse( + description="Attack Paths query execution failed due to a database error" + ), + }, + ), +) +class AttackPathsScanViewSet(BaseRLSViewSet): + queryset = AttackPathsScan.objects.all() + serializer_class = AttackPathsScanSerializer + http_method_names = ["get", "post"] + filterset_class = AttackPathsScanFilter + ordering = ["-inserted_at"] + ordering_fields = [ + "inserted_at", + "started_at", + ] + # RBAC required permissions + required_permissions = [Permissions.MANAGE_SCANS] + + def set_required_permissions(self): + if self.request.method in SAFE_METHODS: + self.required_permissions = [] + + else: + self.required_permissions = [Permissions.MANAGE_SCANS] + + def get_serializer_class(self): + if self.action == "run_attack_paths_query": + return AttackPathsQueryRunRequestSerializer + + return super().get_serializer_class() + + def get_queryset(self): + user_roles = get_role(self.request.user) + base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id) + + if user_roles.unlimited_visibility: + queryset = base_queryset + + else: + queryset = base_queryset.filter(provider__in=get_providers(user_roles)) + + return queryset.select_related("provider", "scan", "task") + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + latest_per_provider = queryset.annotate( + latest_scan_rank=Window( + expression=RowNumber(), + partition_by=[F("provider_id")], + order_by=[F("inserted_at").desc()], + ) + ).filter(latest_scan_rank=1) + + page = self.paginate_queryset(latest_per_provider) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(latest_per_provider, many=True) + return Response(serializer.data) + + @extend_schema(exclude=True) + def create(self, request, *args, **kwargs): + raise MethodNotAllowed(method="POST") + + @extend_schema(exclude=True) + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(method="DELETE") + + @action( + detail=True, + methods=["get"], + url_path="queries", + url_name="queries", + ) + def attack_paths_queries(self, request, pk=None): + attack_paths_scan = self.get_object() + queries = get_queries_for_provider(attack_paths_scan.provider.provider) + + if not queries: + return Response( + {"detail": "No queries found for the selected provider"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = AttackPathsQuerySerializer(queries, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action( + detail=True, + methods=["post"], + url_path="queries/run", + url_name="queries-run", + ) + def run_attack_paths_query(self, request, pk=None): + attack_paths_scan = self.get_object() + + if attack_paths_scan.state != StateChoices.COMPLETED: + raise ValidationError( + { + "detail": "The Attack Paths scan must be completed before running Attack Paths queries" + } + ) + + if not attack_paths_scan.graph_database: + logger.error( + f"The Attack Paths Scan {attack_paths_scan.id} does not reference a graph database" + ) + return Response( + {"detail": "The Attack Paths scan does not reference a graph database"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + payload = attack_paths_views_helpers.normalize_run_payload(request.data) + serializer = AttackPathsQueryRunRequestSerializer(data=payload) + serializer.is_valid(raise_exception=True) + + query_definition = get_query_by_id(serializer.validated_data["id"]) + if ( + query_definition is None + or query_definition.provider != attack_paths_scan.provider.provider + ): + raise ValidationError( + {"id": "Unknown Attack Paths query for the selected provider"} + ) + + parameters = attack_paths_views_helpers.prepare_query_parameters( + query_definition, + serializer.validated_data.get("parameters", {}), + attack_paths_scan.provider.uid, + ) + + graph = attack_paths_views_helpers.execute_attack_paths_query( + attack_paths_scan, query_definition, parameters + ) + graph_database.clear_cache(attack_paths_scan.graph_database) + + status_code = status.HTTP_200_OK + if not graph.get("nodes"): + status_code = status.HTTP_404_NOT_FOUND + + response_serializer = AttackPathsQueryResultSerializer(graph) + return Response(response_serializer.data, status=status_code) + + @extend_schema_view( list=extend_schema( tags=["Resource"], @@ -2293,6 +2514,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): http_method_names = ["get"] filterset_class = ResourceFilter ordering = ["-failed_findings_count", "-updated_at"] + + # Events endpoint constants (currently AWS-only, limited to 90 days by CloudTrail Event History) + EVENTS_DEFAULT_LOOKBACK_DAYS = 90 + EVENTS_MIN_LOOKBACK_DAYS = 1 + EVENTS_MAX_LOOKBACK_DAYS = 90 + # Page size controls how many events CloudTrail returns (prepares for API pagination) + EVENTS_DEFAULT_PAGE_SIZE = 50 + EVENTS_MIN_PAGE_SIZE = 1 + EVENTS_MAX_PAGE_SIZE = 50 # CloudTrail lookup_events max is 50 + # Allowed query parameters for the events endpoint + EVENTS_ALLOWED_PARAMS = frozenset( + {"lookback_days", "page[size]", "include_read_events"} + ) + ordering_fields = [ "provider_uid", "uid", @@ -2368,6 +2603,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): def get_serializer_class(self): if self.action in ["metadata", "metadata_latest"]: return ResourceMetadataSerializer + if self.action == "events": + return ResourceEventSerializer return super().get_serializer_class() def get_filterset_class(self): @@ -2376,8 +2613,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): return ResourceFilter def filter_queryset(self, queryset): - # Do not apply filters when retrieving specific resource - if self.action == "retrieve": + # Do not apply filters when retrieving specific resource or events + if self.action in ["retrieve", "events"]: return queryset return super().filter_queryset(queryset) @@ -2527,10 +2764,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): .order_by("resource_type") ) + # Get groups from Resource model (flatten ArrayField) + all_groups = Resource.objects.filter( + tenant_id=tenant_id, + groups__isnull=False, + ).values_list("groups", flat=True) + groups = sorted( + set(g for groups_list in all_groups if groups_list for g in groups_list) + ) + result = { "services": services, "regions": regions, "types": resource_types, + "groups": groups, } serializer = self.get_serializer(data=result) @@ -2587,16 +2834,243 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): .order_by("resource_type") ) + # Get groups from Resource model for resources in latest scans (flatten ArrayField) + all_groups = Resource.objects.filter( + tenant_id=tenant_id, + groups__isnull=False, + ).values_list("groups", flat=True) + groups = sorted( + set(g for groups_list in all_groups if groups_list for g in groups_list) + ) + result = { "services": services, "regions": regions, "types": resource_types, + "groups": groups, } serializer = self.get_serializer(data=result) serializer.is_valid(raise_exception=True) return Response(serializer.data) + @extend_schema( + tags=["Resource"], + summary="Get events for a resource", + description=( + "Retrieve events showing modification history for a resource. " + "Returns who modified the resource and when. Currently only available for AWS resources.\n\n" + "**Note:** Some events may not appear due to CloudTrail indexing limitations. " + "Not all AWS API calls record the resource identifier in a searchable format." + ), + parameters=[ + OpenApiParameter( + name="lookback_days", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of days to look back (default: 90, min: 1, max: 90).", + required=False, + ), + OpenApiParameter( + name="page[size]", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Maximum number of events to return (default: 50, min: 1, max: 50).", + required=False, + ), + OpenApiParameter( + name="include_read_events", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description=( + "Include read-only events (Describe*, Get*, List*, etc.). " + "Default: false. Set to true to include all events." + ), + required=False, + ), + # NOTE: drf-spectacular auto-generates page[number] and fields[resource-events] + # parameters. This endpoint does not support pagination (results are limited by + # page[size] only) nor sparse fieldsets. + ], + responses={ + 200: ResourceEventSerializer(many=True), + 400: OpenApiResponse(description="Invalid provider or parameters"), + 500: OpenApiResponse(description="Unexpected error retrieving events"), + 502: OpenApiResponse( + description="Provider credentials invalid, expired, or lack required permissions" + ), + 503: OpenApiResponse(description="Provider service unavailable"), + }, + ) + @action( + detail=True, + methods=["get"], + url_name="events", + filter_backends=[], # Disable filters - we're calling external API, not filtering queryset + ) + def events(self, request, pk=None): + """Get events for a resource.""" + resource = self.get_object() + + # Validate query parameters - reject unknown parameters + for param in request.query_params.keys(): + if param not in self.EVENTS_ALLOWED_PARAMS: + raise ValidationError( + [ + { + "detail": f"invalid parameter '{param}'", + "status": "400", + "source": {"parameter": param}, + "code": "invalid", + } + ] + ) + + # Validate provider - currently only AWS CloudTrail is supported + if resource.provider.provider != Provider.ProviderChoices.AWS: + raise ValidationError( + [ + { + "detail": "Events are only available for AWS resources", + "status": "400", + "source": {"pointer": "/data/attributes/provider"}, + "code": "invalid_provider", + } + ] + ) + + # Validate and parse lookback_days from query params + lookback_days_str = request.query_params.get("lookback_days") + if lookback_days_str is None: + lookback_days = self.EVENTS_DEFAULT_LOOKBACK_DAYS + else: + try: + lookback_days = int(lookback_days_str) + except (ValueError, TypeError): + raise ValidationError( + [ + { + "detail": "lookback_days must be a valid integer", + "status": "400", + "source": {"parameter": "lookback_days"}, + "code": "invalid", + } + ] + ) + + if not ( + self.EVENTS_MIN_LOOKBACK_DAYS + <= lookback_days + <= self.EVENTS_MAX_LOOKBACK_DAYS + ): + raise ValidationError( + [ + { + "detail": ( + f"lookback_days must be between {self.EVENTS_MIN_LOOKBACK_DAYS} " + f"and {self.EVENTS_MAX_LOOKBACK_DAYS}" + ), + "status": "400", + "source": {"parameter": "lookback_days"}, + "code": "out_of_range", + } + ] + ) + + # Validate and parse page[size] from query params (JSON:API pagination) + page_size_str = request.query_params.get("page[size]") + if page_size_str is None: + page_size = self.EVENTS_DEFAULT_PAGE_SIZE + else: + try: + page_size = int(page_size_str) + except (ValueError, TypeError): + raise ValidationError( + [ + { + "detail": "page[size] must be a valid integer", + "status": "400", + "source": {"parameter": "page[size]"}, + "code": "invalid", + } + ] + ) + + if not ( + self.EVENTS_MIN_PAGE_SIZE <= page_size <= self.EVENTS_MAX_PAGE_SIZE + ): + raise ValidationError( + [ + { + "detail": ( + f"page[size] must be between {self.EVENTS_MIN_PAGE_SIZE} " + f"and {self.EVENTS_MAX_PAGE_SIZE}" + ), + "status": "400", + "source": {"parameter": "page[size]"}, + "code": "out_of_range", + } + ] + ) + + # Parse include_read_events (default: false) + include_read_events = ( + request.query_params.get("include_read_events", "").lower() == "true" + ) + + try: + # Initialize Prowler provider using existing utility + prowler_provider = initialize_prowler_provider(resource.provider) + + # Get the boto3 session from the Prowler provider + session = prowler_provider._session.current_session + + # Create timeline service (currently only AWS/CloudTrail is supported) + timeline_service = CloudTrailTimeline( + session=session, + lookback_days=lookback_days, + max_results=page_size, + write_events_only=not include_read_events, + ) + + # Get timeline events + events = timeline_service.get_resource_timeline( + region=resource.region, + resource_uid=resource.uid, + ) + + serializer = ResourceEventSerializer(events, many=True) + return Response(serializer.data) + + except NoCredentialsError: + # 502 because this is an upstream auth failure, not API auth failure + raise UpstreamAuthenticationError( + detail="Credentials not found for this provider. Please reconnect the provider." + ) + except AWSAssumeRoleError: + # AssumeRole failed - usually IAM permission issue (not authorized to sts:AssumeRole) + raise UpstreamAccessDeniedError( + detail="Cannot assume role for this provider. Check IAM Role permissions and trust relationship." + ) + except AWSCredentialsError: + # Handles expired tokens, invalid keys, profile not found, etc. + raise UpstreamAuthenticationError() + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + # AccessDenied is expected when credentials lack permissions - don't log as error + if error_code in ("AccessDenied", "AccessDeniedException"): + raise UpstreamAccessDeniedError() + + # Unexpected ClientErrors should be logged for debugging + logger.error( + f"Provider API error retrieving events: {str(e)}", + exc_info=True, + ) + raise UpstreamServiceUnavailableError() + except Exception as e: + sentry_sdk.capture_exception(e) + raise UpstreamInternalError(detail="Failed to retrieve events") + @extend_schema_view( list=extend_schema( @@ -3019,11 +3493,23 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): categories_set.update(categories_list) categories = sorted(categories_set) + # Get groups from ScanGroupSummary for latest scans + groups = list( + ScanGroupSummary.objects.filter( + tenant_id=tenant_id, + scan_id__in=latest_scans_queryset.values_list("id", flat=True), + ) + .values_list("resource_group", flat=True) + .distinct() + .order_by("resource_group") + ) + result = { "services": services, "regions": regions, "resource_types": resource_types, "categories": categories, + "groups": groups, } serializer = self.get_serializer(data=result) @@ -3958,7 +4444,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): # If we couldn't determine from database, try each provider type if not provider_type: for pt in Provider.ProviderChoices.values: - if compliance_id in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(pt, {}): + if compliance_id in get_compliance_frameworks(pt): provider_type = pt break @@ -4097,6 +4583,30 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): filters=True, responses={200: CategoryOverviewSerializer(many=True)}, ), + resource_groups=extend_schema( + summary="Get resource group overview", + description=( + "Retrieve aggregated resource group metrics from latest completed scans per provider. " + "Returns one row per resource group with total, failed, and new failed findings counts, " + "plus a severity breakdown showing failed findings per severity level, " + "and a count of distinct resources evaluated per group." + ), + tags=["Overview"], + filters=True, + responses={200: ResourceGroupOverviewSerializer(many=True)}, + ), + compliance_watchlist=extend_schema( + summary="Get compliance watchlist overview", + description=( + "Retrieve compliance metrics with FAIL-dominant aggregation. " + "Without filters: uses pre-aggregated TenantComplianceSummary. " + "With provider filters: queries ProviderComplianceScore with FAIL-dominant logic " + "where any FAIL in a requirement marks it as failed." + ), + tags=["Overview"], + filters=True, + responses={200: ComplianceWatchlistOverviewSerializer(many=True)}, + ), ) @method_decorator(CACHE_DECORATOR, name="list") class OverviewViewSet(BaseRLSViewSet): @@ -4146,6 +4656,8 @@ class OverviewViewSet(BaseRLSViewSet): return AttackSurfaceOverviewSerializer elif self.action == "categories": return CategoryOverviewSerializer + elif self.action == "resource_groups": + return ResourceGroupOverviewSerializer elif self.action == "compliance_watchlist": return ComplianceWatchlistOverviewSerializer return super().get_serializer_class() @@ -4161,6 +4673,8 @@ class OverviewViewSet(BaseRLSViewSet): return DailySeveritySummaryFilter elif self.action == "categories": return CategoryOverviewFilter + elif self.action == "resource_groups": + return ResourceGroupOverviewFilter elif self.action == "attack_surface": return AttackSurfaceOverviewFilter elif self.action == "compliance_watchlist": @@ -5005,6 +5519,95 @@ class OverviewViewSet(BaseRLSViewSet): status=status.HTTP_200_OK, ) + @action( + detail=False, + methods=["get"], + url_name="resource-groups", + url_path="resource-groups", + ) + def resource_groups(self, request): + tenant_id = request.tenant_id + provider_filters = self._extract_provider_filters_from_params() + latest_scan_ids = self._latest_scan_ids_for_allowed_providers( + tenant_id, provider_filters + ) + + base_queryset = ScanGroupSummary.objects.filter( + tenant_id=tenant_id, scan_id__in=latest_scan_ids + ) + provider_filter_keys = { + "provider_id", + "provider_id__in", + "provider_type", + "provider_type__in", + } + filtered_queryset = self._apply_filterset( + base_queryset, + ResourceGroupOverviewFilter, + exclude_keys=provider_filter_keys, + ) + + aggregation = ( + filtered_queryset.values("resource_group", "severity") + .annotate( + total=Coalesce(Sum("total_findings"), 0), + failed=Coalesce(Sum("failed_findings"), 0), + new_failed=Coalesce(Sum("new_failed_findings"), 0), + ) + .order_by("resource_group", "severity") + ) + + # Get resource_group-level resources_count: + # 1. Max per (scan, resource_group) to deduplicate within-scan severity rows + # 2. Sum across scans for cross-provider aggregation + scan_resource_group_resources = filtered_queryset.values( + "scan_id", "resource_group" + ).annotate(resources=Coalesce(Max("resources_count"), 0)) + resources_by_resource_group = defaultdict(int) + for row in scan_resource_group_resources: + resources_by_resource_group[row["resource_group"]] += row["resources"] + + resource_group_data = defaultdict( + lambda: { + "total_findings": 0, + "failed_findings": 0, + "new_failed_findings": 0, + "resources_count": 0, + "severity": { + "informational": 0, + "low": 0, + "medium": 0, + "high": 0, + "critical": 0, + }, + } + ) + + for row in aggregation: + grp = row["resource_group"] + sev = row["severity"] + resource_group_data[grp]["total_findings"] += row["total"] + resource_group_data[grp]["failed_findings"] += row["failed"] + resource_group_data[grp]["new_failed_findings"] += row["new_failed"] + if sev in resource_group_data[grp]["severity"]: + resource_group_data[grp]["severity"][sev] = row["failed"] + + # Set resources_count from resource_group-level aggregation + for grp in resource_group_data: + resource_group_data[grp]["resources_count"] = ( + resources_by_resource_group.get(grp, 0) + ) + + response_data = [ + {"resource_group": grp, **data} + for grp, data in sorted(resource_group_data.items()) + ] + + return Response( + self.get_serializer(response_data, many=True).data, + status=status.HTTP_200_OK, + ) + @action( detail=False, methods=["get"], @@ -5760,7 +6363,7 @@ class TenantApiKeyViewSet(BaseRLSViewSet): @extend_schema(exclude=True) def destroy(self, request, *args, **kwargs): - raise MethodNotAllowed(method="DESTROY") + raise MethodNotAllowed(method="DELETE") @action(detail=True, methods=["delete"]) def revoke(self, request, *args, **kwargs): diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index b3a0ab4b68..aaa1b1c386 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -1,6 +1,7 @@ import warnings from celery import Celery, Task + from config.env import env # Suppress specific warnings from django-rest-auth: https://github.com/iMerica/dj-rest-auth/issues/684 diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 80b96952d7..c9e1b4750f 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -276,7 +276,7 @@ FINDINGS_MAX_DAYS_IN_RANGE = env.int("DJANGO_FINDINGS_MAX_DAYS_IN_RANGE", 7) DJANGO_TMP_OUTPUT_DIRECTORY = env.str( "DJANGO_TMP_OUTPUT_DIRECTORY", "/tmp/prowler_api_output" ) -DJANGO_FINDINGS_BATCH_SIZE = env.str("DJANGO_FINDINGS_BATCH_SIZE", 1000) +DJANGO_FINDINGS_BATCH_SIZE = env.int("DJANGO_FINDINGS_BATCH_SIZE", 1000) DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID = env.str("DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID", "") diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 00d7f7dbcc..9c83557b77 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -44,6 +44,12 @@ DATABASES = { "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, + "neo4j": { + "HOST": env.str("NEO4J_HOST", "neo4j"), + "PORT": env.str("NEO4J_PORT", "7687"), + "USER": env.str("NEO4J_USER", "neo4j"), + "PASSWORD": env.str("NEO4J_PASSWORD", "neo4j_password"), + }, } DATABASES["default"] = DATABASES["prowler_user"] diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index f350186ed0..b2769237fc 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -45,6 +45,12 @@ DATABASES = { "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, + "neo4j": { + "HOST": env.str("NEO4J_HOST"), + "PORT": env.str("NEO4J_PORT"), + "USER": env.str("NEO4J_USER"), + "PASSWORD": env.str("NEO4J_PASSWORD"), + }, } DATABASES["default"] = DATABASES["prowler_user"] diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 8eefab551f..a3b683ffea 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -1,8 +1,11 @@ import logging +from types import SimpleNamespace + from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch import pytest + from allauth.socialaccount.models import SocialLogin from django.conf import settings from django.db import connection as django_connection @@ -11,13 +14,14 @@ from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status from rest_framework.test import APIClient -from tasks.jobs.backfill import ( - backfill_resource_scan_summaries, - backfill_scan_category_summaries, -) +from api.attack_paths import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) from api.db_utils import rls_transaction from api.models import ( + AttackPathsScan, AttackSurfaceOverview, ComplianceOverview, ComplianceRequirementOverview, @@ -41,6 +45,7 @@ from api.models import ( SAMLDomainIndex, Scan, ScanCategorySummary, + ScanGroupSummary, ScanSummary, StateChoices, StatusChoices, @@ -54,6 +59,11 @@ from api.rls import Tenant from api.v1.serializers import TokenSerializer from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status +from tasks.jobs.backfill import ( + backfill_resource_scan_summaries, + backfill_scan_category_summaries, + backfill_scan_resource_group_summaries, +) TODAY = str(datetime.today().date()) API_JSON_CONTENT_TYPE = "application/vnd.api+json" @@ -166,22 +176,20 @@ def create_test_user_rbac_no_roles(django_db_setup, django_db_blocker, tenants_f @pytest.fixture(scope="function") -def create_test_user_rbac_limited(django_db_setup, django_db_blocker): +def create_test_user_rbac_limited(django_db_setup, django_db_blocker, tenants_fixture): with django_db_blocker.unblock(): user = User.objects.create_user( name="testing_limited", email="rbac_limited@rbac.com", password=TEST_PASSWORD, ) - tenant = Tenant.objects.create( - name="Tenant Test", - ) + tenant = tenants_fixture[0] Membership.objects.create( user=user, tenant=tenant, role=Membership.RoleChoices.OWNER, ) - Role.objects.create( + role = Role.objects.create( name="limited", tenant_id=tenant.id, manage_users=False, @@ -194,7 +202,7 @@ def create_test_user_rbac_limited(django_db_setup, django_db_blocker): ) UserRoleRelationship.objects.create( user=user, - role=Role.objects.get(name="limited"), + role=role, tenant_id=tenant.id, ) return user @@ -739,6 +747,7 @@ def resources_fixture(providers_fixture): region="us-east-1", service="ec2", type="prowler-test", + groups=["compute"], ) resource1.upsert_or_delete_tags(tags) @@ -751,6 +760,7 @@ def resources_fixture(providers_fixture): region="eu-west-1", service="s3", type="prowler-test", + groups=["storage"], ) resource2.upsert_or_delete_tags(tags) @@ -762,6 +772,7 @@ def resources_fixture(providers_fixture): region="us-east-1", service="ec2", type="test", + groups=["compute"], ) tags = [ @@ -1234,7 +1245,7 @@ def lighthouse_config_fixture(authenticated_client, tenants_fixture): return LighthouseConfiguration.objects.create( tenant_id=tenants_fixture[0].id, name="OpenAI", - api_key_decoded="sk-test1234567890T3BlbkFJtest1234567890", + api_key_decoded="sk-fake-test-key-for-unit-testing-only", model="gpt-4o", temperature=0, max_tokens=4000, @@ -1383,11 +1394,13 @@ def latest_scan_finding_with_categories( check_id="genai_iam_check", check_metadata={"CheckId": "genai_iam_check"}, categories=["gen-ai", "iam"], + resource_groups="ai_ml", first_seen_at="2024-01-02T00:00:00Z", ) finding.add_resources([resource]) backfill_resource_scan_summaries(tenant_id, str(scan.id)) backfill_scan_category_summaries(tenant_id, str(scan.id)) + backfill_scan_resource_group_summaries(tenant_id, str(scan.id)) return finding @@ -1590,6 +1603,104 @@ def mute_rules_fixture(tenants_fixture, create_test_user, findings_fixture): return mute_rule1, mute_rule2 +@pytest.fixture +def create_attack_paths_scan(): + """Factory fixture to create Attack Paths scans for tests.""" + + def _create( + provider, + *, + scan=None, + state=StateChoices.COMPLETED, + progress=0, + graph_database="tenant-db", + **extra_fields, + ): + scan_instance = scan or Scan.objects.create( + name=extra_fields.pop("scan_name", "Attack Paths Supporting Scan"), + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=extra_fields.pop("scan_state", StateChoices.COMPLETED), + tenant_id=provider.tenant_id, + ) + + payload = { + "tenant_id": provider.tenant_id, + "provider": provider, + "scan": scan_instance, + "state": state, + "progress": progress, + "graph_database": graph_database, + } + payload.update(extra_fields) + + return AttackPathsScan.objects.create(**payload) + + return _create + + +@pytest.fixture +def attack_paths_query_definition_factory(): + """Factory fixture for building Attack Paths query definitions.""" + + def _create(**overrides): + cast_type = overrides.pop("cast_type", str) + parameters = overrides.pop( + "parameters", + [ + AttackPathsQueryParameterDefinition( + name="limit", + label="Limit", + cast=cast_type, + ) + ], + ) + definition_payload = { + "id": "aws-test", + "name": "Attack Paths Test Query", + "description": "Synthetic Attack Paths definition for tests.", + "provider": "aws", + "cypher": "RETURN 1", + "parameters": parameters, + } + definition_payload.update(overrides) + return AttackPathsQueryDefinition(**definition_payload) + + return _create + + +@pytest.fixture +def attack_paths_graph_stub_classes(): + """Provide lightweight graph element stubs for Attack Paths serialization tests.""" + + class AttackPathsNativeValue: + def __init__(self, value): + self._value = value + + def to_native(self): + return self._value + + class AttackPathsNode: + def __init__(self, element_id, labels, properties): + self.element_id = element_id + self.labels = labels + self._properties = properties + + class AttackPathsRelationship: + def __init__(self, element_id, rel_type, start_node, end_node, properties): + self.element_id = element_id + self.type = rel_type + self.start_node = start_node + self.end_node = end_node + self._properties = properties + + return SimpleNamespace( + NativeValue=AttackPathsNativeValue, + Node=AttackPathsNode, + Relationship=AttackPathsRelationship, + ) + + @pytest.fixture def create_attack_surface_overview(): def _create(tenant, scan, attack_surface_type, total=10, failed=5, muted_failed=2): @@ -1629,6 +1740,103 @@ def create_scan_category_summary(): return _create +@pytest.fixture(scope="function") +def findings_with_group(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource = resources_fixture[0] + + finding = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_with_group_1", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="storage_check", + check_metadata={"CheckId": "storage_check"}, + resource_groups="storage", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id)) + return finding + + +@pytest.fixture(scope="function") +def findings_with_multiple_groups(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource1, resource2 = resources_fixture[:2] + + finding1 = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_multi_grp_1", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="storage_check", + check_metadata={"CheckId": "storage_check"}, + resource_groups="storage", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding1.add_resources([resource1]) + + finding2 = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_multi_grp_2", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status 2", + impact=Severity.high, + impact_extended="test impact 2", + severity=Severity.high, + raw_result={"status": Status.FAIL}, + check_id="security_check", + check_metadata={"CheckId": "security_check"}, + resource_groups="security", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding2.add_resources([resource2]) + + backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id)) + return finding1, finding2 + + +@pytest.fixture +def create_scan_resource_group_summary(): + def _create( + tenant, + scan, + resource_group, + severity, + total_findings=10, + failed_findings=5, + new_failed_findings=2, + resources_count=3, + ): + return ScanGroupSummary.objects.create( + tenant=tenant, + scan=scan, + resource_group=resource_group, + severity=severity, + total_findings=total_findings, + failed_findings=failed_findings, + new_failed_findings=new_failed_findings, + resources_count=resources_count, + ) + + return _create + + def get_authorization_header(access_token: str) -> dict: return {"Authorization": f"Bearer {access_token}"} diff --git a/api/src/backend/tasks/beat.py b/api/src/backend/tasks/beat.py index 262d47496a..e9eb9c9309 100644 --- a/api/src/backend/tasks/beat.py +++ b/api/src/backend/tasks/beat.py @@ -7,6 +7,7 @@ from tasks.tasks import perform_scheduled_scan_task from api.db_utils import rls_transaction from api.exceptions import ConflictException from api.models import Provider, Scan, StateChoices +from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils def schedule_provider_scan(provider_instance: Provider): @@ -39,6 +40,12 @@ def schedule_provider_scan(provider_instance: Provider): scheduled_at=datetime.now(timezone.utc), ) + attack_paths_db_utils.create_attack_paths_scan( + tenant_id=tenant_id, + scan_id=str(scheduled_scan.id), + provider_id=provider_id, + ) + # Schedule the task periodic_task_instance = PeriodicTask.objects.create( interval=schedule, diff --git a/api/src/backend/tasks/jobs/attack_paths/__init__.py b/api/src/backend/tasks/jobs/attack_paths/__init__.py new file mode 100644 index 0000000000..8fb57bc907 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/__init__.py @@ -0,0 +1,7 @@ +from tasks.jobs.attack_paths.db_utils import can_provider_run_attack_paths_scan +from tasks.jobs.attack_paths.scan import run as attack_paths_scan + +__all__ = [ + "attack_paths_scan", + "can_provider_run_attack_paths_scan", +] diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py new file mode 100644 index 0000000000..1bfe4eefac --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -0,0 +1,253 @@ +# Portions of this file are based on code from the Cartography project +# (https://github.com/cartography-cncf/cartography), which is licensed under the Apache 2.0 License. + +from typing import Any + +import aioboto3 +import boto3 +import neo4j + +from cartography.config import Config as CartographyConfig +from cartography.intel import aws as cartography_aws +from celery.utils.log import get_task_logger + +from api.models import ( + AttackPathsScan as ProwlerAPIAttackPathsScan, + Provider as ProwlerAPIProvider, +) +from prowler.providers.common.provider import Provider as ProwlerSDKProvider +from tasks.jobs.attack_paths import db_utils, utils + +logger = get_task_logger(__name__) + + +def start_aws_ingestion( + neo4j_session: neo4j.Session, + cartography_config: CartographyConfig, + prowler_api_provider: ProwlerAPIProvider, + prowler_sdk_provider: ProwlerSDKProvider, + attack_paths_scan: ProwlerAPIAttackPathsScan, +) -> dict[str, dict[str, str]]: + """ + Code based on Cartography version 0.122.0, specifically on `cartography.intel.aws.__init__.py`. + + For the scan progress updates: + - The caller of this function (`tasks.jobs.attack_paths.scan.run`) has set it to 2. + - When the control returns to the caller, it will be set to 95. + """ + + # Initialize variables common to all jobs + common_job_parameters = { + "UPDATE_TAG": cartography_config.update_tag, + "permission_relationships_file": cartography_config.permission_relationships_file, + "aws_guardduty_severity_threshold": cartography_config.aws_guardduty_severity_threshold, + "aws_cloudtrail_management_events_lookback_hours": cartography_config.aws_cloudtrail_management_events_lookback_hours, + "experimental_aws_inspector_batch": cartography_config.experimental_aws_inspector_batch, + } + + boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider) + regions: list[str] = list(prowler_sdk_provider._enabled_regions) + requested_syncs = list(cartography_aws.RESOURCE_FUNCTIONS.keys()) + + sync_args = cartography_aws._build_aws_sync_kwargs( + neo4j_session, + boto3_session, + regions, + prowler_api_provider.uid, + cartography_config.update_tag, + common_job_parameters, + ) + + # Starting with sync functions + logger.info(f"Syncing organizations for AWS account {prowler_api_provider.uid}") + cartography_aws.organizations.sync( + neo4j_session, + {prowler_api_provider.alias: prowler_api_provider.uid}, + cartography_config.update_tag, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 3) + + # Adding an extra field + common_job_parameters["AWS_ID"] = prowler_api_provider.uid + + cartography_aws._autodiscover_accounts( + neo4j_session, + boto3_session, + prowler_api_provider.uid, + cartography_config.update_tag, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 4) + + failed_syncs = sync_aws_account( + prowler_api_provider, requested_syncs, sync_args, attack_paths_scan + ) + + if "permission_relationships" in requested_syncs: + logger.info( + f"Syncing function permission_relationships for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"](**sync_args) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 88) + + if "resourcegroupstaggingapi" in requested_syncs: + logger.info( + f"Syncing function resourcegroupstaggingapi for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.RESOURCE_FUNCTIONS["resourcegroupstaggingapi"](**sync_args) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 89) + + logger.info( + f"Syncing ec2_iaminstanceprofile scoped analysis for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.run_scoped_analysis_job( + "aws_ec2_iaminstanceprofile.json", + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 90) + + logger.info( + f"Syncing lambda_ecr analysis for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.run_analysis_job( + "aws_lambda_ecr.json", + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 91) + + logger.info(f"Syncing metadata for AWS account {prowler_api_provider.uid}") + cartography_aws.merge_module_sync_metadata( + neo4j_session, + group_type="AWSAccount", + group_id=prowler_api_provider.uid, + synced_type="AWSAccount", + update_tag=cartography_config.update_tag, + stat_handler=cartography_aws.stat_handler, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 92) + + # Removing the added extra field + del common_job_parameters["AWS_ID"] + + logger.info(f"Syncing cleanup_job for AWS account {prowler_api_provider.uid}") + cartography_aws.run_cleanup_job( + "aws_post_ingestion_principals_cleanup.json", + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 93) + + logger.info(f"Syncing analysis for AWS account {prowler_api_provider.uid}") + cartography_aws._perform_aws_analysis( + requested_syncs, neo4j_session, common_job_parameters + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 94) + + return failed_syncs + + +def get_boto3_session( + prowler_api_provider: ProwlerAPIProvider, prowler_sdk_provider: ProwlerSDKProvider +) -> boto3.Session: + boto3_session = prowler_sdk_provider.session.current_session + + aws_accounts_from_session = cartography_aws.organizations.get_aws_account_default( + boto3_session + ) + if not aws_accounts_from_session: + raise Exception( + "No valid AWS credentials could be found. No AWS accounts can be synced." + ) + + aws_account_id_from_session = list(aws_accounts_from_session.values())[0] + if prowler_api_provider.uid != aws_account_id_from_session: + raise Exception( + f"Provider {prowler_api_provider.uid} doesn't match AWS account {aws_account_id_from_session}." + ) + + if boto3_session.region_name is None: + global_region = prowler_sdk_provider.get_global_region() + boto3_session._session.set_config_variable("region", global_region) + + return boto3_session + + +def get_aioboto3_session(boto3_session: boto3.Session) -> aioboto3.Session: + return aioboto3.Session(botocore_session=boto3_session._session) + + +def sync_aws_account( + prowler_api_provider: ProwlerAPIProvider, + requested_syncs: list[str], + sync_args: dict[str, Any], + attack_paths_scan: ProwlerAPIAttackPathsScan, +) -> dict[str, str]: + current_progress = 4 # `cartography_aws._autodiscover_accounts` + max_progress = ( + 87 # `cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"]` - 1 + ) + n_steps = ( + len(requested_syncs) - 2 + ) # Excluding `permission_relationships` and `resourcegroupstaggingapi` + progress_step = (max_progress - current_progress) / n_steps + + failed_syncs = {} + + for func_name in requested_syncs: + if func_name in cartography_aws.RESOURCE_FUNCTIONS: + logger.info( + f"Syncing function {func_name} for AWS account {prowler_api_provider.uid}" + ) + + # Updating progress, not really the right place but good enough + current_progress += progress_step + db_utils.update_attack_paths_scan_progress( + attack_paths_scan, int(current_progress) + ) + + try: + # `ecr:image_layers` uses `aioboto3_session` instead of `boto3_session` + if func_name == "ecr:image_layers": + cartography_aws.RESOURCE_FUNCTIONS[func_name]( + neo4j_session=sync_args.get("neo4j_session"), + aioboto3_session=get_aioboto3_session( + sync_args.get("boto3_session") + ), + regions=sync_args.get("regions"), + current_aws_account_id=sync_args.get("current_aws_account_id"), + update_tag=sync_args.get("update_tag"), + common_job_parameters=sync_args.get("common_job_parameters"), + ) + + # Skip permission relationships and tags for now because they rely on data already being in the graph + elif func_name in [ + "permission_relationships", + "resourcegroupstaggingapi", + ]: + continue + + else: + cartography_aws.RESOURCE_FUNCTIONS[func_name](**sync_args) + + except Exception as e: + exception_message = utils.stringify_exception( + e, f"Exception for AWS sync function: {func_name}" + ) + failed_syncs[func_name] = exception_message + + logger.warning( + f"Caught exception syncing function {func_name} from AWS account {prowler_api_provider.uid}. We " + "are continuing on to the next AWS sync function.", + ) + + continue + + else: + raise ValueError( + f'AWS sync function "{func_name}" was specified but does not exist. Did you misspell it?' + ) + + return failed_syncs diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py new file mode 100644 index 0000000000..92f79f6f36 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -0,0 +1,168 @@ +from datetime import datetime, timezone +from typing import Any + +from django.db.models import Q +from cartography.config import Config as CartographyConfig + +from api.db_utils import rls_transaction +from api.models import ( + AttackPathsScan as ProwlerAPIAttackPathsScan, + Provider as ProwlerAPIProvider, + StateChoices, +) +from tasks.jobs.attack_paths.providers import is_provider_available + + +def can_provider_run_attack_paths_scan(tenant_id: str, provider_id: int) -> bool: + with rls_transaction(tenant_id): + prowler_api_provider = ProwlerAPIProvider.objects.get(id=provider_id) + + return is_provider_available(prowler_api_provider.provider) + + +def create_attack_paths_scan( + tenant_id: str, + scan_id: str, + provider_id: int, +) -> ProwlerAPIAttackPathsScan | None: + if not can_provider_run_attack_paths_scan(tenant_id, provider_id): + return None + + with rls_transaction(tenant_id): + attack_paths_scan = ProwlerAPIAttackPathsScan.objects.create( + tenant_id=tenant_id, + provider_id=provider_id, + scan_id=scan_id, + state=StateChoices.SCHEDULED, + started_at=datetime.now(tz=timezone.utc), + ) + attack_paths_scan.save() + + return attack_paths_scan + + +def retrieve_attack_paths_scan( + tenant_id: str, + scan_id: str, +) -> ProwlerAPIAttackPathsScan | None: + try: + with rls_transaction(tenant_id): + attack_paths_scan = ProwlerAPIAttackPathsScan.objects.get( + scan_id=scan_id, + ) + + return attack_paths_scan + + except ProwlerAPIAttackPathsScan.DoesNotExist: + return None + + +def starting_attack_paths_scan( + attack_paths_scan: ProwlerAPIAttackPathsScan, + task_id: str, + cartography_config: CartographyConfig, +) -> None: + with rls_transaction(attack_paths_scan.tenant_id): + attack_paths_scan.task_id = task_id + attack_paths_scan.state = StateChoices.EXECUTING + attack_paths_scan.started_at = datetime.now(tz=timezone.utc) + attack_paths_scan.update_tag = cartography_config.update_tag + attack_paths_scan.graph_database = cartography_config.neo4j_database + + attack_paths_scan.save( + update_fields=[ + "task_id", + "state", + "started_at", + "update_tag", + "graph_database", + ] + ) + + +def finish_attack_paths_scan( + attack_paths_scan: ProwlerAPIAttackPathsScan, + state: StateChoices, + ingestion_exceptions: dict[str, Any], +) -> None: + with rls_transaction(attack_paths_scan.tenant_id): + now = datetime.now(tz=timezone.utc) + duration = int((now - attack_paths_scan.started_at).total_seconds()) + + attack_paths_scan.state = state + attack_paths_scan.progress = 100 + attack_paths_scan.completed_at = now + attack_paths_scan.duration = duration + attack_paths_scan.ingestion_exceptions = ingestion_exceptions + + attack_paths_scan.save( + update_fields=[ + "state", + "progress", + "completed_at", + "duration", + "ingestion_exceptions", + ] + ) + + +def update_attack_paths_scan_progress( + attack_paths_scan: ProwlerAPIAttackPathsScan, + progress: int, +) -> None: + with rls_transaction(attack_paths_scan.tenant_id): + attack_paths_scan.progress = progress + attack_paths_scan.save(update_fields=["progress"]) + + +def get_old_attack_paths_scans( + tenant_id: str, + provider_id: str, + attack_paths_scan_id: str, +) -> list[ProwlerAPIAttackPathsScan]: + """ + An `old_attack_paths_scan` is any `completed` Attack Paths scan for the same provider, + with its graph database not deleted, excluding the current Attack Paths scan. + """ + + with rls_transaction(tenant_id): + completed_scans_qs = ( + ProwlerAPIAttackPathsScan.objects.filter( + provider_id=provider_id, + state=StateChoices.COMPLETED, + is_graph_database_deleted=False, + ) + .exclude(id=attack_paths_scan_id) + .all() + ) + + return list(completed_scans_qs) + + +def update_old_attack_paths_scan( + old_attack_paths_scan: ProwlerAPIAttackPathsScan, +) -> None: + with rls_transaction(old_attack_paths_scan.tenant_id): + old_attack_paths_scan.is_graph_database_deleted = True + old_attack_paths_scan.save(update_fields=["is_graph_database_deleted"]) + + +def get_provider_graph_database_names(tenant_id: str, provider_id: str) -> list[str]: + """ + Return existing graph database names for a tenant/provider. + + Note: For accesing the `AttackPathsScan` we need to use `all_objects` manager because the provider is soft-deleted. + """ + with rls_transaction(tenant_id): + graph_databases_names_qs = ( + ProwlerAPIAttackPathsScan.all_objects.filter( + ~Q(graph_database=""), + graph_database__isnull=False, + provider_id=provider_id, + is_graph_database_deleted=False, + ) + .values_list("graph_database", flat=True) + .distinct() + ) + + return list(graph_databases_names_qs) diff --git a/api/src/backend/tasks/jobs/attack_paths/providers.py b/api/src/backend/tasks/jobs/attack_paths/providers.py new file mode 100644 index 0000000000..a0d4c44551 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/providers.py @@ -0,0 +1,23 @@ +AVAILABLE_PROVIDERS: list[str] = [ + "aws", +] + +ROOT_NODE_LABELS: dict[str, str] = { + "aws": "AWSAccount", +} + +NODE_UID_FIELDS: dict[str, str] = { + "aws": "arn", +} + + +def is_provider_available(provider_type: str) -> bool: + return provider_type in AVAILABLE_PROVIDERS + + +def get_root_node_label(provider_type: str) -> str: + return ROOT_NODE_LABELS.get(provider_type, "UnknownProviderAccount") + + +def get_node_uid_field(provider_type: str) -> str: + return NODE_UID_FIELDS.get(provider_type, "UnknownProviderUID") diff --git a/api/src/backend/tasks/jobs/attack_paths/prowler.py b/api/src/backend/tasks/jobs/attack_paths/prowler.py new file mode 100644 index 0000000000..7a92b9d760 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/prowler.py @@ -0,0 +1,290 @@ +from collections import defaultdict +from typing import Generator + +import neo4j +from cartography.client.core.tx import run_write_query +from cartography.config import Config as CartographyConfig +from celery.utils.log import get_task_logger +from config.env import env +from tasks.jobs.attack_paths.providers import get_node_uid_field, get_root_node_label + +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Finding, Provider, ResourceFindingMapping +from prowler.config import config as ProwlerConfig + +logger = get_task_logger(__name__) + +BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000) + +INDEX_STATEMENTS = [ + "CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.id);", + "CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.provider_uid);", + "CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.lastupdated);", + "CREATE INDEX prowler_finding_check_id IF NOT EXISTS FOR (n:ProwlerFinding) ON (n.status);", +] + +INSERT_STATEMENT_TEMPLATE = """ + MATCH (account:__ROOT_NODE_LABEL__ {id: $provider_uid}) + UNWIND $findings_data AS finding_data + + OPTIONAL MATCH (account)-->(resource_by_uid) + WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid + WITH account, finding_data, resource_by_uid + + OPTIONAL MATCH (account)-->(resource_by_id) + WHERE resource_by_uid IS NULL + AND resource_by_id.id = finding_data.resource_uid + WITH account, finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource + WHERE resource IS NOT NULL + + MERGE (finding:ProwlerFinding {id: finding_data.id}) + ON CREATE SET + finding.id = finding_data.id, + finding.uid = finding_data.uid, + finding.inserted_at = finding_data.inserted_at, + finding.updated_at = finding_data.updated_at, + finding.first_seen_at = finding_data.first_seen_at, + finding.scan_id = finding_data.scan_id, + finding.delta = finding_data.delta, + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.severity = finding_data.severity, + finding.check_id = finding_data.check_id, + finding.check_title = finding_data.check_title, + finding.muted = finding_data.muted, + finding.muted_reason = finding_data.muted_reason, + finding.provider_uid = $provider_uid, + finding.firstseen = timestamp(), + finding.lastupdated = $last_updated, + finding._module_name = 'cartography:prowler', + finding._module_version = $prowler_version + ON MATCH SET + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.lastupdated = $last_updated + + MERGE (resource)-[rel:HAS_FINDING]->(finding) + ON CREATE SET + rel.provider_uid = $provider_uid, + rel.firstseen = timestamp(), + rel.lastupdated = $last_updated, + rel._module_name = 'cartography:prowler', + rel._module_version = $prowler_version + ON MATCH SET + rel.lastupdated = $last_updated +""" + +CLEANUP_STATEMENT = """ + MATCH (finding:ProwlerFinding {provider_uid: $provider_uid}) + WHERE finding.lastupdated < $last_updated + + WITH finding LIMIT $batch_size + + DETACH DELETE finding + + RETURN COUNT(finding) AS deleted_findings_count +""" + + +def create_indexes(neo4j_session: neo4j.Session) -> None: + """ + Code based on Cartography version 0.122.0, specifically on `cartography.intel.create_indexes.run`. + """ + + logger.info("Creating indexes for Prowler Findings node types") + for statement in INDEX_STATEMENTS: + run_write_query(neo4j_session, statement) + + +def analysis( + neo4j_session: neo4j.Session, + prowler_api_provider: Provider, + scan_id: str, + config: CartographyConfig, +) -> None: + findings_data = get_provider_last_scan_findings(prowler_api_provider, scan_id) + load_findings(neo4j_session, findings_data, prowler_api_provider, config) + cleanup_findings(neo4j_session, prowler_api_provider, config) + + +def get_provider_last_scan_findings( + prowler_api_provider: Provider, + scan_id: str, +) -> Generator[list[dict[str, str]], None, None]: + """ + Generator that yields batches of finding-resource pairs. + + Two-step query approach per batch: + 1. Paginate findings for scan (single table, indexed by scan_id) + 2. Batch-fetch resource UIDs via mapping table (single join) + 3. Merge and yield flat structure for Neo4j + + Memory efficient: never holds more than BATCH_SIZE findings in memory. + """ + + logger.info( + f"Starting findings fetch for scan {scan_id} (tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}" + ) + + iteration = 0 + last_id = None + + while True: + iteration += 1 + + with rls_transaction(prowler_api_provider.tenant_id, using=READ_REPLICA_ALIAS): + # Use all_objects to avoid the ActiveProviderManager's implicit JOIN + # through Scan -> Provider (to check is_deleted=False). + # The provider is already validated as active in this context. + qs = Finding.all_objects.filter(scan_id=scan_id).order_by("id") + if last_id is not None: + qs = qs.filter(id__gt=last_id) + + findings_batch = list( + qs.values( + "id", + "uid", + "inserted_at", + "updated_at", + "first_seen_at", + "scan_id", + "delta", + "status", + "status_extended", + "severity", + "check_id", + "check_metadata__checktitle", + "muted", + "muted_reason", + )[:BATCH_SIZE] + ) + + logger.info( + f"Iteration #{iteration} fetched {len(findings_batch)} findings" + ) + + if not findings_batch: + logger.info( + f"No findings returned for iteration #{iteration}; stopping pagination" + ) + break + + last_id = findings_batch[-1]["id"] + enriched_batch = _enrich_and_flatten_batch(findings_batch) + + # Yield outside the transaction + if enriched_batch: + yield enriched_batch + + logger.info(f"Finished fetching findings for scan {scan_id}") + + +def _enrich_and_flatten_batch( + findings_batch: list[dict], +) -> list[dict[str, str]]: + """ + Fetch resource UIDs for a batch of findings and return flat structure. + + One finding with 3 resources becomes 3 dicts (same output format as before). + Must be called within an RLS transaction context. + """ + finding_ids = [f["id"] for f in findings_batch] + + # Single join: mapping -> resource + resource_mappings = ResourceFindingMapping.objects.filter( + finding_id__in=finding_ids + ).values_list("finding_id", "resource__uid") + + # Build finding_id -> [resource_uids] mapping + finding_resources = defaultdict(list) + for finding_id, resource_uid in resource_mappings: + finding_resources[finding_id].append(resource_uid) + + # Flatten: one dict per (finding, resource) pair + results = [] + for f in findings_batch: + resource_uids = finding_resources.get(f["id"], []) + + if not resource_uids: + continue + + for resource_uid in resource_uids: + results.append( + { + "resource_uid": str(resource_uid), + "id": str(f["id"]), + "uid": f["uid"], + "inserted_at": f["inserted_at"], + "updated_at": f["updated_at"], + "first_seen_at": f["first_seen_at"], + "scan_id": str(f["scan_id"]), + "delta": f["delta"], + "status": f["status"], + "status_extended": f["status_extended"], + "severity": f["severity"], + "check_id": str(f["check_id"]), + "check_title": f["check_metadata__checktitle"], + "muted": f["muted"], + "muted_reason": f["muted_reason"], + } + ) + + return results + + +def load_findings( + neo4j_session: neo4j.Session, + findings_batches: Generator[list[dict[str, str]], None, None], + prowler_api_provider: Provider, + config: CartographyConfig, +) -> None: + replacements = { + "__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider), + "__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider), + } + query = INSERT_STATEMENT_TEMPLATE + for replace_key, replace_value in replacements.items(): + query = query.replace(replace_key, replace_value) + + parameters = { + "provider_uid": str(prowler_api_provider.uid), + "last_updated": config.update_tag, + "prowler_version": ProwlerConfig.prowler_version, + } + + batch_num = 0 + total_records = 0 + for batch in findings_batches: + batch_num += 1 + batch_size = len(batch) + total_records += batch_size + + parameters["findings_data"] = batch + + logger.info(f"Loading findings batch {batch_num} ({batch_size} records)") + neo4j_session.run(query, parameters) + + logger.info(f"Finished loading {total_records} records in {batch_num} batches") + + +def cleanup_findings( + neo4j_session: neo4j.Session, + prowler_api_provider: Provider, + config: CartographyConfig, +) -> None: + parameters = { + "provider_uid": str(prowler_api_provider.uid), + "last_updated": config.update_tag, + "batch_size": BATCH_SIZE, + } + + batch = 1 + deleted_count = 1 + while deleted_count > 0: + logger.info(f"Cleaning findings batch {batch}") + + result = neo4j_session.run(CLEANUP_STATEMENT, parameters) + + deleted_count = result.single().get("deleted_findings_count", 0) + batch += 1 diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py new file mode 100644 index 0000000000..9db4d6bf6e --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -0,0 +1,197 @@ +import logging +import time +import asyncio + +from typing import Any, Callable + +from cartography.config import Config as CartographyConfig +from cartography.intel import analysis as cartography_analysis +from cartography.intel import create_indexes as cartography_create_indexes +from cartography.intel import ontology as cartography_ontology +from celery.utils.log import get_task_logger + +from api.attack_paths import database as graph_database +from api.db_utils import rls_transaction +from api.models import ( + Provider as ProwlerAPIProvider, + StateChoices, +) +from api.utils import initialize_prowler_provider +from tasks.jobs.attack_paths import aws, db_utils, prowler, utils + +# Without this Celery goes crazy with Cartography logging +logging.getLogger("cartography").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +logger = get_task_logger(__name__) + +CARTOGRAPHY_INGESTION_FUNCTIONS: dict[str, Callable] = { + "aws": aws.start_aws_ingestion, +} + + +def get_cartography_ingestion_function(provider_type: str) -> Callable | None: + return CARTOGRAPHY_INGESTION_FUNCTIONS.get(provider_type) + + +def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: + """ + Code based on Cartography version 0.122.0, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`, + `cartography.sync.run_with_config` and `cartography.sync.Sync.run`. + """ + ingestion_exceptions = {} # This will hold any exceptions raised during ingestion + + # Prowler necessary objects + with rls_transaction(tenant_id): + prowler_api_provider = ProwlerAPIProvider.objects.get(scan__pk=scan_id) + prowler_sdk_provider = initialize_prowler_provider(prowler_api_provider) + + # Attack Paths Scan necessary objects + cartography_ingestion_function = get_cartography_ingestion_function( + prowler_api_provider.provider + ) + attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id) + + # Checks before starting the scan + if not cartography_ingestion_function: + ingestion_exceptions = { + "global_error": f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans" + } + if attack_paths_scan: + db_utils.finish_attack_paths_scan( + attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions + ) + + logger.warning( + f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans" + ) + return ingestion_exceptions + + else: + if not attack_paths_scan: + logger.warning( + f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then" + ) + attack_paths_scan = db_utils.create_attack_paths_scan( + tenant_id, scan_id, prowler_api_provider.id + ) + + # While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object + cartography_config = CartographyConfig( + neo4j_uri=graph_database.get_uri(), + neo4j_database=graph_database.get_database_name(attack_paths_scan.id), + update_tag=int(time.time()), + ) + + # Starting the Attack Paths scan + db_utils.starting_attack_paths_scan(attack_paths_scan, task_id, cartography_config) + + try: + logger.info( + f"Creating Neo4j database {cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}" + ) + + graph_database.create_database(cartography_config.neo4j_database) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 1) + + logger.info( + f"Starting Cartography ({attack_paths_scan.id}) for " + f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}" + ) + with graph_database.get_session( + cartography_config.neo4j_database + ) as neo4j_session: + # Indexes creation + cartography_create_indexes.run(neo4j_session, cartography_config) + prowler.create_indexes(neo4j_session) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2) + + # The real scan, where iterates over cloud services + ingestion_exceptions = _call_within_event_loop( + cartography_ingestion_function, + neo4j_session, + cartography_config, + prowler_api_provider, + prowler_sdk_provider, + attack_paths_scan, + ) + + # Post-processing: Just keeping it to be more Cartography compliant + logger.info( + f"Syncing Cartography ontology for AWS account {prowler_api_provider.uid}" + ) + cartography_ontology.run(neo4j_session, cartography_config) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95) + + logger.info( + f"Syncing Cartography analysis for AWS account {prowler_api_provider.uid}" + ) + cartography_analysis.run(neo4j_session, cartography_config) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96) + + # Adding Prowler nodes and relationships + logger.info( + f"Syncing Prowler analysis for AWS account {prowler_api_provider.uid}" + ) + prowler.analysis( + neo4j_session, prowler_api_provider, scan_id, cartography_config + ) + + logger.info( + f"Clearing Neo4j cache for database {cartography_config.neo4j_database}" + ) + graph_database.clear_cache(cartography_config.neo4j_database) + + logger.info( + f"Completed Cartography ({attack_paths_scan.id}) for " + f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}" + ) + + # Handling databases changes + old_attack_paths_scans = db_utils.get_old_attack_paths_scans( + prowler_api_provider.tenant_id, + prowler_api_provider.id, + attack_paths_scan.id, + ) + for old_attack_paths_scan in old_attack_paths_scans: + graph_database.drop_database(old_attack_paths_scan.graph_database) + db_utils.update_old_attack_paths_scan(old_attack_paths_scan) + + db_utils.finish_attack_paths_scan( + attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions + ) + return ingestion_exceptions + + except Exception as e: + exception_message = utils.stringify_exception(e, "Cartography failed") + logger.error(exception_message) + ingestion_exceptions["global_cartography_error"] = exception_message + + # Handling databases changes + graph_database.drop_database(cartography_config.neo4j_database) + db_utils.finish_attack_paths_scan( + attack_paths_scan, StateChoices.FAILED, ingestion_exceptions + ) + raise + + +def _call_within_event_loop(fn, *args, **kwargs): + """ + Cartography needs a running event loop, so assuming there is none (Celery task or even regular DRF endpoint), + let's create a new one and set it as the current event loop for this thread. + """ + + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return fn(*args, **kwargs) + + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + + except Exception as e: + logger.warning(f"Failed to shutdown async generators cleanly: {e}") + + loop.close() + asyncio.set_event_loop(None) diff --git a/api/src/backend/tasks/jobs/attack_paths/utils.py b/api/src/backend/tasks/jobs/attack_paths/utils.py new file mode 100644 index 0000000000..0c737d4158 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/utils.py @@ -0,0 +1,10 @@ +import traceback + +from datetime import datetime, timezone + + +def stringify_exception(exception: Exception, context: str) -> str: + timestamp = datetime.now(tz=timezone.utc) + exception_traceback = traceback.TracebackException.from_exception(exception) + traceback_string = "".join(exception_traceback.format()) + return f"{timestamp} - {context}\n{traceback_string}" diff --git a/api/src/backend/tasks/jobs/backfill.py b/api/src/backend/tasks/jobs/backfill.py index 851efe4e4a..d9985afafb 100644 --- a/api/src/backend/tasks/jobs/backfill.py +++ b/api/src/backend/tasks/jobs/backfill.py @@ -2,13 +2,13 @@ from collections import defaultdict from datetime import timedelta from celery.utils.log import get_task_logger -from django.db.models import Sum +from django.db.models import OuterRef, Subquery, Sum from django.utils import timezone from tasks.jobs.queries import ( COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, COMPLIANCE_UPSERT_TENANT_SUMMARY_ALL_SQL, ) -from tasks.jobs.scan import aggregate_category_counts +from tasks.jobs.scan import aggregate_category_counts, aggregate_resource_group_counts from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import ( @@ -28,6 +28,7 @@ from api.models import ( ResourceScanSummary, Scan, ScanCategorySummary, + ScanGroupSummary, ScanSummary, StateChoices, ) @@ -356,6 +357,92 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str): return {"status": "backfilled", "categories_count": len(category_counts)} +def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str): + """ + Backfill ScanGroupSummary for a completed scan. + + Aggregates resource group counts from all findings in the scan and creates + one ScanGroupSummary row per (resource_group, severity) combination. + + Args: + tenant_id: Target tenant UUID + scan_id: Scan UUID to backfill + + Returns: + dict: Status indicating whether backfill was performed + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + if ScanGroupSummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).exists(): + return {"status": "already backfilled"} + + if not Scan.objects.filter( + tenant_id=tenant_id, + id=scan_id, + state__in=(StateChoices.COMPLETED, StateChoices.FAILED), + ).exists(): + return {"status": "scan is not completed"} + + resource_group_counts: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + # Get findings with their first resource UID via annotation + resource_uid_subquery = ResourceFindingMapping.objects.filter( + finding_id=OuterRef("id"), tenant_id=tenant_id + ).values("resource__uid")[:1] + + for finding in ( + Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id) + .annotate(resource_uid=Subquery(resource_uid_subquery)) + .values( + "resource_groups", + "severity", + "status", + "delta", + "muted", + "resource_uid", + ) + ): + aggregate_resource_group_counts( + resource_group=finding.get("resource_groups"), + severity=finding.get("severity"), + status=finding.get("status"), + delta=finding.get("delta"), + muted=finding.get("muted", False), + resource_uid=finding.get("resource_uid") or "", + cache=resource_group_counts, + group_resources_cache=group_resources_cache, + ) + + if not resource_group_counts: + return {"status": "no resource groups to backfill"} + + # Compute group-level resource counts (same value for all severity rows in a group) + group_resource_counts = { + grp: len(uids) for grp, uids in group_resources_cache.items() + } + resource_group_summaries = [ + ScanGroupSummary( + tenant_id=tenant_id, + scan_id=scan_id, + resource_group=grp, + severity=severity, + total_findings=counts["total"], + failed_findings=counts["failed"], + new_failed_findings=counts["new_failed"], + resources_count=group_resource_counts.get(grp, 0), + ) + for (grp, severity), counts in resource_group_counts.items() + ] + + with rls_transaction(tenant_id): + ScanGroupSummary.objects.bulk_create( + resource_group_summaries, batch_size=500, ignore_conflicts=True + ) + + return {"status": "backfilled", "resource_groups_count": len(resource_group_counts)} + + def backfill_provider_compliance_scores(tenant_id: str) -> dict: """ Backfill ProviderComplianceScore from latest completed scan per provider. diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index d72b8de40e..6eee63de6a 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -1,9 +1,19 @@ from celery.utils.log import get_task_logger from django.db import DatabaseError +from api.attack_paths import database as graph_database from api.db_router import MainRouter from api.db_utils import batch_delete, rls_transaction -from api.models import Finding, Provider, Resource, Scan, ScanSummary, Tenant +from api.models import ( + AttackPathsScan, + Finding, + Provider, + Resource, + Scan, + ScanSummary, + Tenant, +) +from tasks.jobs.attack_paths.db_utils import get_provider_graph_database_names logger = get_task_logger(__name__) @@ -23,16 +33,27 @@ def delete_provider(tenant_id: str, pk: str): Raises: Provider.DoesNotExist: If no instance with the provided primary key exists. """ + # Delete the Attack Paths' graph databases related to the provider + graph_database_names = get_provider_graph_database_names(tenant_id, pk) + try: + for graph_database_name in graph_database_names: + graph_database.drop_database(graph_database_name) + except graph_database.GraphDatabaseQueryException as gdb_error: + logger.error(f"Error deleting Provider databases: {gdb_error}") + raise + + # Get all provider related data and delete them in batches with rls_transaction(tenant_id): instance = Provider.all_objects.get(pk=pk) - deletion_summary = {} deletion_steps = [ ("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)), ("Findings", Finding.all_objects.filter(scan__provider=instance)), ("Resources", Resource.all_objects.filter(provider=instance)), ("Scans", Scan.all_objects.filter(provider=instance)), + ("AttackPathsScans", AttackPathsScan.all_objects.filter(provider=instance)), ] + deletion_summary = {} for step_name, queryset in deletion_steps: try: _, step_summary = batch_delete(tenant_id, queryset) @@ -48,6 +69,7 @@ def delete_provider(tenant_id: str, pk: str): except DatabaseError as db_error: logger.error(f"Error deleting Provider: {db_error}") raise + return deletion_summary diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index 1fbf9161d6..de022e9afd 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -1,1023 +1,25 @@ -import io -import os -from collections import defaultdict -from functools import partial from pathlib import Path from shutil import rmtree -import matplotlib.pyplot as plt from celery.utils.log import get_task_logger from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY -from reportlab.lib import colors -from reportlab.lib.enums import TA_CENTER -from reportlab.lib.pagesizes import letter -from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont -from reportlab.pdfgen import canvas -from reportlab.platypus import ( - Image, - PageBreak, - Paragraph, - SimpleDocTemplate, - Spacer, - Table, - TableStyle, -) from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3 -from tasks.jobs.threatscore import compute_threatscore_metrics -from tasks.jobs.threatscore_utils import ( - _aggregate_requirement_statistics_from_database, - _calculate_requirements_data_from_statistics, - _load_findings_for_requirement_checks, +from tasks.jobs.reports import ( + FRAMEWORK_REGISTRY, + ENSReportGenerator, + NIS2ReportGenerator, + ThreatScoreReportGenerator, ) +from tasks.jobs.threatscore import compute_threatscore_metrics +from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_database from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction -from api.models import Provider, ScanSummary, StatusChoices, ThreatScoreSnapshot -from api.utils import initialize_prowler_provider -from prowler.lib.check.compliance_models import Compliance +from api.models import Provider, ScanSummary, ThreatScoreSnapshot from prowler.lib.outputs.finding import Finding as FindingOutput -pdfmetrics.registerFont( - TTFont( - "PlusJakartaSans", - os.path.join( - os.path.dirname(__file__), "../assets/fonts/PlusJakartaSans-Regular.ttf" - ), - ) -) - -pdfmetrics.registerFont( - TTFont( - "FiraCode", - os.path.join(os.path.dirname(__file__), "../assets/fonts/FiraCode-Regular.ttf"), - ) -) - logger = get_task_logger(__name__) -# Color constants -COLOR_PROWLER_DARK_GREEN = colors.Color(0.1, 0.5, 0.2) -COLOR_BLUE = colors.Color(0.2, 0.4, 0.6) -COLOR_LIGHT_BLUE = colors.Color(0.3, 0.5, 0.7) -COLOR_LIGHTER_BLUE = colors.Color(0.4, 0.6, 0.8) -COLOR_BG_BLUE = colors.Color(0.95, 0.97, 1.0) -COLOR_BG_LIGHT_BLUE = colors.Color(0.98, 0.99, 1.0) -COLOR_GRAY = colors.Color(0.2, 0.2, 0.2) -COLOR_LIGHT_GRAY = colors.Color(0.9, 0.9, 0.9) -COLOR_BORDER_GRAY = colors.Color(0.7, 0.8, 0.9) -COLOR_GRID_GRAY = colors.Color(0.7, 0.7, 0.7) -COLOR_DARK_GRAY = colors.Color(0.4, 0.4, 0.4) -COLOR_HEADER_DARK = colors.Color(0.1, 0.3, 0.5) -COLOR_HEADER_MEDIUM = colors.Color(0.15, 0.35, 0.55) -COLOR_WHITE = colors.white - -# Risk and status colors -COLOR_HIGH_RISK = colors.Color(0.8, 0.2, 0.2) -COLOR_MEDIUM_RISK = colors.Color(0.9, 0.6, 0.2) -COLOR_LOW_RISK = colors.Color(0.9, 0.9, 0.2) -COLOR_SAFE = colors.Color(0.2, 0.8, 0.2) - -# ENS specific colors -COLOR_ENS_ALTO = colors.Color(0.8, 0.2, 0.2) -COLOR_ENS_MEDIO = colors.Color(0.98, 0.75, 0.13) -COLOR_ENS_BAJO = colors.Color(0.06, 0.72, 0.51) -COLOR_ENS_OPCIONAL = colors.Color(0.42, 0.45, 0.50) -COLOR_ENS_TIPO = colors.Color(0.2, 0.4, 0.6) -COLOR_ENS_AUTO = colors.Color(0.30, 0.69, 0.31) -COLOR_ENS_MANUAL = colors.Color(0.96, 0.60, 0.0) - -# NIS2 specific colors -COLOR_NIS2_PRIMARY = colors.Color(0.12, 0.23, 0.54) # EU Blue #1E3A8A -COLOR_NIS2_SECONDARY = colors.Color(0.23, 0.51, 0.96) # Light Blue #3B82F6 -COLOR_NIS2_BG_BLUE = colors.Color(0.96, 0.97, 0.99) # Very light blue background - -# Chart colors -CHART_COLOR_GREEN_1 = "#4CAF50" -CHART_COLOR_GREEN_2 = "#8BC34A" -CHART_COLOR_YELLOW = "#FFEB3B" -CHART_COLOR_ORANGE = "#FF9800" -CHART_COLOR_RED = "#F44336" -CHART_COLOR_BLUE = "#2196F3" - -# ENS dimension mappings -DIMENSION_MAPPING = { - "trazabilidad": ("T", colors.Color(0.26, 0.52, 0.96)), - "autenticidad": ("A", colors.Color(0.30, 0.69, 0.31)), - "integridad": ("I", colors.Color(0.61, 0.15, 0.69)), - "confidencialidad": ("C", colors.Color(0.96, 0.26, 0.21)), - "disponibilidad": ("D", colors.Color(1.0, 0.60, 0.0)), -} - -# ENS tipo icons -TIPO_ICONS = { - "requisito": "⚠️", - "refuerzo": "πŸ›‘οΈ", - "recomendacion": "πŸ’‘", - "medida": "πŸ“‹", -} - -# Dimension names for charts -DIMENSION_NAMES = [ - "Trazabilidad", - "Autenticidad", - "Integridad", - "Confidencialidad", - "Disponibilidad", -] - -DIMENSION_KEYS = [ - "trazabilidad", - "autenticidad", - "integridad", - "confidencialidad", - "disponibilidad", -] - -# ENS nivel order -ENS_NIVEL_ORDER = ["alto", "medio", "bajo", "opcional"] - -# ENS tipo order -ENS_TIPO_ORDER = ["requisito", "refuerzo", "recomendacion", "medida"] - -# ThreatScore expected sections -THREATSCORE_SECTIONS = [ - "1. IAM", - "2. Attack Surface", - "3. Logging and Monitoring", - "4. Encryption", -] - -# NIS2 main sections (simplified for chart display) -NIS2_SECTIONS = [ - "1", # Policy on Security - "2", # Risk Management - "3", # Incident Handling - "4", # Business Continuity - "5", # Supply Chain Security - "6", # Acquisition & Development - "7", # Effectiveness Assessment - "9", # Cryptography - "11", # Access Control - "12", # Asset Management -] - -# Table column widths (in inches) -COL_WIDTH_SMALL = 0.4 * inch -COL_WIDTH_MEDIUM = 0.9 * inch -COL_WIDTH_LARGE = 1.5 * inch -COL_WIDTH_XLARGE = 2 * inch -COL_WIDTH_XXLARGE = 3 * inch - -# Common padding values -PADDING_SMALL = 4 -PADDING_MEDIUM = 6 -PADDING_LARGE = 8 -PADDING_XLARGE = 10 - - -# Cache for PDF styles to avoid recreating them on every call -_PDF_STYLES_CACHE: dict[str, ParagraphStyle] | None = None - - -# Helper functions for performance optimization -def _get_color_for_risk_level(risk_level: int) -> colors.Color: - """Get color based on risk level using optimized lookup.""" - if risk_level >= 4: - return COLOR_HIGH_RISK - elif risk_level >= 3: - return COLOR_MEDIUM_RISK - elif risk_level >= 2: - return COLOR_LOW_RISK - return COLOR_SAFE - - -def _get_color_for_weight(weight: int) -> colors.Color: - """Get color based on weight using optimized lookup.""" - if weight > 100: - return COLOR_HIGH_RISK - elif weight > 50: - return COLOR_LOW_RISK - return COLOR_SAFE - - -def _get_color_for_compliance(percentage: float) -> colors.Color: - """Get color based on compliance percentage.""" - if percentage >= 80: - return COLOR_SAFE - elif percentage >= 60: - return COLOR_LOW_RISK - return COLOR_HIGH_RISK - - -def _get_chart_color_for_percentage(percentage: float) -> str: - """Get chart color string based on percentage.""" - if percentage >= 80: - return CHART_COLOR_GREEN_1 - elif percentage >= 60: - return CHART_COLOR_GREEN_2 - elif percentage >= 40: - return CHART_COLOR_YELLOW - elif percentage >= 20: - return CHART_COLOR_ORANGE - return CHART_COLOR_RED - - -def _get_ens_nivel_color(nivel: str) -> colors.Color: - """Get ENS nivel color using optimized lookup.""" - nivel_lower = nivel.lower() - if nivel_lower == "alto": - return COLOR_ENS_ALTO - elif nivel_lower == "medio": - return COLOR_ENS_MEDIO - elif nivel_lower == "bajo": - return COLOR_ENS_BAJO - return COLOR_ENS_OPCIONAL - - -def _safe_getattr(obj, attr: str, default: str = "N/A") -> str: - """Optimized getattr with default value.""" - return getattr(obj, attr, default) - - -def _create_info_table_style() -> TableStyle: - """Create a reusable table style for information/metadata tables. - - ReportLab TableStyle coordinate system: - - Format: (COMMAND, (start_col, start_row), (end_col, end_row), value) - - Coordinates use (column, row) format, starting at (0, 0) for top-left cell - - Negative indices work like Python slicing: -1 means "last row/column" - - (0, 0) to (0, -1) = entire first column (all rows) - - (0, 0) to (-1, 0) = entire first row (all columns) - - (0, 0) to (-1, -1) = entire table - - Styles are applied in order; later rules override earlier ones - """ - return TableStyle( - [ - # Column 0 (labels): blue background with white text - ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), - ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), - ("FONTNAME", (0, 0), (0, -1), "FiraCode"), - # Column 1 (values): light blue background with gray text - ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), - ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), - ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), - # Apply to entire table - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("FONTSIZE", (0, 0), (-1, -1), 11), - ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), - ("LEFTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), - ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), - ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ] - ) - - -def _create_header_table_style(header_color: colors.Color = None) -> TableStyle: - """Create a reusable table style for tables with headers. - - ReportLab TableStyle coordinate system: - - Format: (COMMAND, (start_col, start_row), (end_col, end_row), value) - - (0, 0) to (-1, 0) = entire first row (header row) - - (1, 1) to (-1, -1) = all data cells (excludes header row and first column) - - See _create_info_table_style() for full coordinate system documentation - """ - if header_color is None: - header_color = COLOR_BLUE - - return TableStyle( - [ - # Header row (row 0): colored background with white text - ("BACKGROUND", (0, 0), (-1, 0), header_color), - ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("FONTSIZE", (0, 0), (-1, 0), 10), - # Apply to entire table - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - # Data cells (excluding header): smaller font - ("FONTSIZE", (1, 1), (-1, -1), 9), - # Apply to entire table - ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), - ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ] - ) - - -def _create_findings_table_style() -> TableStyle: - """Create a reusable table style for findings tables. - - ReportLab TableStyle coordinate system: - - Format: (COMMAND, (start_col, start_row), (end_col, end_row), value) - - (0, 0) to (-1, 0) = entire first row (header row) - - (0, 0) to (0, 0) = only the top-left cell - - See _create_info_table_style() for full coordinate system documentation - """ - return TableStyle( - [ - # Header row (row 0): colored background with white text - ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), - ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - # Only top-left cell centered (for index/number column) - ("ALIGN", (0, 0), (0, 0), "CENTER"), - # Apply to entire table - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 9), - ("GRID", (0, 0), (-1, -1), 0.1, COLOR_BORDER_GRAY), - # Remove padding only from top-left cell - ("LEFTPADDING", (0, 0), (0, 0), 0), - ("RIGHTPADDING", (0, 0), (0, 0), 0), - # Apply to entire table - ("TOPPADDING", (0, 0), (-1, -1), PADDING_SMALL), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_SMALL), - ] - ) - - -def _create_pdf_styles() -> dict[str, ParagraphStyle]: - """ - Create and return PDF paragraph styles used throughout the report. - - Styles are cached on first call to improve performance. - - Returns: - dict[str, ParagraphStyle]: A dictionary containing the following styles: - - 'title': Title style with prowler green color - - 'h1': Heading 1 style with blue color and background - - 'h2': Heading 2 style with light blue color - - 'h3': Heading 3 style for sub-headings - - 'normal': Normal text style with left indent - - 'normal_center': Normal text style without indent - """ - global _PDF_STYLES_CACHE - - if _PDF_STYLES_CACHE is not None: - return _PDF_STYLES_CACHE - - styles = getSampleStyleSheet() - - title_style = ParagraphStyle( - "CustomTitle", - parent=styles["Title"], - fontSize=24, - textColor=COLOR_PROWLER_DARK_GREEN, - spaceAfter=20, - fontName="PlusJakartaSans", - alignment=TA_CENTER, - ) - - h1 = ParagraphStyle( - "CustomH1", - parent=styles["Heading1"], - fontSize=18, - textColor=COLOR_BLUE, - spaceBefore=20, - spaceAfter=12, - fontName="PlusJakartaSans", - leftIndent=0, - borderWidth=2, - borderColor=COLOR_BLUE, - borderPadding=PADDING_LARGE, - backColor=COLOR_BG_BLUE, - ) - - h2 = ParagraphStyle( - "CustomH2", - parent=styles["Heading2"], - fontSize=14, - textColor=COLOR_LIGHT_BLUE, - spaceBefore=15, - spaceAfter=8, - fontName="PlusJakartaSans", - leftIndent=10, - borderWidth=1, - borderColor=COLOR_BORDER_GRAY, - borderPadding=5, - backColor=COLOR_BG_LIGHT_BLUE, - ) - - h3 = ParagraphStyle( - "CustomH3", - parent=styles["Heading3"], - fontSize=12, - textColor=COLOR_LIGHTER_BLUE, - spaceBefore=10, - spaceAfter=6, - fontName="PlusJakartaSans", - leftIndent=20, - ) - - normal = ParagraphStyle( - "CustomNormal", - parent=styles["Normal"], - fontSize=10, - textColor=COLOR_GRAY, - spaceBefore=PADDING_SMALL, - spaceAfter=PADDING_SMALL, - leftIndent=30, - fontName="PlusJakartaSans", - ) - - normal_center = ParagraphStyle( - "CustomNormalCenter", - parent=styles["Normal"], - fontSize=10, - textColor=COLOR_GRAY, - fontName="PlusJakartaSans", - ) - - _PDF_STYLES_CACHE = { - "title": title_style, - "h1": h1, - "h2": h2, - "h3": h3, - "normal": normal, - "normal_center": normal_center, - } - - return _PDF_STYLES_CACHE - - -def _create_risk_component(risk_level: int, weight: int, score: int = 0) -> Table: - """ - Create a visual risk component table for the PDF report. - - Args: - risk_level (int): The risk level (0-5), where higher values indicate higher risk. - weight (int): The weight of the risk component. - score (int): The calculated score. Defaults to 0. - - Returns: - Table: A ReportLab Table object with colored cells representing risk, weight, and score. - """ - risk_color = _get_color_for_risk_level(risk_level) - weight_color = _get_color_for_weight(weight) - - data = [ - [ - "Risk Level:", - str(risk_level), - "Weight:", - str(weight), - "Score:", - str(score), - ] - ] - - table = Table( - data, - colWidths=[ - 0.8 * inch, - COL_WIDTH_SMALL, - 0.6 * inch, - COL_WIDTH_SMALL, - 0.5 * inch, - COL_WIDTH_SMALL, - ], - ) - - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), - ("BACKGROUND", (1, 0), (1, 0), risk_color), - ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("BACKGROUND", (2, 0), (2, 0), COLOR_LIGHT_GRAY), - ("BACKGROUND", (3, 0), (3, 0), weight_color), - ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE), - ("FONTNAME", (3, 0), (3, 0), "FiraCode"), - ("BACKGROUND", (4, 0), (4, 0), COLOR_LIGHT_GRAY), - ("BACKGROUND", (5, 0), (5, 0), COLOR_DARK_GRAY), - ("TEXTCOLOR", (5, 0), (5, 0), COLOR_WHITE), - ("FONTNAME", (5, 0), (5, 0), "FiraCode"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ] - ) - ) - - return table - - -def _create_status_component(status: str) -> Table: - """ - Create a visual status component with colored background. - - Args: - status (str): The status value (e.g., "PASS", "FAIL", "MANUAL"). - - Returns: - Table: A ReportLab Table object displaying the status with appropriate color coding. - """ - status_upper = status.upper() - if status_upper == "PASS": - status_color = COLOR_SAFE - elif status_upper == "FAIL": - status_color = COLOR_HIGH_RISK - else: - status_color = COLOR_DARK_GRAY - - data = [["State:", status_upper]] - - table = Table(data, colWidths=[0.6 * inch, 0.8 * inch]) - - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), - ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), - ("BACKGROUND", (1, 0), (1, 0), status_color), - ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 12), - ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("TOPPADDING", (0, 0), (-1, -1), PADDING_XLARGE), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_XLARGE), - ] - ) - ) - - return table - - -def _create_ens_nivel_badge(nivel: str) -> Table: - """ - Create a visual badge for ENS requirement level (Nivel). - - Args: - nivel (str): The level value (e.g., "alto", "medio", "bajo", "opcional"). - - Returns: - Table: A ReportLab Table object displaying the level with appropriate color coding. - """ - nivel_color = _get_ens_nivel_color(nivel) - data = [[f"Nivel: {nivel.upper()}"]] - - table = Table(data, colWidths=[1.4 * inch]) - - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), nivel_color), - ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), - ("FONTNAME", (0, 0), (0, 0), "FiraCode"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 11), - ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ] - ) - ) - - return table - - -def _create_ens_tipo_badge(tipo: str) -> Table: - """ - Create a visual badge for ENS requirement type (Tipo). - - Args: - tipo (str): The type value (e.g., "requisito", "refuerzo", "recomendacion", "medida"). - - Returns: - Table: A ReportLab Table object displaying the type with appropriate styling. - """ - tipo_lower = tipo.lower() - icon = TIPO_ICONS.get(tipo_lower, "") - - data = [[f"{icon} {tipo.capitalize()}"]] - - table = Table(data, colWidths=[1.8 * inch]) - - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), COLOR_ENS_TIPO), - ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), - ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 11), - ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), - ] - ) - ) - - return table - - -def _create_ens_dimension_badges(dimensiones: list[str]) -> Table: - """ - Create visual badges for ENS security dimensions. - - Args: - dimensiones (list[str]): List of dimension names (e.g., ["trazabilidad", "autenticidad"]). - - Returns: - Table: A ReportLab Table object with color-coded badges for each dimension. - """ - badges = [ - DIMENSION_MAPPING[dimension.lower()] - for dimension in dimensiones - if dimension.lower() in DIMENSION_MAPPING - ] - - if not badges: - data = [["N/A"]] - table = Table(data, colWidths=[1 * inch]) - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ] - ) - ) - return table - - data = [[badge[0] for badge in badges]] - col_widths = [COL_WIDTH_SMALL] * len(badges) - - table = Table(data, colWidths=col_widths) - - styles = [ - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTNAME", (0, 0), (-1, -1), "FiraCode"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("TEXTCOLOR", (0, 0), (-1, -1), COLOR_WHITE), - ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), PADDING_SMALL), - ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_SMALL), - ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), - ] - - for idx, (_, badge_color) in enumerate(badges): - styles.append(("BACKGROUND", (idx, 0), (idx, 0), badge_color)) - - table.setStyle(TableStyle(styles)) - - return table - - -def _create_section_score_chart( - requirements_list: list[dict], attributes_by_requirement_id: dict -) -> io.BytesIO: - """ - Create a bar chart showing compliance score by section using ThreatScore formula. - - Args: - requirements_list (list[dict]): List of requirement dictionaries with status and findings data. - attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes including risk level and weight. - - Returns: - io.BytesIO: A BytesIO buffer containing the chart image in PNG format. - """ - # Initialize all expected sections with default values - sections_data = { - section: { - "numerator": 0, - "denominator": 0, - "has_findings": False, - } - for section in THREATSCORE_SECTIONS - } - - # Collect data from requirements - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - section = _safe_getattr(m, "Section", "Unknown") - - # Add section if not in expected list (for flexibility) - if section not in sections_data: - sections_data[section] = { - "numerator": 0, - "denominator": 0, - "has_findings": False, - } - - # Get findings data - passed_findings = requirement["attributes"].get("passed_findings", 0) - total_findings = requirement["attributes"].get("total_findings", 0) - - if total_findings > 0: - sections_data[section]["has_findings"] = True - risk_level = _safe_getattr(m, "LevelOfRisk", 0) - weight = _safe_getattr(m, "Weight", 0) - - # Calculate using ThreatScore formula from UI - rate_i = passed_findings / total_findings - rfac_i = 1 + 0.25 * risk_level - - sections_data[section]["numerator"] += ( - rate_i * total_findings * weight * rfac_i - ) - sections_data[section]["denominator"] += total_findings * weight * rfac_i - - # Calculate percentages - section_names = [] - compliance_percentages = [] - - for section, data in sections_data.items(): - if data["has_findings"] and data["denominator"] > 0: - compliance_percentage = (data["numerator"] / data["denominator"]) * 100 - else: - compliance_percentage = 100 # No findings = 100% (PASS) - - section_names.append(section) - compliance_percentages.append(compliance_percentage) - - # Sort alphabetically by section name - sorted_data = sorted(zip(section_names, compliance_percentages), key=lambda x: x[0]) - if not sorted_data: - section_names, compliance_percentages = [], [] - else: - section_names, compliance_percentages = zip(*sorted_data) - - # Generate chart - fig, ax = plt.subplots(figsize=(12, 8)) - - # Use helper function for color selection - colors_list = [_get_chart_color_for_percentage(p) for p in compliance_percentages] - - bars = ax.bar(section_names, compliance_percentages, color=colors_list) - - ax.set_ylabel("Compliance Score (%)", fontsize=12) - ax.set_xlabel("Section", fontsize=12) - ax.set_ylim(0, 100) - - for bar, percentage in zip(bars, compliance_percentages): - height = bar.get_height() - ax.text( - bar.get_x() + bar.get_width() / 2.0, - height + 1, - f"{percentage:.1f}%", - ha="center", - va="bottom", - fontweight="bold", - ) - - plt.xticks(rotation=45, ha="right") - ax.grid(True, alpha=0.3, axis="y") - plt.tight_layout() - - buffer = io.BytesIO() - try: - plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") - buffer.seek(0) - finally: - plt.close(fig) - - return buffer - - -def _add_pdf_footer( - canvas_obj: canvas.Canvas, doc: SimpleDocTemplate, compliance_name: str -) -> None: - """ - Add footer with page number and branding to each page of the PDF. - - Args: - canvas_obj (canvas.Canvas): The ReportLab canvas object for drawing. - doc (SimpleDocTemplate): The document template containing page information. - """ - canvas_obj.saveState() - width, height = doc.pagesize - page_num_text = ( - f"{'PΓ‘gina' if 'ens' in compliance_name.lower() else 'Page'} {doc.page}" - ) - canvas_obj.setFont("PlusJakartaSans", 9) - canvas_obj.setFillColorRGB(0.4, 0.4, 0.4) - canvas_obj.drawString(30, 20, page_num_text) - powered_text = "Powered by Prowler" - text_width = canvas_obj.stringWidth(powered_text, "PlusJakartaSans", 9) - canvas_obj.drawString(width - text_width - 30, 20, powered_text) - canvas_obj.restoreState() - - -def _create_marco_category_chart( - requirements_list: list[dict], attributes_by_requirement_id: dict -) -> io.BytesIO: - """ - Create a bar chart showing compliance percentage by Marco (Section) and CategorΓ­a. - - Args: - requirements_list (list[dict]): List of requirement dictionaries with status and findings data. - attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes. - - Returns: - io.BytesIO: A BytesIO buffer containing the chart image in PNG format. - """ - # Collect data by Marco and CategorΓ­a - marco_categoria_data = defaultdict(lambda: {"passed": 0, "total": 0}) - - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) - requirement_status = requirement["attributes"].get( - "status", StatusChoices.MANUAL - ) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - marco = _safe_getattr(m, "Marco") - categoria = _safe_getattr(m, "Categoria") - - key = f"{marco} - {categoria}" - marco_categoria_data[key]["total"] += 1 - if requirement_status == StatusChoices.PASS: - marco_categoria_data[key]["passed"] += 1 - - # Calculate percentages - categories = [] - percentages = [] - - for category, data in sorted(marco_categoria_data.items()): - percentage = (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0 - categories.append(category) - percentages.append(percentage) - - if not categories: - # Return empty chart if no data - fig, ax = plt.subplots(figsize=(12, 6)) - ax.text(0.5, 0.5, "No data available", ha="center", va="center", fontsize=14) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") - buffer = io.BytesIO() - try: - plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") - buffer.seek(0) - finally: - plt.close(fig) - return buffer - - # Create horizontal bar chart - fig, ax = plt.subplots(figsize=(12, max(8, len(categories) * 0.4))) - - # Use helper function for color selection - colors_list = [_get_chart_color_for_percentage(p) for p in percentages] - - y_pos = range(len(categories)) - bars = ax.barh(y_pos, percentages, color=colors_list) - - ax.set_yticks(y_pos) - ax.set_yticklabels(categories, fontsize=16) - ax.set_xlabel("Porcentaje de Cumplimiento (%)", fontsize=14) - ax.set_xlim(0, 100) - - # Add percentage labels - for bar, percentage in zip(bars, percentages): - width = bar.get_width() - ax.text( - width + 1, - bar.get_y() + bar.get_height() / 2.0, - f"{percentage:.1f}%", - ha="left", - va="center", - fontweight="bold", - fontsize=10, - ) - - ax.grid(True, alpha=0.3, axis="x") - plt.tight_layout() - - buffer = io.BytesIO() - try: - # Render canvas and save explicitly from the figure to avoid state bleed - fig.canvas.draw() - fig.savefig(buffer, format="png", dpi=300, bbox_inches="tight") - buffer.seek(0, io.SEEK_END) - finally: - plt.close(fig) - - return buffer - - -def _create_dimensions_radar_chart( - requirements_list: list[dict], attributes_by_requirement_id: dict -) -> io.BytesIO: - """ - Create a radar/spider chart showing compliance percentage by security dimension. - - Args: - requirements_list (list[dict]): List of requirement dictionaries with status and findings data. - attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes. - - Returns: - io.BytesIO: A BytesIO buffer containing the chart image in PNG format. - """ - dimension_data = {key: {"passed": 0, "total": 0} for key in DIMENSION_KEYS} - - # Collect data for each dimension - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) - requirement_status = requirement["attributes"].get( - "status", StatusChoices.MANUAL - ) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - dimensiones_attr = getattr(m, "Dimensiones", None) - dimensiones = dimensiones_attr or [] - if isinstance(dimensiones, str): - dimensiones = [dimensiones] - - for dimension in dimensiones: - dimension_lower = dimension.lower() - if dimension_lower in dimension_data: - dimension_data[dimension_lower]["total"] += 1 - if requirement_status == StatusChoices.PASS: - dimension_data[dimension_lower]["passed"] += 1 - - # Calculate percentages - percentages = [ - ( - (dimension_data[key]["passed"] / dimension_data[key]["total"] * 100) - if dimension_data[key]["total"] > 0 - else 100 - ) # No requirements = 100% (no failures) - for key in DIMENSION_KEYS - ] - - # Create radar chart - num_dims = len(DIMENSION_NAMES) - angles = [n / float(num_dims) * 2 * 3.14159 for n in range(num_dims)] - percentages += percentages[:1] - angles += angles[:1] - - fig, ax = plt.subplots(figsize=(10, 10), subplot_kw=dict(projection="polar")) - - ax.plot(angles, percentages, "o-", linewidth=2, color=CHART_COLOR_BLUE) - ax.fill(angles, percentages, alpha=0.25, color=CHART_COLOR_BLUE) - ax.set_xticks(angles[:-1]) - ax.set_xticklabels(DIMENSION_NAMES, fontsize=14) - ax.set_ylim(0, 100) - ax.set_yticks([20, 40, 60, 80, 100]) - ax.set_yticklabels(["20%", "40%", "60%", "80%", "100%"], fontsize=12) - ax.grid(True, alpha=0.3) - - plt.tight_layout() - - buffer = io.BytesIO() - try: - fig.canvas.draw() - fig.savefig(buffer, format="png", dpi=300, bbox_inches="tight") - buffer.seek(0, io.SEEK_END) - finally: - plt.close(fig) - - return buffer - def generate_threatscore_report( tenant_id: str, @@ -1027,911 +29,39 @@ def generate_threatscore_report( provider_id: str, only_failed: bool = True, min_risk_level: int = 4, - provider_obj=None, + provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, ) -> None: """ Generate a PDF compliance report based on Prowler ThreatScore framework. - This function creates a comprehensive PDF report containing: - - Compliance overview and metadata - - Section-by-section compliance scores with charts - - Overall ThreatScore calculation - - Critical failed requirements - - Detailed findings for each requirement - Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): ID of the scan executed by Prowler. - compliance_id (str): ID of the compliance framework (e.g., "prowler_threatscore_aws"). - output_path (str): Output PDF file path (e.g., "/tmp/threatscore_report.pdf"). - provider_id (str): Provider ID for the scan. - only_failed (bool): If True, only requirements with status "FAIL" will be included - in the detailed requirements section. Defaults to True. - min_risk_level (int): Minimum risk level for critical failed requirements. Defaults to 4. - provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries. - If None, the provider will be fetched from the database. - requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid - duplicate database aggregations. If None, statistics will be aggregated from the database. - findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries. - If None, findings will be loaded from the database. When provided, reduces database - queries and transformation overhead when generating multiple reports. - - Raises: - Exception: If any error occurs during PDF generation, it will be logged and re-raised. + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the compliance framework (e.g., "prowler_threatscore_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + only_failed: If True, only include failed requirements in detailed section. + min_risk_level: Minimum risk level for critical failed requirements. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. """ - logger.info( - f"Generating the report for the scan {scan_id} with provider {provider_id}" + generator = ThreatScoreReportGenerator(FRAMEWORK_REGISTRY["prowler_threatscore"]) + generator._min_risk_level = min_risk_level + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + only_failed=only_failed, ) - try: - # Get PDF styles - pdf_styles = _create_pdf_styles() - title_style = pdf_styles["title"] - h1 = pdf_styles["h1"] - h2 = pdf_styles["h2"] - h3 = pdf_styles["h3"] - normal = pdf_styles["normal"] - normal_center = pdf_styles["normal_center"] - - # Get compliance and provider information - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Use provided provider_obj or fetch from database - if provider_obj is None: - provider_obj = Provider.objects.get(id=provider_id) - - prowler_provider = initialize_prowler_provider(provider_obj) - provider_type = provider_obj.provider - - frameworks_bulk = Compliance.get_bulk(provider_type) - compliance_obj = frameworks_bulk[compliance_id] - compliance_framework = _safe_getattr(compliance_obj, "Framework") - compliance_version = _safe_getattr(compliance_obj, "Version") - compliance_name = _safe_getattr(compliance_obj, "Name") - compliance_description = _safe_getattr(compliance_obj, "Description", "") - - # Aggregate requirement statistics from database (memory-efficient) - # Use provided requirement_statistics or fetch from database - if requirement_statistics is None: - logger.info(f"Aggregating requirement statistics for scan {scan_id}") - requirement_statistics_by_check_id = ( - _aggregate_requirement_statistics_from_database(tenant_id, scan_id) - ) - else: - logger.info( - f"Reusing pre-aggregated requirement statistics for scan {scan_id}" - ) - requirement_statistics_by_check_id = requirement_statistics - - # Calculate requirements data using aggregated statistics - attributes_by_requirement_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - compliance_obj, requirement_statistics_by_check_id - ) - ) - - # Initialize PDF document - doc = SimpleDocTemplate( - output_path, - pagesize=letter, - title=f"Prowler ThreatScore Report - {compliance_framework}", - author="Prowler", - subject=f"Compliance Report for {compliance_framework}", - creator="Prowler Engineering Team", - keywords=f"compliance,{compliance_framework},security,framework,prowler", - ) - - elements = [] - - # Add logo - img_path = os.path.join( - os.path.dirname(__file__), "../assets/img/prowler_logo.png" - ) - logo = Image( - img_path, - width=5 * inch, - height=1 * inch, - ) - elements.append(logo) - - elements.append(Spacer(1, 0.5 * inch)) - elements.append(Paragraph("Prowler ThreatScore Report", title_style)) - elements.append(Spacer(1, 0.5 * inch)) - - # Add compliance information table - provider_alias = provider_obj.alias or "N/A" - info_data = [ - ["Framework:", compliance_framework], - ["ID:", compliance_id], - ["Name:", Paragraph(compliance_name, normal_center)], - ["Version:", compliance_version], - ["Provider:", provider_type.upper()], - ["Account ID:", provider_obj.uid], - ["Alias:", provider_alias], - ["Scan ID:", scan_id], - ["Description:", Paragraph(compliance_description, normal_center)], - ] - info_table = Table(info_data, colWidths=[COL_WIDTH_XLARGE, 4 * inch]) - info_table.setStyle(_create_info_table_style()) - - elements.append(info_table) - elements.append(PageBreak()) - - # Add compliance score chart - elements.append(Paragraph("Compliance Score by Sections", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - chart_buffer = _create_section_score_chart( - requirements_list, attributes_by_requirement_id - ) - chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) - elements.append(chart_image) - - # Calculate overall ThreatScore using the same formula as the UI - numerator = 0 - denominator = 0 - has_findings = False - - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - - # Get findings data - passed_findings = requirement["attributes"].get("passed_findings", 0) - total_findings = requirement["attributes"].get("total_findings", 0) - - # Skip if no findings (avoid division by zero) - if total_findings == 0: - continue - - has_findings = True - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata and len(metadata) > 0: - m = metadata[0] - risk_level = getattr(m, "LevelOfRisk", 0) - weight = getattr(m, "Weight", 0) - - # Calculate using ThreatScore formula from UI - rate_i = passed_findings / total_findings - rfac_i = 1 + 0.25 * risk_level - - numerator += rate_i * total_findings * weight * rfac_i - denominator += total_findings * weight * rfac_i - - # Calculate ThreatScore (percentualScore) - # If no findings exist, consider it 100% (PASS) - if not has_findings: - overall_compliance = 100 - elif denominator > 0: - overall_compliance = (numerator / denominator) * 100 - else: - overall_compliance = 0 - - elements.append(Spacer(1, 0.3 * inch)) - - summary_data = [ - ["ThreatScore:", f"{overall_compliance:.2f}%"], - ] - - compliance_color = _get_color_for_compliance(overall_compliance) - - summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) - summary_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), - ("TEXTCOLOR", (0, 0), (0, 0), colors.white), - ("FONTNAME", (0, 0), (0, 0), "FiraCode"), - ("FONTSIZE", (0, 0), (0, 0), 12), - ("BACKGROUND", (1, 0), (1, 0), compliance_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("FONTSIZE", (1, 0), (1, 0), 16), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 12), - ("RIGHTPADDING", (0, 0), (-1, -1), 12), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ] - ) - ) - - elements.append(summary_table) - elements.append(PageBreak()) - - # Add requirements index - elements.append(Paragraph("Requirements Index", h1)) - - sections = {} - for ( - requirement_id, - requirement_attributes, - ) in attributes_by_requirement_id.items(): - meta = requirement_attributes["attributes"]["req_attributes"][0] - section = getattr(meta, "Section", "N/A") - subsection = getattr(meta, "SubSection", "N/A") - title = getattr(meta, "Title", "N/A") - - if section not in sections: - sections[section] = {} - if subsection not in sections[section]: - sections[section][subsection] = [] - - sections[section][subsection].append({"id": requirement_id, "title": title}) - - section_num = 1 - for section_name, subsections in sections.items(): - elements.append(Paragraph(f"{section_num}. {section_name}", h2)) - - subsection_num = 1 - for subsection_name, requirements in subsections.items(): - elements.append(Paragraph(f"{subsection_name}", h3)) - - req_num = 1 - for req in requirements: - elements.append(Paragraph(f"{req['id']} - {req['title']}", normal)) - req_num += 1 - - subsection_num += 1 - - section_num += 1 - elements.append(Spacer(1, 0.1 * inch)) - - elements.append(PageBreak()) - - # Add critical failed requirements section - elements.append(Paragraph("Top Requirements by Level of Risk", h1)) - elements.append(Spacer(1, 0.1 * inch)) - elements.append( - Paragraph( - f"Critical Failed Requirements (Risk Level β‰₯ {min_risk_level})", h2 - ) - ) - elements.append(Spacer(1, 0.2 * inch)) - - critical_failed_requirements = [] - for requirement in requirements_list: - requirement_status = requirement["attributes"]["status"] - if requirement_status == StatusChoices.FAIL: - requirement_id = requirement["id"] - metadata = ( - attributes_by_requirement_id.get(requirement_id, {}) - .get("attributes", {}) - .get("req_attributes", [{}])[0] - ) - if metadata: - risk_level = getattr(metadata, "LevelOfRisk", 0) - weight = getattr(metadata, "Weight", 0) - - if risk_level >= min_risk_level: - critical_failed_requirements.append( - { - "requirement": requirement, - "attributes": attributes_by_requirement_id[ - requirement_id - ], - "risk_level": risk_level, - "weight": weight, - "metadata": metadata, - } - ) - - critical_failed_requirements.sort( - key=lambda x: (x["risk_level"], x["weight"]), reverse=True - ) - - if not critical_failed_requirements: - elements.append( - Paragraph( - "βœ… No critical failed requirements found. Great job!", normal - ) - ) - else: - elements.append( - Paragraph( - f"Found {len(critical_failed_requirements)} critical failed requirements that require immediate attention:", - normal, - ) - ) - elements.append(Spacer(1, 0.5 * inch)) - - table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] - - for idx, critical_failed_requirement in enumerate( - critical_failed_requirements - ): - requirement_id = critical_failed_requirement["requirement"]["id"] - risk_level = critical_failed_requirement["risk_level"] - weight = critical_failed_requirement["weight"] - title = getattr(critical_failed_requirement["metadata"], "Title", "N/A") - section = getattr( - critical_failed_requirement["metadata"], "Section", "N/A" - ) - - if len(title) > 50: - title = title[:47] + "..." - - table_data.append( - [str(risk_level), str(weight), requirement_id, title, section] - ) - - critical_table = Table( - table_data, - colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch], - ) - - critical_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("FONTSIZE", (0, 0), (-1, 0), 10), - ("BACKGROUND", (0, 1), (0, -1), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 1), (0, -1), colors.white), - ("FONTNAME", (0, 1), (0, -1), "FiraCode"), - ("ALIGN", (0, 1), (0, -1), "CENTER"), - ("FONTSIZE", (0, 1), (0, -1), 12), - ("ALIGN", (1, 1), (1, -1), "CENTER"), - ("FONTNAME", (1, 1), (1, -1), "FiraCode"), - ("FONTNAME", (2, 1), (2, -1), "FiraCode"), - ("FONTSIZE", (2, 1), (2, -1), 9), - ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), - ("FONTSIZE", (3, 1), (-1, -1), 8), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 6), - ("RIGHTPADDING", (0, 0), (-1, -1), 6), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ( - "BACKGROUND", - (1, 1), - (-1, -1), - colors.Color(0.98, 0.98, 0.98), - ), - ] - ) - ) - - for idx, critical_failed_requirement in enumerate( - critical_failed_requirements - ): - row_idx = idx + 1 - weight = critical_failed_requirement["weight"] - - if weight >= 150: - weight_color = colors.Color(0.8, 0.2, 0.2) - elif weight >= 100: - weight_color = colors.Color(0.9, 0.6, 0.2) - else: - weight_color = colors.Color(0.9, 0.9, 0.2) - - critical_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), - ("TEXTCOLOR", (1, row_idx), (1, row_idx), colors.white), - ] - ) - ) - - elements.append(critical_table) - elements.append(Spacer(1, 0.2 * inch)) - - # Get styles for warning - styles = getSampleStyleSheet() - warning_text = """ - IMMEDIATE ACTION REQUIRED:
- These requirements have the highest risk levels and have failed compliance checks. - Please prioritize addressing these issues to improve your security posture. - """ - - warning_style = ParagraphStyle( - "Warning", - parent=styles["Normal"], - fontSize=11, - textColor=colors.Color(0.8, 0.2, 0.2), - spaceBefore=10, - spaceAfter=10, - leftIndent=20, - rightIndent=20, - fontName="PlusJakartaSans", - backColor=colors.Color(1.0, 0.95, 0.95), - borderWidth=2, - borderColor=colors.Color(0.8, 0.2, 0.2), - borderPadding=10, - ) - - elements.append(Paragraph(warning_text, warning_style)) - - elements.append(PageBreak()) - - # Add detailed requirements section - def get_weight_for_requirement(requirement_dict): - requirement_id = requirement_dict["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata: - return getattr(metadata[0], "Weight", 0) - return 0 - - sorted_requirements = sorted( - requirements_list, key=get_weight_for_requirement, reverse=True - ) - - if only_failed: - sorted_requirements = [ - requirement - for requirement in sorted_requirements - if requirement["attributes"]["status"] == StatusChoices.FAIL - ] - - # Collect all check IDs for requirements that will be displayed - # This allows us to load only the findings we actually need (memory optimization) - check_ids_to_load = [] - for requirement in sorted_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - check_ids = requirement_attributes.get("attributes", {}).get("checks", []) - check_ids_to_load.extend(check_ids) - - # Load findings on-demand only for the checks that will be displayed - logger.info( - f"Loading findings on-demand for {len(sorted_requirements)} requirements" - ) - findings_by_check_id = _load_findings_for_requirement_checks( - tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache - ) - - for requirement in sorted_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - requirement_description = requirement["attributes"]["description"] - requirement_status = requirement["attributes"]["status"] - - elements.append( - Paragraph( - f"{requirement_id}: {requirement_attributes.get('description', requirement_description)}", - h1, - ) - ) - - status_component = _create_status_component(requirement_status) - elements.append(status_component) - elements.append(Spacer(1, 0.1 * inch)) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata and len(metadata) > 0: - m = metadata[0] - elements.append(Paragraph("Title: ", h3)) - elements.append(Paragraph(f"{getattr(m, 'Title', 'N/A')}", normal)) - elements.append(Paragraph("Section: ", h3)) - elements.append(Paragraph(f"{getattr(m, 'Section', 'N/A')}", normal)) - elements.append(Paragraph("SubSection: ", h3)) - elements.append(Paragraph(f"{getattr(m, 'SubSection', 'N/A')}", normal)) - elements.append(Paragraph("Description: ", h3)) - elements.append( - Paragraph(f"{getattr(m, 'AttributeDescription', 'N/A')}", normal) - ) - elements.append(Paragraph("Additional Information: ", h3)) - elements.append( - Paragraph(f"{getattr(m, 'AdditionalInformation', 'N/A')}", normal) - ) - elements.append(Spacer(1, 0.1 * inch)) - - risk_level = getattr(m, "LevelOfRisk", 0) - weight = getattr(m, "Weight", 0) - - if requirement_status == StatusChoices.PASS: - score = risk_level * weight - else: - score = 0 - - risk_component = _create_risk_component(risk_level, weight, score) - elements.append(risk_component) - elements.append(Spacer(1, 0.1 * inch)) - - # Get findings for this requirement's checks (loaded on-demand earlier) - requirement_check_ids = requirement_attributes.get("attributes", {}).get( - "checks", [] - ) - for check_id in requirement_check_ids: - elements.append(Paragraph(f"Check: {check_id}", h2)) - elements.append(Spacer(1, 0.1 * inch)) - - # Get findings for this check (already loaded on-demand) - check_findings = findings_by_check_id.get(check_id, []) - - if not check_findings: - elements.append( - Paragraph("- No information for this finding currently", normal) - ) - else: - findings_table_data = [ - [ - "Finding", - "Resource name", - "Severity", - "Status", - "Region", - ] - ] - for finding_output in check_findings: - check_metadata = getattr(finding_output, "metadata", {}) - finding_title = getattr( - check_metadata, - "CheckTitle", - getattr(finding_output, "check_id", ""), - ) - resource_name = getattr(finding_output, "resource_name", "") - if not resource_name: - resource_name = getattr(finding_output, "resource_uid", "") - severity = getattr(check_metadata, "Severity", "").capitalize() - finding_status = getattr(finding_output, "status", "").upper() - region = getattr(finding_output, "region", "global") - - findings_table_data.append( - [ - Paragraph(finding_title, normal_center), - Paragraph(resource_name, normal_center), - Paragraph(severity, normal_center), - Paragraph(finding_status, normal_center), - Paragraph(region, normal_center), - ] - ) - findings_table = Table( - findings_table_data, - colWidths=[ - 2.5 * inch, - 3 * inch, - 0.9 * inch, - 0.9 * inch, - 0.9 * inch, - ], - ) - findings_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - colors.Color(0.2, 0.4, 0.6), - ), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("ALIGN", (0, 0), (0, 0), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 9), - ( - "GRID", - (0, 0), - (-1, -1), - 0.1, - colors.Color(0.7, 0.8, 0.9), - ), - ("LEFTPADDING", (0, 0), (0, 0), 0), - ("RIGHTPADDING", (0, 0), (0, 0), 0), - ("TOPPADDING", (0, 0), (-1, -1), 4), - ("BOTTOMPADDING", (0, 0), (-1, -1), 4), - ] - ) - ) - elements.append(findings_table) - elements.append(Spacer(1, 0.1 * inch)) - - elements.append(PageBreak()) - - # Build the PDF - doc.build( - elements, - onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name), - onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name), - ) - except Exception as e: - tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown" - logger.info(f"Error building the document, line {tb_lineno} -- {e}") - raise e - - -def _create_nis2_section_chart( - requirements_list: list[dict], attributes_by_requirement_id: dict -) -> io.BytesIO: - """ - Create a horizontal bar chart showing compliance percentage by NIS2 section. - - Args: - requirements_list (list[dict]): List of requirement dictionaries with status and findings data. - attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes. - - Returns: - io.BytesIO: A BytesIO buffer containing the chart image in PNG format. - """ - # Initialize sections data - sections_data = defaultdict(lambda: {"passed": 0, "total": 0}) - - # Collect data from requirements - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - section_full = _safe_getattr(m, "Section", "") - - # Extract section number (e.g., "1" from "1 POLICY ON...") - section_number = section_full.split()[0] if section_full else "Unknown" - - # Get findings data - passed_findings = requirement["attributes"].get("passed_findings", 0) - total_findings = requirement["attributes"].get("total_findings", 0) - - if total_findings > 0: - sections_data[section_number]["passed"] += passed_findings - sections_data[section_number]["total"] += total_findings - - # Calculate percentages and prepare data for chart - section_names = [] - compliance_percentages = [] - - # Get section titles for display - section_titles = { - "1": "1. Policy on Security", - "2": "2. Risk Management", - "3": "3. Incident Handling", - "4": "4. Business Continuity", - "5": "5. Supply Chain", - "6": "6. Acquisition & Dev", - "7": "7. Effectiveness", - "9": "9. Cryptography", - "11": "11. Access Control", - "12": "12. Asset Management", - } - - # Sort by section number - for section_num in sorted( - sections_data.keys(), key=lambda x: int(x) if x.isdigit() else 999 - ): - data = sections_data[section_num] - if data["total"] > 0: - compliance_percentage = (data["passed"] / data["total"]) * 100 - else: - compliance_percentage = 100 # No findings = 100% (PASS) - - section_title = section_titles.get(section_num, f"{section_num}. Unknown") - section_names.append(section_title) - compliance_percentages.append(compliance_percentage) - - # Generate horizontal bar chart - fig, ax = plt.subplots(figsize=(10, 8)) - - # Use color helper for compliance percentage - colors_list = [_get_chart_color_for_percentage(p) for p in compliance_percentages] - - bars = ax.barh(section_names, compliance_percentages, color=colors_list) - - ax.set_xlabel("Compliance (%)", fontsize=12) - ax.set_xlim(0, 100) - - # Add percentage labels - for bar, percentage in zip(bars, compliance_percentages): - width = bar.get_width() - ax.text( - width + 1, - bar.get_y() + bar.get_height() / 2.0, - f"{percentage:.1f}%", - ha="left", - va="center", - fontweight="bold", - ) - - ax.grid(True, alpha=0.3, axis="x") - plt.tight_layout() - - buffer = io.BytesIO() - try: - fig.canvas.draw() - fig.savefig(buffer, format="png", dpi=300, bbox_inches="tight") - buffer.seek(0, io.SEEK_END) - finally: - plt.close(fig) - - return buffer - - -def _create_nis2_subsection_table( - requirements_list: list[dict], attributes_by_requirement_id: dict -) -> Table: - """ - Create a table showing compliance by subsection. - - Args: - requirements_list (list[dict]): List of requirement dictionaries. - attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes. - - Returns: - Table: A ReportLab table showing subsection breakdown. - """ - # Collect data by subsection - subsections_data = defaultdict(lambda: {"passed": 0, "failed": 0, "manual": 0}) - - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - subsection = _safe_getattr(m, "SubSection", "Unknown") - status = requirement["attributes"].get("status", StatusChoices.MANUAL) - - if status == StatusChoices.PASS: - subsections_data[subsection]["passed"] += 1 - elif status == StatusChoices.FAIL: - subsections_data[subsection]["failed"] += 1 - else: - subsections_data[subsection]["manual"] += 1 - - # Create table data - table_data = [["SubSection", "Total", "Pass", "Fail", "Manual", "Compliance %"]] - - for subsection in sorted(subsections_data.keys()): - data = subsections_data[subsection] - total = data["passed"] + data["failed"] + data["manual"] - compliance = ( - (data["passed"] / (data["passed"] + data["failed"]) * 100) - if (data["passed"] + data["failed"]) > 0 - else 100 - ) - - if len(subsection) > 100: - subsection = subsection[:80] + "..." - - table_data.append( - [ - subsection, # No truncate - let it wrap naturally - str(total), - str(data["passed"]), - str(data["failed"]), - str(data["manual"]), - f"{compliance:.1f}%", - ] - ) - - # Create table with wider SubSection column - table = Table( - table_data, - colWidths=[ - 4.5 * inch, - 0.6 * inch, - 0.6 * inch, - 0.6 * inch, - 0.7 * inch, - 1 * inch, - ], - ) - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY), - ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("ALIGN", (0, 1), (0, -1), "LEFT"), - ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"), - ("FONTSIZE", (0, 0), (-1, 0), 10), - ("FONTSIZE", (0, 1), (-1, -1), 9), - ("BOTTOMPADDING", (0, 0), (-1, 0), 8), - ("TOPPADDING", (0, 0), (-1, 0), 8), - ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), - ("ROWBACKGROUNDS", (0, 1), (-1, -1), [COLOR_WHITE, COLOR_NIS2_BG_BLUE]), - ] - ) - ) - - return table - - -def _create_nis2_requirements_index( - requirements_list: list[dict], attributes_by_requirement_id: dict, h2, h3, normal -) -> list: - """ - Create a hierarchical requirements index organized by Section and SubSection. - - Args: - requirements_list (list[dict]): List of requirement dictionaries. - attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes. - h2, h3, normal: Paragraph styles. - - Returns: - list: List of ReportLab elements for the index. - """ - elements = [] - - # Organize requirements by section and subsection - sections_hierarchy = defaultdict(lambda: defaultdict(list)) - - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - section = _safe_getattr(m, "Section", "Unknown") - subsection = _safe_getattr(m, "SubSection", "Unknown") - status = requirement["attributes"].get("status", StatusChoices.MANUAL) - - # Status indicator - if status == StatusChoices.PASS: - status_indicator = "βœ“" - elif status == StatusChoices.FAIL: - status_indicator = "βœ—" - else: - status_indicator = "βŠ™" - - description = requirement["attributes"].get( - "description", "No description available" - ) - sections_hierarchy[section][subsection].append( - { - "id": requirement_id, - "description": ( - description[:100] + "..." if len(description) > 100 else description - ), - "status_indicator": status_indicator, - } - ) - - # Build the index - for section in sorted(sections_hierarchy.keys()): - # Section header - elements.append(Paragraph(section, h2)) - - subsections = sections_hierarchy[section] - for subsection in sorted(subsections.keys()): - # Subsection header - elements.append(Paragraph(f" {subsection}", h3)) - - # Requirements - for req in subsections[subsection]: - req_text = ( - f" {req['status_indicator']} {req['id']} - {req['description']}" - ) - elements.append(Paragraph(req_text, normal)) - - elements.append(Spacer(1, 0.1 * inch)) - - return elements def generate_ens_report( @@ -1941,952 +71,37 @@ def generate_ens_report( output_path: str, provider_id: str, include_manual: bool = True, - provider_obj=None, + provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, ) -> None: """ Generate a PDF compliance report for ENS RD2022 framework. - This function creates a comprehensive PDF report containing: - - Compliance overview and metadata - - Executive summary with overall compliance score - - Marco/CategorΓ­a analysis with charts - - Security dimensions radar chart - - Requirement type distribution - - Execution mode distribution - - Critical failed requirements (nivel alto) - - Requirements index - - Detailed findings for failed and manual requirements - Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): ID of the scan executed by Prowler. - compliance_id (str): ID of the compliance framework (e.g., "ens_rd2022_aws"). - output_path (str): Output PDF file path (e.g., "/tmp/ens_report.pdf"). - provider_id (str): Provider ID for the scan. - include_manual (bool): If True, include requirements with manual execution mode - in the detailed requirements section. Defaults to True. - provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries. - If None, the provider will be fetched from the database. - requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid - duplicate database aggregations. If None, statistics will be aggregated from the database. - findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries. - If None, findings will be loaded from the database. When provided, reduces database - queries and transformation overhead when generating multiple reports. - - Raises: - Exception: If any error occurs during PDF generation, it will be logged and re-raised. + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the compliance framework (e.g., "ens_rd2022_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + include_manual: If True, include manual requirements in detailed section. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. """ - logger.info(f"Generating ENS report for scan {scan_id} with provider {provider_id}") - try: - # Get PDF styles - pdf_styles = _create_pdf_styles() - title_style = pdf_styles["title"] - h1 = pdf_styles["h1"] - h2 = pdf_styles["h2"] - h3 = pdf_styles["h3"] - normal = pdf_styles["normal"] - normal_center = pdf_styles["normal_center"] - - # Get compliance and provider information - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Use provided provider_obj or fetch from database - if provider_obj is None: - provider_obj = Provider.objects.get(id=provider_id) - - prowler_provider = initialize_prowler_provider(provider_obj) - provider_type = provider_obj.provider - - frameworks_bulk = Compliance.get_bulk(provider_type) - compliance_obj = frameworks_bulk[compliance_id] - compliance_framework = _safe_getattr(compliance_obj, "Framework") - compliance_version = _safe_getattr(compliance_obj, "Version") - compliance_name = _safe_getattr(compliance_obj, "Name") - compliance_description = _safe_getattr(compliance_obj, "Description", "") - - # Aggregate requirement statistics from database (memory-efficient) - # Use provided requirement_statistics or fetch from database - if requirement_statistics is None: - logger.info(f"Aggregating requirement statistics for scan {scan_id}") - requirement_statistics_by_check_id = ( - _aggregate_requirement_statistics_from_database(tenant_id, scan_id) - ) - else: - logger.info( - f"Reusing pre-aggregated requirement statistics for scan {scan_id}" - ) - requirement_statistics_by_check_id = requirement_statistics - - # Calculate requirements data using aggregated statistics - attributes_by_requirement_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - compliance_obj, requirement_statistics_by_check_id - ) - ) - - # Count manual requirements before filtering - manual_requirements_count = sum( - 1 - for req in requirements_list - if req["attributes"]["status"] == StatusChoices.MANUAL - ) - total_requirements_count = len(requirements_list) - - # Filter out manual requirements for the report - requirements_list = [ - req - for req in requirements_list - if req["attributes"]["status"] != StatusChoices.MANUAL - ] - - logger.info( - f"Filtered {manual_requirements_count} manual requirements out of {total_requirements_count} total requirements" - ) - - # Initialize PDF document - doc = SimpleDocTemplate( - output_path, - pagesize=letter, - title="Informe de Cumplimiento ENS - Prowler", - author="Prowler", - subject=f"Informe de Cumplimiento para {compliance_framework}", - creator="Prowler Engineering Team", - keywords=f"compliance,{compliance_framework},security,ens,prowler", - ) - - elements = [] - - # SECTION 1: PORTADA (Cover Page) - # Create logos side by side - prowler_logo_path = os.path.join( - os.path.dirname(__file__), "../assets/img/prowler_logo.png" - ) - ens_logo_path = os.path.join( - os.path.dirname(__file__), "../assets/img/ens_logo.png" - ) - - prowler_logo = Image( - prowler_logo_path, - width=3.5 * inch, - height=0.7 * inch, - ) - ens_logo = Image( - ens_logo_path, - width=1.5 * inch, - height=2 * inch, - ) - - # Create table with both logos - logos_table = Table( - [[prowler_logo, ens_logo]], colWidths=[4 * inch, 2.5 * inch] - ) - logos_table.setStyle( - TableStyle( - [ - ("ALIGN", (0, 0), (0, 0), "LEFT"), - ("ALIGN", (1, 0), (1, 0), "RIGHT"), - ("VALIGN", (0, 0), (0, 0), "MIDDLE"), # Prowler logo middle - ("VALIGN", (1, 0), (1, 0), "TOP"), # ENS logo top - ] - ) - ) - elements.append(logos_table) - elements.append(Spacer(1, 0.3 * inch)) - elements.append( - Paragraph("Informe de Cumplimiento ENS RD 311/2022", title_style) - ) - elements.append(Spacer(1, 0.5 * inch)) - - # Add compliance information table - provider_alias = provider_obj.alias or "N/A" - info_data = [ - ["Framework:", compliance_framework], - ["ID:", compliance_id], - ["Nombre:", Paragraph(compliance_name, normal_center)], - ["VersiΓ³n:", compliance_version], - ["Proveedor:", provider_type.upper()], - ["Account ID:", provider_obj.uid], - ["Alias:", provider_alias], - ["Scan ID:", scan_id], - ["DescripciΓ³n:", Paragraph(compliance_description, normal_center)], - ] - info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) - info_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, -1), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (0, -1), colors.white), - ("FONTNAME", (0, 0), (0, -1), "FiraCode"), - ("BACKGROUND", (1, 0), (1, -1), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (1, 0), (1, -1), colors.Color(0.2, 0.2, 0.2)), - ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("FONTSIZE", (0, 0), (-1, -1), 11), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)), - ("LEFTPADDING", (0, 0), (-1, -1), 10), - ("RIGHTPADDING", (0, 0), (-1, -1), 10), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ] - ) - ) - elements.append(info_table) - elements.append(Spacer(1, 0.5 * inch)) - - # Add warning about excluded manual requirements - warning_text = ( - f"AVISO: Este informe no incluye los requisitos de ejecuciΓ³n manual. " - f"El compliance {compliance_id} contiene un total de " - f"{manual_requirements_count} requisitos manuales que no han sido evaluados " - f"automΓ‘ticamente y por tanto no estΓ‘n reflejados en las estadΓ­sticas de este reporte. " - f"El anΓ‘lisis se basa ΓΊnicamente en los {len(requirements_list)} requisitos automatizados." - ) - warning_paragraph = Paragraph(warning_text, normal) - warning_table = Table([[warning_paragraph]], colWidths=[6 * inch]) - warning_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(1.0, 0.95, 0.7)), - ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.4, 0.3, 0.0)), - ("ALIGN", (0, 0), (0, 0), "LEFT"), - ("VALIGN", (0, 0), (0, 0), "MIDDLE"), - ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.9, 0.7, 0.0)), - ("LEFTPADDING", (0, 0), (-1, -1), 15), - ("RIGHTPADDING", (0, 0), (-1, -1), 15), - ("TOPPADDING", (0, 0), (-1, -1), 12), - ("BOTTOMPADDING", (0, 0), (-1, -1), 12), - ] - ) - ) - elements.append(warning_table) - elements.append(Spacer(1, 0.5 * inch)) - - # Add legend explaining ENS values - elements.append(Paragraph("Leyenda de Valores ENS", h2)) - elements.append(Spacer(1, 0.2 * inch)) - - legend_text = """ - Nivel (Criticidad del requisito):
- β€’ Alto: Requisitos crΓ­ticos que deben cumplirse prioritariamente
- β€’ Medio: Requisitos importantes con impacto moderado
- β€’ Bajo: Requisitos complementarios de menor criticidad
- β€’ Opcional: Recomendaciones adicionales no obligatorias
-
- Tipo (ClasificaciΓ³n del requisito):
- β€’ Requisito: ObligaciΓ³n establecida por el ENS
- β€’ Refuerzo: Medida adicional que refuerza un requisito
- β€’ RecomendaciΓ³n: Buena prΓ‘ctica sugerida
- β€’ Medida: AcciΓ³n concreta de implementaciΓ³n
-
- Modo de EjecuciΓ³n:
- β€’ AutomΓ‘tico: El requisito puede verificarse automΓ‘ticamente mediante escaneo
- β€’ Manual: Requiere verificaciΓ³n manual por parte de un auditor
-
- Dimensiones de Seguridad:
- β€’ C (Confidencialidad): ProtecciΓ³n contra accesos no autorizados a la informaciΓ³n
- β€’ I (Integridad): GarantΓ­a de exactitud y completitud de la informaciΓ³n
- β€’ T (Trazabilidad): Capacidad de rastrear acciones y eventos
- β€’ A (Autenticidad): VerificaciΓ³n de identidad de usuarios y sistemas
- β€’ D (Disponibilidad): Acceso a la informaciΓ³n cuando se necesita
-
- Estados de Cumplimiento:
- β€’ CUMPLE (PASS): El requisito se cumple satisfactoriamente
- β€’ NO CUMPLE (FAIL): El requisito no se cumple y requiere correcciΓ³n
- β€’ MANUAL: Requiere revisiΓ³n manual para determinar cumplimiento - """ - legend_paragraph = Paragraph(legend_text, normal) - legend_table = Table([[legend_paragraph]], colWidths=[6.5 * inch]) - legend_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.2, 0.2, 0.2)), - ("ALIGN", (0, 0), (0, 0), "LEFT"), - ("VALIGN", (0, 0), (0, 0), "TOP"), - ("BOX", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.8)), - ("LEFTPADDING", (0, 0), (-1, -1), 15), - ("RIGHTPADDING", (0, 0), (-1, -1), 15), - ("TOPPADDING", (0, 0), (-1, -1), 12), - ("BOTTOMPADDING", (0, 0), (-1, -1), 12), - ] - ) - ) - elements.append(legend_table) - elements.append(PageBreak()) - - # SECTION 2: RESUMEN EJECUTIVO (Executive Summary) - elements.append(Paragraph("Resumen Ejecutivo", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - # Calculate overall compliance (simple PASS/TOTAL) - total_requirements = len(requirements_list) - passed_requirements = sum( - 1 - for req in requirements_list - if req["attributes"]["status"] == StatusChoices.PASS - ) - failed_requirements = sum( - 1 - for req in requirements_list - if req["attributes"]["status"] == StatusChoices.FAIL - ) - - overall_compliance = ( - (passed_requirements / total_requirements * 100) - if total_requirements > 0 - else 0 - ) - - if overall_compliance >= 80: - compliance_color = colors.Color(0.2, 0.8, 0.2) - elif overall_compliance >= 60: - compliance_color = colors.Color(0.8, 0.8, 0.2) - else: - compliance_color = colors.Color(0.8, 0.2, 0.2) - - summary_data = [ - ["Nivel de Cumplimiento Global:", f"{overall_compliance:.2f}%"], - ] - - summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) - summary_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), - ("TEXTCOLOR", (0, 0), (0, 0), colors.white), - ("FONTNAME", (0, 0), (0, 0), "FiraCode"), - ("FONTSIZE", (0, 0), (0, 0), 12), - ("BACKGROUND", (1, 0), (1, 0), compliance_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("FONTSIZE", (1, 0), (1, 0), 16), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 12), - ("RIGHTPADDING", (0, 0), (-1, -1), 12), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ] - ) - ) - elements.append(summary_table) - elements.append(Spacer(1, 0.3 * inch)) - - # Summary counts table - counts_data = [ - ["Estado", "Cantidad", "Porcentaje"], - [ - "CUMPLE", - str(passed_requirements), - ( - f"{(passed_requirements / total_requirements * 100):.1f}%" - if total_requirements > 0 - else "0.0%" - ), - ], - [ - "NO CUMPLE", - str(failed_requirements), - ( - f"{(failed_requirements / total_requirements * 100):.1f}%" - if total_requirements > 0 - else "0.0%" - ), - ], - ["TOTAL", str(total_requirements), "100%"], - ] - - counts_table = Table(counts_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch]) - counts_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("BACKGROUND", (0, 1), (0, 1), colors.Color(0.2, 0.8, 0.2)), - ("TEXTCOLOR", (0, 1), (0, 1), colors.white), - ("BACKGROUND", (0, 2), (0, 2), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 2), (0, 2), colors.white), - ("BACKGROUND", (0, 3), (0, 3), colors.Color(0.4, 0.4, 0.4)), - ("TEXTCOLOR", (0, 3), (0, 3), colors.white), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 6), - ("BOTTOMPADDING", (0, 0), (-1, -1), 6), - ] - ) - ) - elements.append(counts_table) - elements.append(Spacer(1, 0.3 * inch)) - - # Summary by Nivel - nivel_data = defaultdict(lambda: {"passed": 0, "total": 0}) - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - requirement_status = requirement["attributes"]["status"] - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - nivel = _safe_getattr(m, "Nivel") - nivel_data[nivel]["total"] += 1 - if requirement_status == StatusChoices.PASS: - nivel_data[nivel]["passed"] += 1 - - elements.append(Paragraph("Cumplimiento por Nivel", h2)) - nivel_table_data = [["Nivel", "Cumplidos", "Total", "Porcentaje"]] - for nivel in ENS_NIVEL_ORDER: - if nivel in nivel_data: - data = nivel_data[nivel] - percentage = ( - (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0 - ) - nivel_table_data.append( - [ - nivel.capitalize(), - str(data["passed"]), - str(data["total"]), - f"{percentage:.1f}%", - ] - ) - - nivel_table = Table( - nivel_table_data, colWidths=[1.5 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] - ) - nivel_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 6), - ("BOTTOMPADDING", (0, 0), (-1, -1), 6), - ] - ) - ) - elements.append(nivel_table) - elements.append(PageBreak()) - - # SECTION 3: ANÁLISIS POR MARCOS (Marco Analysis) - elements.append(Paragraph("AnΓ‘lisis por Marcos y CategorΓ­as", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - chart_buffer = _create_marco_category_chart( - requirements_list, attributes_by_requirement_id - ) - chart_image = Image(chart_buffer, width=7 * inch, height=5 * inch) - elements.append(chart_image) - elements.append(PageBreak()) - - # SECTION 4: DIMENSIONES DE SEGURIDAD (Security Dimensions) - elements.append(Paragraph("AnΓ‘lisis por Dimensiones de Seguridad", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - radar_buffer = _create_dimensions_radar_chart( - requirements_list, attributes_by_requirement_id - ) - radar_image = Image(radar_buffer, width=6 * inch, height=6 * inch) - elements.append(radar_image) - elements.append(PageBreak()) - - # SECTION 5: DISTRIBUCIΓ“N POR TIPO (Type Distribution) - elements.append(Paragraph("DistribuciΓ³n por Tipo de Requisito", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - tipo_data = defaultdict(lambda: {"passed": 0, "total": 0}) - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - requirement_status = requirement["attributes"]["status"] - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if not metadata: - continue - - m = metadata[0] - tipo = _safe_getattr(m, "Tipo") - tipo_data[tipo]["total"] += 1 - if requirement_status == StatusChoices.PASS: - tipo_data[tipo]["passed"] += 1 - - tipo_table_data = [["Tipo", "Cumplidos", "Total", "Porcentaje"]] - for tipo in ENS_TIPO_ORDER: - if tipo in tipo_data: - data = tipo_data[tipo] - percentage = ( - (data["passed"] / data["total"] * 100) if data["total"] > 0 else 0 - ) - tipo_table_data.append( - [ - tipo.capitalize(), - str(data["passed"]), - str(data["total"]), - f"{percentage:.1f}%", - ] - ) - - tipo_table = Table( - tipo_table_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] - ) - tipo_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 6), - ("BOTTOMPADDING", (0, 0), (-1, -1), 6), - ] - ) - ) - elements.append(tipo_table) - elements.append(PageBreak()) - - # SECTION 6: REQUISITOS CRÍTICOS NO CUMPLIDOS (Critical Failed Requirements) - elements.append(Paragraph("Requisitos CrΓ­ticos No Cumplidos", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - critical_failed = [] - for requirement in requirements_list: - requirement_status = requirement["attributes"]["status"] - if requirement_status == StatusChoices.FAIL: - requirement_id = requirement["id"] - req_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ).get("attributes", {}) - metadata_list = req_attributes.get("req_attributes", []) - if metadata_list: - metadata = metadata_list[0] - nivel = _safe_getattr(metadata, "Nivel", "") - if nivel.lower() == "alto": - critical_failed.append( - { - "requirement": requirement, - "metadata": metadata, - } - ) - - if not critical_failed: - elements.append( - Paragraph( - "βœ… No se encontraron requisitos crΓ­ticos no cumplidos.", normal - ) - ) - else: - elements.append( - Paragraph( - f"Se encontraron {len(critical_failed)} requisitos de nivel Alto que no cumplen:", - normal, - ) - ) - elements.append(Spacer(1, 0.3 * inch)) - - critical_table_data = [["ID", "DescripciΓ³n", "Marco", "CategorΓ­a"]] - for item in critical_failed: - requirement_id = item["requirement"]["id"] - description = item["requirement"]["attributes"]["description"] - marco = _safe_getattr(item["metadata"], "Marco") - categoria = _safe_getattr(item["metadata"], "Categoria") - - if len(description) > 60: - description = description[:57] + "..." - - critical_table_data.append( - [requirement_id, description, marco, categoria] - ) - - critical_table = Table( - critical_table_data, - colWidths=[1.5 * inch, 3.3 * inch, 1.5 * inch, 2 * inch], - ) - critical_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("FONTSIZE", (0, 0), (-1, 0), 9), - ("FONTNAME", (0, 1), (0, -1), "FiraCode"), - ("FONTSIZE", (0, 1), (-1, -1), 8), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 6), - ("RIGHTPADDING", (0, 0), (-1, -1), 6), - ("TOPPADDING", (0, 0), (-1, -1), 6), - ("BOTTOMPADDING", (0, 0), (-1, -1), 6), - ( - "BACKGROUND", - (1, 1), - (-1, -1), - colors.Color(0.98, 0.98, 0.98), - ), - ] - ) - ) - elements.append(critical_table) - - elements.append(PageBreak()) - - # SECTION 7: ÍNDICE DE REQUISITOS (Requirements Index) - elements.append(Paragraph("Índice de Requisitos", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - # Group by Marco β†’ CategorΓ­a - marco_categoria_index = defaultdict(lambda: defaultdict(list)) - for ( - requirement_id, - requirement_attributes, - ) in attributes_by_requirement_id.items(): - metadata = requirement_attributes["attributes"]["req_attributes"][0] - marco = getattr(metadata, "Marco", "N/A") - categoria = getattr(metadata, "Categoria", "N/A") - id_grupo = getattr(metadata, "IdGrupoControl", "N/A") - - marco_categoria_index[marco][categoria].append( - { - "id": requirement_id, - "id_grupo": id_grupo, - "description": requirement_attributes["description"], - } - ) - - for marco, categorias in sorted(marco_categoria_index.items()): - elements.append(Paragraph(f"Marco: {marco.capitalize()}", h2)) - for categoria, requirements in sorted(categorias.items()): - elements.append(Paragraph(f"CategorΓ­a: {categoria.capitalize()}", h3)) - for req in requirements: - desc = req["description"] - if len(desc) > 80: - desc = desc[:77] + "..." - elements.append(Paragraph(f"{req['id']} - {desc}", normal)) - elements.append(Spacer(1, 0.05 * inch)) - - elements.append(PageBreak()) - - # SECTION 8: DETALLE DE REQUISITOS (Detailed Requirements) - elements.append(Paragraph("Detalle de Requisitos", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - # Filter: NO CUMPLE + MANUAL (if include_manual) - filtered_requirements = [ - req - for req in requirements_list - if req["attributes"]["status"] == StatusChoices.FAIL - or (include_manual and req["attributes"]["status"] == StatusChoices.MANUAL) - ] - - if not filtered_requirements: - elements.append( - Paragraph("βœ… Todos los requisitos automΓ‘ticos cumplen.", normal) - ) - else: - elements.append( - Paragraph( - f"Se muestran {len(filtered_requirements)} requisitos que requieren atenciΓ³n:", - normal, - ) - ) - elements.append(Spacer(1, 0.2 * inch)) - - # Collect check IDs to load - check_ids_to_load = [] - for requirement in filtered_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - check_ids = requirement_attributes.get("attributes", {}).get( - "checks", [] - ) - check_ids_to_load.extend(check_ids) - - # Load findings on-demand - logger.info( - f"Loading findings on-demand for {len(filtered_requirements)} requirements" - ) - findings_by_check_id = _load_findings_for_requirement_checks( - tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache - ) - - for requirement in filtered_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - requirement_status = requirement["attributes"]["status"] - requirement_description = requirement_attributes.get("description", "") - - # Requirement ID header in a box - req_id_paragraph = Paragraph(requirement_id, h2) - req_id_table = Table([[req_id_paragraph]], colWidths=[6.5 * inch]) - req_id_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (0, 0), - colors.Color(0.15, 0.35, 0.55), - ), - ("TEXTCOLOR", (0, 0), (0, 0), colors.white), - ("ALIGN", (0, 0), (0, 0), "CENTER"), - ("VALIGN", (0, 0), (0, 0), "MIDDLE"), - ("LEFTPADDING", (0, 0), (-1, -1), 15), - ("RIGHTPADDING", (0, 0), (-1, -1), 15), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.2, 0.4, 0.6)), - ] - ) - ) - elements.append(req_id_table) - elements.append(Spacer(1, 0.15 * inch)) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata and len(metadata) > 0: - m = metadata[0] - - # Create all badges - status_component = _create_status_component(requirement_status) - nivel = getattr(m, "Nivel", "N/A") - nivel_badge = _create_ens_nivel_badge(nivel) - tipo = getattr(m, "Tipo", "N/A") - tipo_badge = _create_ens_tipo_badge(tipo) - - # Organize badges in a horizontal table (2 rows x 2 cols) - badges_table = Table( - [[status_component, nivel_badge], [tipo_badge]], - colWidths=[3.25 * inch, 3.25 * inch], - ) - badges_table.setStyle( - TableStyle( - [ - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("LEFTPADDING", (0, 0), (-1, -1), 5), - ("RIGHTPADDING", (0, 0), (-1, -1), 5), - ("TOPPADDING", (0, 0), (-1, -1), 5), - ("BOTTOMPADDING", (0, 0), (-1, -1), 5), - ] - ) - ) - elements.append(badges_table) - elements.append(Spacer(1, 0.15 * inch)) - - # Dimensiones badges (if present) - dimensiones = getattr(m, "Dimensiones", []) - if dimensiones: - dim_label = Paragraph("Dimensiones:", normal) - dim_badges = _create_ens_dimension_badges(dimensiones) - dim_table = Table( - [[dim_label, dim_badges]], colWidths=[1.5 * inch, 5 * inch] - ) - dim_table.setStyle( - TableStyle( - [ - ("ALIGN", (0, 0), (0, 0), "LEFT"), - ("ALIGN", (1, 0), (1, 0), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ] - ) - ) - elements.append(dim_table) - elements.append(Spacer(1, 0.15 * inch)) - - # Requirement details in a clean table - details_data = [ - ["DescripciΓ³n:", Paragraph(requirement_description, normal)], - ["Marco:", Paragraph(getattr(m, "Marco", "N/A"), normal)], - [ - "CategorΓ­a:", - Paragraph(getattr(m, "Categoria", "N/A"), normal), - ], - [ - "ID Grupo Control:", - Paragraph(getattr(m, "IdGrupoControl", "N/A"), normal), - ], - [ - "DescripciΓ³n del Control:", - Paragraph(getattr(m, "DescripcionControl", "N/A"), normal), - ], - ] - details_table = Table( - details_data, colWidths=[2.2 * inch, 4.5 * inch] - ) - details_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (0, -1), - colors.Color(0.9, 0.93, 0.96), - ), - ( - "TEXTCOLOR", - (0, 0), - (0, -1), - colors.Color(0.2, 0.2, 0.2), - ), - ("FONTNAME", (0, 0), (0, -1), "FiraCode"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("ALIGN", (0, 0), (0, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ( - "GRID", - (0, 0), - (-1, -1), - 0.5, - colors.Color(0.7, 0.8, 0.9), - ), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 6), - ("BOTTOMPADDING", (0, 0), (-1, -1), 6), - ] - ) - ) - elements.append(details_table) - elements.append(Spacer(1, 0.2 * inch)) - - # Findings for checks - requirement_check_ids = requirement_attributes.get( - "attributes", {} - ).get("checks", []) - for check_id in requirement_check_ids: - elements.append(Paragraph(f"Check: {check_id}", h2)) - elements.append(Spacer(1, 0.1 * inch)) - - check_findings = findings_by_check_id.get(check_id, []) - - if not check_findings: - elements.append( - Paragraph( - "- No hay informaciΓ³n disponible para este check", - normal, - ) - ) - else: - findings_table_data = [ - ["Finding", "Resource name", "Severity", "Status", "Region"] - ] - for finding_output in check_findings: - check_metadata = getattr(finding_output, "metadata", {}) - finding_title = getattr( - check_metadata, - "CheckTitle", - getattr(finding_output, "check_id", ""), - ) - resource_name = getattr(finding_output, "resource_name", "") - if not resource_name: - resource_name = getattr( - finding_output, "resource_uid", "" - ) - severity = getattr( - check_metadata, "Severity", "" - ).capitalize() - finding_status = getattr( - finding_output, "status", "" - ).upper() - region = getattr(finding_output, "region", "global") - - findings_table_data.append( - [ - Paragraph(finding_title, normal_center), - Paragraph(resource_name, normal_center), - Paragraph(severity, normal_center), - Paragraph(finding_status, normal_center), - Paragraph(region, normal_center), - ] - ) - - findings_table = Table( - findings_table_data, - colWidths=[ - 2.5 * inch, - 3 * inch, - 0.9 * inch, - 0.9 * inch, - 0.9 * inch, - ], - ) - findings_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - colors.Color(0.2, 0.4, 0.6), - ), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("ALIGN", (0, 0), (0, 0), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 9), - ( - "GRID", - (0, 0), - (-1, -1), - 0.1, - colors.Color(0.7, 0.8, 0.9), - ), - ("LEFTPADDING", (0, 0), (0, 0), 0), - ("RIGHTPADDING", (0, 0), (0, 0), 0), - ("TOPPADDING", (0, 0), (-1, -1), 4), - ("BOTTOMPADDING", (0, 0), (-1, -1), 4), - ] - ) - ) - elements.append(findings_table) - - elements.append(Spacer(1, 0.1 * inch)) - - elements.append(PageBreak()) - - # Build the PDF - logger.info("Building PDF...") - doc.build( - elements, - onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name), - onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name), - ) - except Exception as e: - tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown" - logger.error(f"Error building ENS report, line {tb_lineno} -- {e}") - raise e + generator = ENSReportGenerator(FRAMEWORK_REGISTRY["ens"]) + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + include_manual=include_manual, + ) def generate_nis2_report( @@ -2897,552 +112,39 @@ def generate_nis2_report( provider_id: str, only_failed: bool = True, include_manual: bool = False, - provider_obj=None, + provider_obj: Provider | None = None, requirement_statistics: dict[str, dict[str, int]] | None = None, findings_cache: dict[str, list[FindingOutput]] | None = None, ) -> None: """ Generate a PDF compliance report for NIS2 Directive (EU) 2022/2555. - This function creates a comprehensive PDF report containing: - - Compliance overview and metadata - - Executive summary with overall compliance score - - Section analysis with horizontal bar chart - - SubSection breakdown table - - Critical failed requirements - - Requirements index organized by section and subsection - - Detailed findings for failed requirements - Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): ID of the scan executed by Prowler. - compliance_id (str): ID of the compliance framework (e.g., "nis2_aws"). - output_path (str): Output PDF file path (e.g., "/tmp/nis2_report.pdf"). - provider_id (str): Provider ID for the scan. - only_failed (bool): If True, only requirements with status "FAIL" will be included - in the detailed requirements section. Defaults to True. - include_manual (bool): If True, includes MANUAL requirements in the detailed findings - section along with FAIL requirements. Defaults to True. - provider_obj (Provider, optional): Pre-fetched Provider object to avoid duplicate queries. - If None, the provider will be fetched from the database. - requirement_statistics (dict, optional): Pre-aggregated requirement statistics to avoid - duplicate database aggregations. If None, statistics will be aggregated from the database. - findings_cache (dict, optional): Cache of already loaded findings to avoid duplicate queries. - If None, findings will be loaded from the database. - - Raises: - Exception: If any error occurs during PDF generation, it will be logged and re-raised. + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the compliance framework (e.g., "nis2_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + only_failed: If True, only include failed requirements in detailed section. + include_manual: If True, include manual requirements in detailed section. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. """ - logger.info( - f"Generating NIS2 report for scan {scan_id} with provider {provider_id}" + generator = NIS2ReportGenerator(FRAMEWORK_REGISTRY["nis2"]) + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + only_failed=only_failed, + include_manual=include_manual, ) - try: - # Get PDF styles - pdf_styles = _create_pdf_styles() - title_style = pdf_styles["title"] - h1 = pdf_styles["h1"] - h2 = pdf_styles["h2"] - h3 = pdf_styles["h3"] - normal = pdf_styles["normal"] - normal_center = pdf_styles["normal_center"] - - # Get compliance and provider information - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Use provided provider_obj or fetch from database - if provider_obj is None: - provider_obj = Provider.objects.get(id=provider_id) - - prowler_provider = initialize_prowler_provider(provider_obj) - provider_type = provider_obj.provider - - frameworks_bulk = Compliance.get_bulk(provider_type) - compliance_obj = frameworks_bulk[compliance_id] - compliance_framework = _safe_getattr(compliance_obj, "Framework") - compliance_version = _safe_getattr(compliance_obj, "Version") - compliance_name = _safe_getattr(compliance_obj, "Name") - compliance_description = _safe_getattr(compliance_obj, "Description", "") - - # Aggregate requirement statistics from database - if requirement_statistics is None: - logger.info(f"Aggregating requirement statistics for scan {scan_id}") - requirement_statistics_by_check_id = ( - _aggregate_requirement_statistics_from_database(tenant_id, scan_id) - ) - else: - logger.info( - f"Reusing pre-aggregated requirement statistics for scan {scan_id}" - ) - requirement_statistics_by_check_id = requirement_statistics - - # Calculate requirements data using aggregated statistics - attributes_by_requirement_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - compliance_obj, requirement_statistics_by_check_id - ) - ) - - # Initialize PDF document - doc = SimpleDocTemplate( - output_path, - pagesize=letter, - title="NIS2 Compliance Report - Prowler", - author="Prowler", - subject=f"Compliance Report for {compliance_framework}", - creator="Prowler Engineering Team", - keywords=f"compliance,{compliance_framework},security,nis2,prowler,eu", - ) - - elements = [] - - # SECTION 1: Cover Page - # Create logos side by side - prowler_logo_path = os.path.join( - os.path.dirname(__file__), "../assets/img/prowler_logo.png" - ) - nis2_logo_path = os.path.join( - os.path.dirname(__file__), "../assets/img/nis2_logo.png" - ) - - prowler_logo = Image( - prowler_logo_path, - width=3.5 * inch, - height=0.7 * inch, - ) - nis2_logo = Image( - nis2_logo_path, - width=2.3 * inch, - height=1.5 * inch, - ) - - # Create table with both logos - logos_table = Table( - [[prowler_logo, nis2_logo]], colWidths=[4 * inch, 2.5 * inch] - ) - logos_table.setStyle( - TableStyle( - [ - ("ALIGN", (0, 0), (0, 0), "LEFT"), - ("ALIGN", (1, 0), (1, 0), "RIGHT"), - ("VALIGN", (0, 0), (0, 0), "MIDDLE"), # Prowler logo middle - ("VALIGN", (1, 0), (1, 0), "MIDDLE"), # NIS2 logo middle - ] - ) - ) - elements.append(logos_table) - elements.append(Spacer(1, 0.3 * inch)) - - # Title - title = Paragraph( - "NIS2 Compliance Report
Directive (EU) 2022/2555", - title_style, - ) - elements.append(title) - elements.append(Spacer(1, 0.3 * inch)) - - # Compliance metadata table - provider_alias = provider_obj.alias or "N/A" - metadata_data = [ - ["Framework:", compliance_framework], - ["Name:", Paragraph(compliance_name, normal_center)], - ["Version:", compliance_version or "N/A"], - ["Provider:", provider_type.upper()], - ["Account ID:", provider_obj.uid], - ["Alias:", provider_alias], - ["Scan ID:", scan_id], - ["Description:", Paragraph(compliance_description, normal_center)], - ] - - metadata_table = Table(metadata_data, colWidths=[COL_WIDTH_XLARGE, 4 * inch]) - metadata_table.setStyle(_create_info_table_style()) - elements.append(metadata_table) - elements.append(PageBreak()) - - # SECTION 2: Executive Summary - elements.append(Paragraph("Executive Summary", h1)) - elements.append(Spacer(1, 0.1 * inch)) - - # Calculate overall statistics - total_requirements = len(requirements_list) - passed_requirements = sum( - 1 - for req in requirements_list - if req["attributes"].get("status") == StatusChoices.PASS - ) - failed_requirements = sum( - 1 - for req in requirements_list - if req["attributes"].get("status") == StatusChoices.FAIL - ) - manual_requirements = sum( - 1 - for req in requirements_list - if req["attributes"].get("status") == StatusChoices.MANUAL - ) - - overall_compliance = ( - (passed_requirements / (passed_requirements + failed_requirements) * 100) - if (passed_requirements + failed_requirements) > 0 - else 100 - ) - - # Summary statistics table - summary_data = [ - ["Metric", "Value"], - ["Total Requirements", str(total_requirements)], - ["Passed βœ“", str(passed_requirements)], - ["Failed βœ—", str(failed_requirements)], - ["Manual βŠ™", str(manual_requirements)], - ["Overall Compliance", f"{overall_compliance:.1f}%"], - ] - - summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) - summary_table.setStyle( - TableStyle( - [ - # Header row - ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY), - ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), - # Status-specific colors for left column - ("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE), # Passed row - ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE), - ("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK), # Failed row - ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE), - ("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY), # Manual row - ("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE), - # General styling - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"), - ("FONTSIZE", (0, 0), (-1, 0), 12), - ("FONTSIZE", (0, 1), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, 0), 10), - ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), - # Alternating backgrounds for right column - ( - "ROWBACKGROUNDS", - (1, 1), - (1, -1), - [COLOR_WHITE, COLOR_NIS2_BG_BLUE], - ), - ] - ) - ) - elements.append(summary_table) - elements.append(PageBreak()) - - # SECTION 3: Compliance by Section Analysis - elements.append(Paragraph("Compliance by Section", h1)) - elements.append(Spacer(1, 0.1 * inch)) - - elements.append( - Paragraph( - "The following chart shows compliance percentage for each main section of the NIS2 directive:", - normal_center, - ) - ) - elements.append(Spacer(1, 0.1 * inch)) - - # Create section chart - section_chart_buffer = _create_nis2_section_chart( - requirements_list, attributes_by_requirement_id - ) - section_chart_buffer.seek(0) - section_chart = Image(section_chart_buffer, width=6.5 * inch, height=5 * inch) - elements.append(section_chart) - elements.append(PageBreak()) - - # SECTION 4: SubSection Breakdown - elements.append(Paragraph("SubSection Breakdown", h1)) - elements.append(Spacer(1, 0.1 * inch)) - - subsection_table = _create_nis2_subsection_table( - requirements_list, attributes_by_requirement_id - ) - elements.append(subsection_table) - elements.append(PageBreak()) - - # SECTION 5: Requirements Index - elements.append(Paragraph("Requirements Index", h1)) - elements.append(Spacer(1, 0.1 * inch)) - - index_elements = _create_nis2_requirements_index( - requirements_list, attributes_by_requirement_id, h2, h3, normal - ) - elements.extend(index_elements) - elements.append(PageBreak()) - - # SECTION 6: Detailed Findings - elements.append(Paragraph("Detailed Findings", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - # Filter requirements for detailed findings (FAIL + MANUAL if include_manual) - filtered_requirements = [ - req - for req in requirements_list - if req["attributes"]["status"] == StatusChoices.FAIL - or (include_manual and req["attributes"]["status"] == StatusChoices.MANUAL) - ] - - if not filtered_requirements: - elements.append( - Paragraph("βœ… All automatic requirements are compliant.", normal) - ) - else: - elements.append( - Paragraph( - f"Showing {len(filtered_requirements)} requirements that need attention:", - normal, - ) - ) - elements.append(Spacer(1, 0.2 * inch)) - - # Collect check IDs to load - check_ids_to_load = [] - for requirement in filtered_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - check_ids = requirement_attributes.get("attributes", {}).get( - "checks", [] - ) - check_ids_to_load.extend(check_ids) - - # Load findings on-demand - logger.info( - f"Loading findings on-demand for {len(filtered_requirements)} NIS2 requirements" - ) - findings_by_check_id = _load_findings_for_requirement_checks( - tenant_id, scan_id, check_ids_to_load, prowler_provider, findings_cache - ) - - for requirement in filtered_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - requirement_status = requirement["attributes"]["status"] - requirement_description = requirement_attributes.get("description", "") - - # Requirement ID header in a box - req_id_paragraph = Paragraph(f"Requirement: {requirement_id}", h2) - req_id_table = Table([[req_id_paragraph]], colWidths=[6.5 * inch]) - req_id_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), COLOR_NIS2_PRIMARY), - ("TEXTCOLOR", (0, 0), (0, 0), colors.white), - ("ALIGN", (0, 0), (0, 0), "CENTER"), - ("VALIGN", (0, 0), (0, 0), "MIDDLE"), - ("LEFTPADDING", (0, 0), (-1, -1), 15), - ("RIGHTPADDING", (0, 0), (-1, -1), 15), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ("BOX", (0, 0), (-1, -1), 2, COLOR_NIS2_SECONDARY), - ] - ) - ) - elements.append(req_id_table) - elements.append(Spacer(1, 0.15 * inch)) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata: - m = metadata[0] - section = _safe_getattr(m, "Section", "Unknown") - subsection = _safe_getattr(m, "SubSection", "Unknown") - service = _safe_getattr(m, "Service", "generic") - - # Status badge - status_text = ( - "βœ“ PASS" - if requirement_status == StatusChoices.PASS - else ( - "βœ— FAIL" - if requirement_status == StatusChoices.FAIL - else "βŠ™ MANUAL" - ) - ) - status_color = ( - COLOR_SAFE - if requirement_status == StatusChoices.PASS - else ( - COLOR_HIGH_RISK - if requirement_status == StatusChoices.FAIL - else COLOR_DARK_GRAY - ) - ) - - status_badge = Paragraph( - f"{status_text}", - ParagraphStyle( - "status_badge", - parent=normal, - alignment=1, - textColor=colors.white, - fontSize=14, - ), - ) - status_table = Table([[status_badge]], colWidths=[6.5 * inch]) - status_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), status_color), - ("ALIGN", (0, 0), (0, 0), "CENTER"), - ("VALIGN", (0, 0), (0, 0), "MIDDLE"), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ] - ) - ) - elements.append(status_table) - elements.append(Spacer(1, 0.15 * inch)) - - # Requirement details table - details_data = [ - [ - "Description:", - Paragraph(requirement_description, normal_center), - ], - ["Section:", Paragraph(section, normal_center)], - ["SubSection:", Paragraph(subsection, normal_center)], - ["Service:", service], - ] - details_table = Table( - details_data, colWidths=[2.2 * inch, 4.5 * inch] - ) - details_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (0, -1), - COLOR_NIS2_BG_BLUE, - ), - ("TEXTCOLOR", (0, 0), (0, -1), COLOR_GRAY), - ("FONTNAME", (0, 0), (0, -1), "FiraCode"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("ALIGN", (0, 0), (0, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 6), - ("BOTTOMPADDING", (0, 0), (-1, -1), 6), - ] - ) - ) - elements.append(details_table) - elements.append(Spacer(1, 0.2 * inch)) - - # Findings for checks - requirement_check_ids = requirement_attributes.get( - "attributes", {} - ).get("checks", []) - for check_id in requirement_check_ids: - elements.append(Paragraph(f"Check: {check_id}", h3)) - elements.append(Spacer(1, 0.1 * inch)) - - check_findings = findings_by_check_id.get(check_id, []) - - if not check_findings: - elements.append( - Paragraph( - "- No information available for this check", normal - ) - ) - else: - findings_table_data = [ - ["Finding", "Resource name", "Severity", "Status", "Region"] - ] - for finding_output in check_findings: - check_metadata = getattr(finding_output, "metadata", {}) - finding_title = getattr( - check_metadata, - "CheckTitle", - getattr(finding_output, "check_id", ""), - ) - resource_name = getattr(finding_output, "resource_name", "") - if not resource_name: - resource_name = getattr( - finding_output, "resource_uid", "" - ) - severity = getattr( - check_metadata, "Severity", "" - ).capitalize() - finding_status = getattr( - finding_output, "status", "" - ).upper() - region = getattr(finding_output, "region", "global") - - findings_table_data.append( - [ - Paragraph(finding_title, normal_center), - Paragraph(resource_name, normal_center), - Paragraph(severity, normal_center), - Paragraph(finding_status, normal_center), - Paragraph(region, normal_center), - ] - ) - - findings_table = Table( - findings_table_data, - colWidths=[ - 2.5 * inch, - 3 * inch, - 0.9 * inch, - 0.9 * inch, - 0.9 * inch, - ], - ) - findings_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - COLOR_NIS2_PRIMARY, - ), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("ALIGN", (0, 0), (0, 0), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 9), - ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), - ( - "ROWBACKGROUNDS", - (0, 1), - (-1, -1), - [colors.white, COLOR_NIS2_BG_BLUE], - ), - ("LEFTPADDING", (0, 0), (-1, -1), 5), - ("RIGHTPADDING", (0, 0), (-1, -1), 5), - ("TOPPADDING", (0, 0), (-1, -1), 5), - ("BOTTOMPADDING", (0, 0), (-1, -1), 5), - ] - ) - ) - elements.append(findings_table) - - elements.append(Spacer(1, 0.15 * inch)) - - elements.append(Spacer(1, 0.2 * inch)) - - # Build the PDF - logger.info("Building NIS2 PDF...") - doc.build( - elements, - onFirstPage=partial(_add_pdf_footer, compliance_name=compliance_name), - onLaterPages=partial(_add_pdf_footer, compliance_name=compliance_name), - ) - logger.info(f"NIS2 report successfully generated at {output_path}") - - except Exception as e: - tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown" - logger.error(f"Error building NIS2 report, line {tb_lineno} -- {e}") - raise e def generate_compliance_reports( @@ -3459,58 +161,45 @@ def generate_compliance_reports( only_failed_nis2: bool = True, ) -> dict[str, dict[str, bool | str]]: """ - Generate multiple compliance reports (ThreatScore, ENS, and/or NIS2) with shared database queries. + Generate multiple compliance reports with shared database queries. This function optimizes the generation of multiple reports by: - Fetching the provider object once - Aggregating requirement statistics once (shared across all reports) - Reusing compliance framework data when possible - This can reduce database queries by up to 50-70% when generating multiple reports. - Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): The ID of the scan to generate reports for. - provider_id (str): The ID of the provider used in the scan. - generate_threatscore (bool): Whether to generate ThreatScore report. Defaults to True. - generate_ens (bool): Whether to generate ENS report. Defaults to True. - generate_nis2 (bool): Whether to generate NIS2 report. Defaults to True. - only_failed_threatscore (bool): For ThreatScore, only include failed requirements. Defaults to True. - min_risk_level_threatscore (int): Minimum risk level for ThreatScore critical requirements. Defaults to 4. - include_manual_ens (bool): For ENS, include manual requirements. Defaults to True. - only_failed_nis2 (bool): For NIS2, only include failed requirements. Defaults to True. + tenant_id: The tenant ID for Row-Level Security context. + scan_id: The ID of the scan to generate reports for. + provider_id: The ID of the provider used in the scan. + generate_threatscore: Whether to generate ThreatScore report. + generate_ens: Whether to generate ENS report. + generate_nis2: Whether to generate NIS2 report. + only_failed_threatscore: For ThreatScore, only include failed requirements. + min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements. + include_manual_ens: For ENS, include manual requirements. + include_manual_nis2: For NIS2, include manual requirements. + only_failed_nis2: For NIS2, only include failed requirements. Returns: - dict[str, dict[str, bool | str]]: Dictionary with results for each report: - { - 'threatscore': {'upload': bool, 'path': str, 'error': str (optional)}, - 'ens': {'upload': bool, 'path': str, 'error': str (optional)}, - 'nis2': {'upload': bool, 'path': str, 'error': str (optional)} - } - - Example: - >>> results = generate_compliance_reports( - ... tenant_id="tenant-123", - ... scan_id="scan-456", - ... provider_id="provider-789", - ... generate_threatscore=True, - ... generate_ens=True, - ... generate_nis2=True - ... ) - >>> print(results['threatscore']['upload']) - True + Dictionary with results for each report type. """ logger.info( - f"Generating compliance reports for scan {scan_id} with provider {provider_id}" - f" (ThreatScore: {generate_threatscore}, ENS: {generate_ens}, NIS2: {generate_nis2})" + "Generating compliance reports for scan %s with provider %s" + " (ThreatScore: %s, ENS: %s, NIS2: %s)", + scan_id, + provider_id, + generate_threatscore, + generate_ens, + generate_nis2, ) results = {} - # Validate that the scan has findings and get provider info (shared query) + # Validate that the scan has findings and get provider info with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): if not ScanSummary.objects.filter(scan_id=scan_id).exists(): - logger.info(f"No findings found for scan {scan_id}") + logger.info("No findings found for scan %s", scan_id) if generate_threatscore: results["threatscore"] = {"upload": False, "path": ""} if generate_ens: @@ -3519,7 +208,6 @@ def generate_compliance_reports( results["nis2"] = {"upload": False, "path": ""} return results - # Fetch provider once (optimization) provider_obj = Provider.objects.get(id=provider_id) provider_uid = provider_obj.uid provider_type = provider_obj.provider @@ -3533,43 +221,36 @@ def generate_compliance_reports( "kubernetes", "alibabacloud", ]: - logger.info( - f"Provider {provider_id} ({provider_type}) is not supported for ThreatScore report" - ) + logger.info("Provider %s not supported for ThreatScore report", provider_type) results["threatscore"] = {"upload": False, "path": ""} generate_threatscore = False if generate_ens and provider_type not in ["aws", "azure", "gcp"]: - logger.info( - f"Provider {provider_id} ({provider_type}) is not supported for ENS report" - ) + logger.info("Provider %s not supported for ENS report", provider_type) results["ens"] = {"upload": False, "path": ""} generate_ens = False if generate_nis2 and provider_type not in ["aws", "azure", "gcp"]: - logger.info( - f"Provider {provider_id} ({provider_type}) is not supported for NIS2 report" - ) + logger.info("Provider %s not supported for NIS2 report", provider_type) results["nis2"] = {"upload": False, "path": ""} generate_nis2 = False - # If no reports to generate, return early if not generate_threatscore and not generate_ens and not generate_nis2: return results - # Aggregate requirement statistics once (major optimization) + # Aggregate requirement statistics once logger.info( - f"Aggregating requirement statistics once for all reports (scan {scan_id})" + "Aggregating requirement statistics once for all reports (scan %s)", scan_id ) requirement_statistics = _aggregate_requirement_statistics_from_database( tenant_id, scan_id ) - # Create shared findings cache (major optimization for findings queries) + # Create shared findings cache findings_cache = {} - logger.info("Created shared findings cache for both reports") + logger.info("Created shared findings cache for all reports") - # Generate output directories for each compliance framework + # Generate output directories try: logger.info("Generating output directories") threatscore_path = _generate_compliance_output_directory( @@ -3593,10 +274,9 @@ def generate_compliance_reports( scan_id, compliance_framework="nis2", ) - # Extract base scan directory for cleanup (parent of threatscore directory) out_dir = str(Path(threatscore_path).parent.parent) except Exception as e: - logger.error(f"Error generating output directory: {e}") + logger.error("Error generating output directory: %s", e) error_dict = {"error": str(e), "upload": False, "path": ""} if generate_threatscore: results["threatscore"] = error_dict.copy() @@ -3611,7 +291,8 @@ def generate_compliance_reports( compliance_id_threatscore = f"prowler_threatscore_{provider_type}" pdf_path_threatscore = f"{threatscore_path}_threatscore_report.pdf" logger.info( - f"Generating ThreatScore report with compliance {compliance_id_threatscore}" + "Generating ThreatScore report with compliance %s", + compliance_id_threatscore, ) try: @@ -3623,13 +304,13 @@ def generate_compliance_reports( provider_id=provider_id, only_failed=only_failed_threatscore, min_risk_level=min_risk_level_threatscore, - provider_obj=provider_obj, # Reuse provider object - requirement_statistics=requirement_statistics, # Reuse statistics - findings_cache=findings_cache, # Share findings cache + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, ) # Compute and store ThreatScore metrics snapshot - logger.info(f"Computing ThreatScore metrics for scan {scan_id}") + logger.info("Computing ThreatScore metrics for scan %s", scan_id) try: metrics = compute_threatscore_metrics( tenant_id=tenant_id, @@ -3639,9 +320,7 @@ def generate_compliance_reports( min_risk_level=min_risk_level_threatscore, ) - # Create snapshot in database with rls_transaction(tenant_id): - # Get previous snapshot for the same provider to calculate delta previous_snapshot = ( ThreatScoreSnapshot.objects.filter( tenant_id=tenant_id, @@ -3652,7 +331,6 @@ def generate_compliance_reports( .first() ) - # Calculate score delta (improvement) score_delta = None if previous_snapshot: score_delta = metrics["overall_score"] - float( @@ -3683,12 +361,10 @@ def generate_compliance_reports( else "" ) logger.info( - f"ThreatScore snapshot created with ID {snapshot.id} " - f"(score: {snapshot.overall_score}%{delta_msg})" + f"ThreatScore snapshot created with ID {snapshot.id} (score: {snapshot.overall_score}%{delta_msg})", ) except Exception as e: - # Log error but don't fail the job if snapshot creation fails - logger.error(f"Error creating ThreatScore snapshot: {e}") + logger.error("Error creating ThreatScore snapshot: %s", e) upload_uri_threatscore = _upload_to_s3( tenant_id, @@ -3702,20 +378,20 @@ def generate_compliance_reports( "upload": True, "path": upload_uri_threatscore, } - logger.info(f"ThreatScore report uploaded to {upload_uri_threatscore}") + logger.info("ThreatScore report uploaded to %s", upload_uri_threatscore) else: results["threatscore"] = {"upload": False, "path": out_dir} - logger.warning(f"ThreatScore report saved locally at {out_dir}") + logger.warning("ThreatScore report saved locally at %s", out_dir) except Exception as e: - logger.error(f"Error generating ThreatScore report: {e}") + logger.error("Error generating ThreatScore report: %s", e) results["threatscore"] = {"upload": False, "path": "", "error": str(e)} # Generate ENS report if generate_ens: compliance_id_ens = f"ens_rd2022_{provider_type}" pdf_path_ens = f"{ens_path}_ens_report.pdf" - logger.info(f"Generating ENS report with compliance {compliance_id_ens}") + logger.info("Generating ENS report with compliance %s", compliance_id_ens) try: generate_ens_report( @@ -3725,34 +401,31 @@ def generate_compliance_reports( output_path=pdf_path_ens, provider_id=provider_id, include_manual=include_manual_ens, - provider_obj=provider_obj, # Reuse provider object - requirement_statistics=requirement_statistics, # Reuse statistics - findings_cache=findings_cache, # Share findings cache + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, ) upload_uri_ens = _upload_to_s3( - tenant_id, - scan_id, - pdf_path_ens, - f"ens/{Path(pdf_path_ens).name}", + tenant_id, scan_id, pdf_path_ens, f"ens/{Path(pdf_path_ens).name}" ) if upload_uri_ens: results["ens"] = {"upload": True, "path": upload_uri_ens} - logger.info(f"ENS report uploaded to {upload_uri_ens}") + logger.info("ENS report uploaded to %s", upload_uri_ens) else: results["ens"] = {"upload": False, "path": out_dir} - logger.warning(f"ENS report saved locally at {out_dir}") + logger.warning("ENS report saved locally at %s", out_dir) except Exception as e: - logger.error(f"Error generating ENS report: {e}") + logger.error("Error generating ENS report: %s", e) results["ens"] = {"upload": False, "path": "", "error": str(e)} # Generate NIS2 report if generate_nis2: compliance_id_nis2 = f"nis2_{provider_type}" pdf_path_nis2 = f"{nis2_path}_nis2_report.pdf" - logger.info(f"Generating NIS2 report with compliance {compliance_id_nis2}") + logger.info("Generating NIS2 report with compliance %s", compliance_id_nis2) try: generate_nis2_report( @@ -3763,27 +436,24 @@ def generate_compliance_reports( provider_id=provider_id, only_failed=only_failed_nis2, include_manual=include_manual_nis2, - provider_obj=provider_obj, # Reuse provider object - requirement_statistics=requirement_statistics, # Reuse statistics - findings_cache=findings_cache, # Share findings cache + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, ) upload_uri_nis2 = _upload_to_s3( - tenant_id, - scan_id, - pdf_path_nis2, - f"nis2/{Path(pdf_path_nis2).name}", + tenant_id, scan_id, pdf_path_nis2, f"nis2/{Path(pdf_path_nis2).name}" ) if upload_uri_nis2: results["nis2"] = {"upload": True, "path": upload_uri_nis2} - logger.info(f"NIS2 report uploaded to {upload_uri_nis2}") + logger.info("NIS2 report uploaded to %s", upload_uri_nis2) else: results["nis2"] = {"upload": False, "path": out_dir} - logger.warning(f"NIS2 report saved locally at {out_dir}") + logger.warning("NIS2 report saved locally at %s", out_dir) except Exception as e: - logger.error(f"Error generating NIS2 report: {e}") + logger.error("Error generating NIS2 report: %s", e) results["nis2"] = {"upload": False, "path": "", "error": str(e)} # Clean up temporary files if all reports were uploaded successfully @@ -3796,11 +466,11 @@ def generate_compliance_reports( if all_uploaded: try: rmtree(Path(out_dir), ignore_errors=True) - logger.info(f"Cleaned up temporary files at {out_dir}") + logger.info("Cleaned up temporary files at %s", out_dir) except Exception as e: - logger.error(f"Error deleting output files: {e}") + logger.error("Error deleting output files: %s", e) - logger.info(f"Compliance reports generation completed. Results: {results}") + logger.info("Compliance reports generation completed. Results: %s", results) return results @@ -3813,75 +483,24 @@ def generate_compliance_reports_job( generate_nis2: bool = True, ) -> dict[str, dict[str, bool | str]]: """ - Job function to generate ThreatScore, ENS, and/or NIS2 compliance reports with optimized database queries. - - This function efficiently generates compliance reports by: - - Fetching the provider object once (shared across all reports) - - Aggregating requirement statistics once (shared across all reports) - - Sharing findings cache between reports to avoid duplicate queries - - Reducing total database queries by 50-70% compared to generating reports separately - - Use this job when you need to generate compliance reports for a scan. + Celery task wrapper for generate_compliance_reports. Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): The ID of the scan to generate reports for. - provider_id (str): The ID of the provider used in the scan. - generate_threatscore (bool): Whether to generate ThreatScore report. Defaults to True. - generate_ens (bool): Whether to generate ENS report. Defaults to True. - generate_nis2 (bool): Whether to generate NIS2 report. Defaults to True. + tenant_id: The tenant ID for Row-Level Security context. + scan_id: The ID of the scan to generate reports for. + provider_id: The ID of the provider used in the scan. + generate_threatscore: Whether to generate ThreatScore report. + generate_ens: Whether to generate ENS report. + generate_nis2: Whether to generate NIS2 report. Returns: - dict[str, dict[str, bool | str]]: Dictionary with results for each report: - { - 'threatscore': {'upload': bool, 'path': str, 'error': str (optional)}, - 'ens': {'upload': bool, 'path': str, 'error': str (optional)}, - 'nis2': {'upload': bool, 'path': str, 'error': str (optional)} - } - - Example: - >>> results = generate_compliance_reports_job( - ... tenant_id="tenant-123", - ... scan_id="scan-456", - ... provider_id="provider-789" - ... ) - >>> if results['threatscore']['upload']: - ... print(f"ThreatScore uploaded to {results['threatscore']['path']}") - >>> if results['ens']['upload']: - ... print(f"ENS uploaded to {results['ens']['path']}") - >>> if results['nis2']['upload']: - ... print(f"NIS2 uploaded to {results['nis2']['path']}") + Dictionary with results for each report type. """ - logger.info( - f"Starting optimized compliance reports job for scan {scan_id} " - f"(ThreatScore: {generate_threatscore}, ENS: {generate_ens}, NIS2: {generate_nis2})" + return generate_compliance_reports( + tenant_id=tenant_id, + scan_id=scan_id, + provider_id=provider_id, + generate_threatscore=generate_threatscore, + generate_ens=generate_ens, + generate_nis2=generate_nis2, ) - - try: - results = generate_compliance_reports( - tenant_id=tenant_id, - scan_id=scan_id, - provider_id=provider_id, - generate_threatscore=generate_threatscore, - generate_ens=generate_ens, - generate_nis2=generate_nis2, - only_failed_threatscore=True, - min_risk_level_threatscore=4, - include_manual_ens=True, - include_manual_nis2=False, - only_failed_nis2=True, - ) - logger.info("Optimized compliance reports job completed successfully") - return results - - except Exception as e: - logger.error(f"Error in optimized compliance reports job: {e}") - error_result = {"upload": False, "path": "", "error": str(e)} - results = {} - if generate_threatscore: - results["threatscore"] = error_result.copy() - if generate_ens: - results["ens"] = error_result.copy() - if generate_nis2: - results["nis2"] = error_result.copy() - return results diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py new file mode 100644 index 0000000000..60602b93ab --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/__init__.py @@ -0,0 +1,186 @@ +# Base classes and data structures +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + RequirementData, + create_pdf_styles, + get_requirement_metadata, +) + +# Chart functions +from .charts import ( + create_horizontal_bar_chart, + create_pie_chart, + create_radar_chart, + create_stacked_bar_chart, + create_vertical_bar_chart, + get_chart_color_for_percentage, +) + +# Reusable components +# Reusable components: Color helpers, Badge components, Risk component, +# Table components, Section components +from .components import ( + ColumnConfig, + create_badge, + create_data_table, + create_findings_table, + create_info_table, + create_multi_badge_row, + create_risk_component, + create_section_header, + create_status_badge, + create_summary_table, + get_color_for_compliance, + get_color_for_risk_level, + get_color_for_weight, + get_status_color, +) + +# Framework configuration: Main configuration, Color constants, ENS colors, +# NIS2 colors, Chart colors, ENS constants, Section constants, Layout constants +from .config import ( + CHART_COLOR_BLUE, + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COL_WIDTH_LARGE, + COL_WIDTH_MEDIUM, + COL_WIDTH_SMALL, + COL_WIDTH_XLARGE, + COL_WIDTH_XXLARGE, + COLOR_BG_BLUE, + COLOR_BG_LIGHT_BLUE, + COLOR_BLUE, + COLOR_DARK_GRAY, + COLOR_ENS_ALTO, + COLOR_ENS_BAJO, + COLOR_ENS_MEDIO, + COLOR_ENS_OPCIONAL, + COLOR_GRAY, + COLOR_HIGH_RISK, + COLOR_LIGHT_BLUE, + COLOR_LIGHT_GRAY, + COLOR_LIGHTER_BLUE, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_NIS2_PRIMARY, + COLOR_NIS2_SECONDARY, + COLOR_PROWLER_DARK_GREEN, + COLOR_SAFE, + COLOR_WHITE, + DIMENSION_KEYS, + DIMENSION_MAPPING, + DIMENSION_NAMES, + ENS_NIVEL_ORDER, + ENS_TIPO_ORDER, + FRAMEWORK_REGISTRY, + NIS2_SECTION_TITLES, + NIS2_SECTIONS, + PADDING_LARGE, + PADDING_MEDIUM, + PADDING_SMALL, + PADDING_XLARGE, + THREATSCORE_SECTIONS, + TIPO_ICONS, + FrameworkConfig, + get_framework_config, +) + +# Framework-specific generators +from .ens import ENSReportGenerator +from .nis2 import NIS2ReportGenerator +from .threatscore import ThreatScoreReportGenerator + +__all__ = [ + # Base classes + "BaseComplianceReportGenerator", + "ComplianceData", + "RequirementData", + "create_pdf_styles", + "get_requirement_metadata", + # Framework-specific generators + "ThreatScoreReportGenerator", + "ENSReportGenerator", + "NIS2ReportGenerator", + # Configuration + "FrameworkConfig", + "FRAMEWORK_REGISTRY", + "get_framework_config", + # Color constants + "COLOR_BLUE", + "COLOR_LIGHT_BLUE", + "COLOR_LIGHTER_BLUE", + "COLOR_BG_BLUE", + "COLOR_BG_LIGHT_BLUE", + "COLOR_GRAY", + "COLOR_LIGHT_GRAY", + "COLOR_DARK_GRAY", + "COLOR_WHITE", + "COLOR_HIGH_RISK", + "COLOR_MEDIUM_RISK", + "COLOR_LOW_RISK", + "COLOR_SAFE", + "COLOR_PROWLER_DARK_GREEN", + "COLOR_ENS_ALTO", + "COLOR_ENS_MEDIO", + "COLOR_ENS_BAJO", + "COLOR_ENS_OPCIONAL", + "COLOR_NIS2_PRIMARY", + "COLOR_NIS2_SECONDARY", + "CHART_COLOR_BLUE", + "CHART_COLOR_GREEN_1", + "CHART_COLOR_GREEN_2", + "CHART_COLOR_YELLOW", + "CHART_COLOR_ORANGE", + "CHART_COLOR_RED", + # ENS constants + "DIMENSION_MAPPING", + "DIMENSION_NAMES", + "DIMENSION_KEYS", + "ENS_NIVEL_ORDER", + "ENS_TIPO_ORDER", + "TIPO_ICONS", + # Section constants + "THREATSCORE_SECTIONS", + "NIS2_SECTIONS", + "NIS2_SECTION_TITLES", + # Layout constants + "COL_WIDTH_SMALL", + "COL_WIDTH_MEDIUM", + "COL_WIDTH_LARGE", + "COL_WIDTH_XLARGE", + "COL_WIDTH_XXLARGE", + "PADDING_SMALL", + "PADDING_MEDIUM", + "PADDING_LARGE", + "PADDING_XLARGE", + # Color helpers + "get_color_for_risk_level", + "get_color_for_weight", + "get_color_for_compliance", + "get_status_color", + # Badge components + "create_badge", + "create_status_badge", + "create_multi_badge_row", + # Risk component + "create_risk_component", + # Table components + "create_info_table", + "create_data_table", + "create_findings_table", + "ColumnConfig", + # Section components + "create_section_header", + "create_summary_table", + # Chart functions + "get_chart_color_for_percentage", + "create_vertical_bar_chart", + "create_horizontal_bar_chart", + "create_radar_chart", + "create_pie_chart", + "create_stacked_bar_chart", +] diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py new file mode 100644 index 0000000000..42776681fb --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -0,0 +1,911 @@ +import gc +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from celery.utils.log import get_task_logger +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas +from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer +from tasks.jobs.threatscore_utils import ( + _aggregate_requirement_statistics_from_database, + _calculate_requirements_data_from_statistics, + _load_findings_for_requirement_checks, +) + +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Provider, StatusChoices +from api.utils import initialize_prowler_provider +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.finding import Finding as FindingOutput + +from .components import ( + ColumnConfig, + create_data_table, + create_info_table, + create_status_badge, +) +from .config import ( + COLOR_BG_BLUE, + COLOR_BG_LIGHT_BLUE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_GRAY, + COLOR_LIGHT_BLUE, + COLOR_LIGHTER_BLUE, + COLOR_PROWLER_DARK_GREEN, + PADDING_LARGE, + PADDING_SMALL, + FrameworkConfig, +) + +logger = get_task_logger(__name__) + +# Register fonts (done once at module load) +_fonts_registered: bool = False + + +def _register_fonts() -> None: + """Register custom fonts for PDF generation. + + Uses a module-level flag to ensure fonts are only registered once, + avoiding duplicate registration errors from reportlab. + """ + global _fonts_registered + if _fonts_registered: + return + + fonts_dir = os.path.join(os.path.dirname(__file__), "../../assets/fonts") + + pdfmetrics.registerFont( + TTFont( + "PlusJakartaSans", + os.path.join(fonts_dir, "PlusJakartaSans-Regular.ttf"), + ) + ) + + pdfmetrics.registerFont( + TTFont( + "FiraCode", + os.path.join(fonts_dir, "FiraCode-Regular.ttf"), + ) + ) + + _fonts_registered = True + + +# ============================================================================= +# Data Classes +# ============================================================================= + + +@dataclass +class RequirementData: + """Data for a single compliance requirement. + + Attributes: + id: Requirement identifier + description: Requirement description + status: Compliance status (PASS, FAIL, MANUAL) + passed_findings: Number of passed findings + failed_findings: Number of failed findings + total_findings: Total number of findings + checks: List of check IDs associated with this requirement + attributes: Framework-specific requirement attributes + """ + + id: str + description: str + status: str + passed_findings: int = 0 + failed_findings: int = 0 + total_findings: int = 0 + checks: list[str] = field(default_factory=list) + attributes: Any = None + + +@dataclass +class ComplianceData: + """Aggregated compliance data for report generation. + + This dataclass holds all the data needed to generate a compliance report, + including compliance framework metadata, requirements, and findings. + + Attributes: + tenant_id: Tenant identifier + scan_id: Scan identifier + provider_id: Provider identifier + compliance_id: Compliance framework identifier + framework: Framework name (e.g., "CIS", "ENS") + name: Full compliance framework name + version: Framework version + description: Framework description + requirements: List of RequirementData objects + attributes_by_requirement_id: Mapping of requirement IDs to their attributes + findings_by_check_id: Mapping of check IDs to their findings + provider_obj: Provider model object + prowler_provider: Initialized Prowler provider + """ + + tenant_id: str + scan_id: str + provider_id: str + compliance_id: str + framework: str + name: str + version: str + description: str + requirements: list[RequirementData] = field(default_factory=list) + attributes_by_requirement_id: dict[str, dict] = field(default_factory=dict) + findings_by_check_id: dict[str, list[FindingOutput]] = field(default_factory=dict) + provider_obj: Provider | None = None + prowler_provider: Any = None + + +def get_requirement_metadata( + requirement_id: str, + attributes_by_requirement_id: dict[str, dict], +) -> Any | None: + """Get the first requirement metadata object from attributes. + + This helper function extracts the requirement metadata (req_attributes) + from the attributes dictionary. It's a common pattern used across all + report generators. + + Args: + requirement_id: The requirement ID to look up. + attributes_by_requirement_id: Mapping of requirement IDs to their attributes. + + Returns: + The first requirement attribute object, or None if not found. + + Example: + >>> meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + >>> if meta: + ... section = getattr(meta, "Section", "Unknown") + """ + req_attrs = attributes_by_requirement_id.get(requirement_id, {}) + meta_list = req_attrs.get("attributes", {}).get("req_attributes", []) + if meta_list: + return meta_list[0] + return None + + +# ============================================================================= +# PDF Styles Cache +# ============================================================================= + +_PDF_STYLES_CACHE: dict[str, ParagraphStyle] | None = None + + +def create_pdf_styles() -> dict[str, ParagraphStyle]: + """Create and return PDF paragraph styles used throughout the report. + + Styles are cached on first call to improve performance. + + Returns: + Dictionary containing the following styles: + - 'title': Title style with prowler green color + - 'h1': Heading 1 style with blue color and background + - 'h2': Heading 2 style with light blue color + - 'h3': Heading 3 style for sub-headings + - 'normal': Normal text style with left indent + - 'normal_center': Normal text style without indent + """ + global _PDF_STYLES_CACHE + + if _PDF_STYLES_CACHE is not None: + return _PDF_STYLES_CACHE + + _register_fonts() + styles = getSampleStyleSheet() + + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Title"], + fontSize=24, + textColor=COLOR_PROWLER_DARK_GREEN, + spaceAfter=20, + fontName="PlusJakartaSans", + alignment=TA_CENTER, + ) + + h1 = ParagraphStyle( + "CustomH1", + parent=styles["Heading1"], + fontSize=18, + textColor=COLOR_BLUE, + spaceBefore=20, + spaceAfter=12, + fontName="PlusJakartaSans", + leftIndent=0, + borderWidth=2, + borderColor=COLOR_BLUE, + borderPadding=PADDING_LARGE, + backColor=COLOR_BG_BLUE, + ) + + h2 = ParagraphStyle( + "CustomH2", + parent=styles["Heading2"], + fontSize=14, + textColor=COLOR_LIGHT_BLUE, + spaceBefore=15, + spaceAfter=8, + fontName="PlusJakartaSans", + leftIndent=10, + borderWidth=1, + borderColor=COLOR_BORDER_GRAY, + borderPadding=5, + backColor=COLOR_BG_LIGHT_BLUE, + ) + + h3 = ParagraphStyle( + "CustomH3", + parent=styles["Heading3"], + fontSize=12, + textColor=COLOR_LIGHTER_BLUE, + spaceBefore=10, + spaceAfter=6, + fontName="PlusJakartaSans", + leftIndent=20, + ) + + normal = ParagraphStyle( + "CustomNormal", + parent=styles["Normal"], + fontSize=10, + textColor=COLOR_GRAY, + spaceBefore=PADDING_SMALL, + spaceAfter=PADDING_SMALL, + leftIndent=30, + fontName="PlusJakartaSans", + ) + + normal_center = ParagraphStyle( + "CustomNormalCenter", + parent=styles["Normal"], + fontSize=10, + textColor=COLOR_GRAY, + fontName="PlusJakartaSans", + ) + + _PDF_STYLES_CACHE = { + "title": title_style, + "h1": h1, + "h2": h2, + "h3": h3, + "normal": normal, + "normal_center": normal_center, + } + + return _PDF_STYLES_CACHE + + +# ============================================================================= +# Base Report Generator +# ============================================================================= + + +class BaseComplianceReportGenerator(ABC): + """Abstract base class for compliance PDF report generators. + + This class implements the Template Method pattern, providing a common + structure for all compliance reports while allowing subclasses to + customize specific sections. + + Subclasses must implement: + - create_executive_summary() + - create_charts_section() + - create_requirements_index() + + Optionally, subclasses can override: + - create_cover_page() + - create_detailed_findings() + - get_footer_text() + """ + + def __init__(self, config: FrameworkConfig): + """Initialize the report generator. + + Args: + config: Framework configuration + """ + self.config = config + self.styles = create_pdf_styles() + + # ========================================================================= + # Template Method + # ========================================================================= + + def generate( + self, + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + provider_obj: Provider | None = None, + requirement_statistics: dict[str, dict[str, int]] | None = None, + findings_cache: dict[str, list[FindingOutput]] | None = None, + **kwargs, + ) -> None: + """Generate the PDF compliance report. + + This is the template method that orchestrates the report generation. + It calls abstract methods that subclasses must implement. + + Args: + tenant_id: Tenant identifier for RLS context + scan_id: Scan identifier + compliance_id: Compliance framework identifier + output_path: Path where the PDF will be saved + provider_id: Provider identifier + provider_obj: Optional pre-fetched Provider object + requirement_statistics: Optional pre-aggregated statistics + findings_cache: Optional pre-loaded findings cache + **kwargs: Additional framework-specific arguments + """ + logger.info( + "Generating %s report for scan %s", self.config.display_name, scan_id + ) + + try: + # 1. Load compliance data + data = self._load_compliance_data( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + ) + + # 2. Create PDF document + doc = self._create_document(output_path, data) + + # 3. Build report elements incrementally to manage memory + # We collect garbage after heavy sections to prevent OOM on large reports + elements = [] + + # Cover page (lightweight) + elements.extend(self.create_cover_page(data)) + elements.append(PageBreak()) + + # Executive summary (framework-specific) + elements.extend(self.create_executive_summary(data)) + + # Body sections (charts + requirements index) + # Override _build_body_sections() in subclasses to change section order + elements.extend(self._build_body_sections(data)) + + # Detailed findings - heaviest section, loads findings on-demand + logger.info("Building detailed findings section...") + elements.extend(self.create_detailed_findings(data, **kwargs)) + gc.collect() # Free findings data after processing + + # 4. Build the PDF + logger.info("Building PDF document with %d elements...", len(elements)) + self._build_pdf(doc, elements, data) + + # Final cleanup + del elements + gc.collect() + + logger.info("Successfully generated report at %s", output_path) + + except Exception as e: + import traceback + + tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown" + logger.error("Error generating report, line %s -- %s", tb_lineno, e) + logger.error("Full traceback:\n%s", traceback.format_exc()) + raise + + def _build_body_sections(self, data: ComplianceData) -> list: + """Build the body sections between executive summary and detailed findings. + + Override in subclasses to change section order. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Charts section (framework-specific) - heavy on memory due to matplotlib + elements.extend(self.create_charts_section(data)) + elements.append(PageBreak()) + gc.collect() # Free matplotlib resources + + # Requirements index (framework-specific) + elements.extend(self.create_requirements_index(data)) + elements.append(PageBreak()) + + return elements + + # ========================================================================= + # Abstract Methods (must be implemented by subclasses) + # ========================================================================= + + @abstractmethod + def create_executive_summary(self, data: ComplianceData) -> list: + """Create the executive summary section. + + This section typically includes: + - Overall compliance score/metrics + - High-level statistics + - Critical findings summary + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + + @abstractmethod + def create_charts_section(self, data: ComplianceData) -> list: + """Create the charts and visualizations section. + + This section typically includes: + - Compliance score charts by section + - Distribution charts + - Trend visualizations + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + + @abstractmethod + def create_requirements_index(self, data: ComplianceData) -> list: + """Create the requirements index/table of contents. + + This section typically includes: + - Hierarchical list of requirements + - Status indicators + - Section groupings + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + + # ========================================================================= + # Common Methods (can be overridden by subclasses) + # ========================================================================= + + def create_cover_page(self, data: ComplianceData) -> list: + """Create the report cover page. + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + elements = [] + + # Prowler logo + logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + if os.path.exists(logo_path): + logo = Image(logo_path, width=5 * inch, height=1 * inch) + elements.append(logo) + + elements.append(Spacer(1, 0.5 * inch)) + + # Title + title_text = f"{self.config.display_name} Report" + elements.append(Paragraph(title_text, self.styles["title"])) + elements.append(Spacer(1, 0.5 * inch)) + + # Compliance info table + info_rows = self._build_info_rows(data, language=self.config.language) + + info_table = create_info_table( + rows=info_rows, + label_width=2 * inch, + value_width=4 * inch, + normal_style=self.styles["normal_center"], + ) + elements.append(info_table) + + return elements + + def _build_info_rows( + self, data: ComplianceData, language: str = "en" + ) -> list[tuple[str, str]]: + """Build the standard info rows for the cover page table. + + This helper method creates the common metadata rows used in all + report cover pages. Subclasses can use this to maintain consistency + while customizing other aspects of the cover page. + + Args: + data: Aggregated compliance data. + language: Language for labels ("en" or "es"). + + Returns: + List of (label, value) tuples for the info table. + """ + # Labels based on language + labels = { + "en": { + "framework": "Framework:", + "id": "ID:", + "name": "Name:", + "version": "Version:", + "provider": "Provider:", + "account_id": "Account ID:", + "alias": "Alias:", + "scan_id": "Scan ID:", + "description": "Description:", + }, + "es": { + "framework": "Framework:", + "id": "ID:", + "name": "Nombre:", + "version": "VersiΓ³n:", + "provider": "Proveedor:", + "account_id": "Account ID:", + "alias": "Alias:", + "scan_id": "Scan ID:", + "description": "DescripciΓ³n:", + }, + } + lang_labels = labels.get(language, labels["en"]) + + info_rows = [ + (lang_labels["framework"], data.framework), + (lang_labels["id"], data.compliance_id), + (lang_labels["name"], data.name), + (lang_labels["version"], data.version), + ] + + # Add provider info if available + if data.provider_obj: + info_rows.append( + (lang_labels["provider"], data.provider_obj.provider.upper()) + ) + info_rows.append( + (lang_labels["account_id"], data.provider_obj.uid or "N/A") + ) + info_rows.append((lang_labels["alias"], data.provider_obj.alias or "N/A")) + + info_rows.append((lang_labels["scan_id"], data.scan_id)) + + if data.description: + info_rows.append((lang_labels["description"], data.description)) + + return info_rows + + def create_detailed_findings(self, data: ComplianceData, **kwargs) -> list: + """Create the detailed findings section. + + This default implementation creates a requirement-by-requirement + breakdown with findings tables. Subclasses can override for + framework-specific presentation. + + This method implements on-demand loading of findings using the shared + findings cache to minimize database queries and memory usage. + + Args: + data: Aggregated compliance data + **kwargs: Framework-specific options (e.g., only_failed) + + Returns: + List of ReportLab elements + """ + elements = [] + only_failed = kwargs.get("only_failed", True) + include_manual = kwargs.get("include_manual", False) + + # Filter requirements if needed + requirements = data.requirements + if only_failed: + # Include FAIL requirements, and optionally MANUAL if include_manual is True + if include_manual: + requirements = [ + r + for r in requirements + if r.status in (StatusChoices.FAIL, StatusChoices.MANUAL) + ] + else: + requirements = [ + r for r in requirements if r.status == StatusChoices.FAIL + ] + + # Collect all check IDs for requirements that will be displayed + # This allows us to load only the findings we actually need (memory optimization) + check_ids_to_load = [] + for req in requirements: + check_ids_to_load.extend(req.checks) + + # Load findings on-demand only for the checks that will be displayed + # Uses the shared findings cache to avoid duplicate queries across reports + logger.info("Loading findings on-demand for %d requirements", len(requirements)) + findings_by_check_id = _load_findings_for_requirement_checks( + data.tenant_id, + data.scan_id, + check_ids_to_load, + data.prowler_provider, + data.findings_by_check_id, # Pass the cache to update it + ) + + for req in requirements: + # Requirement header + elements.append( + Paragraph( + f"{req.id}: {req.description}", + self.styles["h1"], + ) + ) + + # Status badge + elements.append(create_status_badge(req.status)) + elements.append(Spacer(1, 0.1 * inch)) + + # Findings for this requirement + for check_id in req.checks: + elements.append(Paragraph(f"Check: {check_id}", self.styles["h2"])) + + findings = findings_by_check_id.get(check_id, []) + if not findings: + elements.append( + Paragraph( + "- No information for this finding currently", + self.styles["normal"], + ) + ) + else: + # Create findings table + findings_table = self._create_findings_table(findings) + elements.append(findings_table) + + elements.append(Spacer(1, 0.1 * inch)) + + elements.append(PageBreak()) + + return elements + + def get_footer_text(self, page_num: int) -> tuple[str, str]: + """Get footer text for a page. + + Args: + page_num: Current page number + + Returns: + Tuple of (left_text, right_text) for the footer + """ + if self.config.language == "es": + page_text = f"PΓ‘gina {page_num}" + else: + page_text = f"Page {page_num}" + + return page_text, "Powered by Prowler" + + # ========================================================================= + # Private Helper Methods + # ========================================================================= + + def _load_compliance_data( + self, + tenant_id: str, + scan_id: str, + compliance_id: str, + provider_id: str, + provider_obj: Provider | None, + requirement_statistics: dict | None, + findings_cache: dict | None, + ) -> ComplianceData: + """Load and aggregate compliance data from the database. + + Args: + tenant_id: Tenant identifier + scan_id: Scan identifier + compliance_id: Compliance framework identifier + provider_id: Provider identifier + provider_obj: Optional pre-fetched Provider + requirement_statistics: Optional pre-aggregated statistics + findings_cache: Optional pre-loaded findings + + Returns: + Aggregated ComplianceData object + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Load provider + if provider_obj is None: + provider_obj = Provider.objects.get(id=provider_id) + + prowler_provider = initialize_prowler_provider(provider_obj) + provider_type = provider_obj.provider + + # Load compliance framework + frameworks_bulk = Compliance.get_bulk(provider_type) + compliance_obj = frameworks_bulk.get(compliance_id) + + if not compliance_obj: + raise ValueError(f"Compliance framework not found: {compliance_id}") + + framework = getattr(compliance_obj, "Framework", "N/A") + name = getattr(compliance_obj, "Name", "N/A") + version = getattr(compliance_obj, "Version", "N/A") + description = getattr(compliance_obj, "Description", "") + + # Aggregate requirement statistics + if requirement_statistics is None: + logger.info("Aggregating requirement statistics for scan %s", scan_id) + requirement_statistics = _aggregate_requirement_statistics_from_database( + tenant_id, scan_id + ) + else: + logger.info("Reusing pre-aggregated statistics for scan %s", scan_id) + + # Calculate requirements data + attributes_by_requirement_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics + ) + ) + + # Convert to RequirementData objects + requirements = [] + for req_dict in requirements_list: + req = RequirementData( + id=req_dict["id"], + description=req_dict["attributes"].get("description", ""), + status=req_dict["attributes"].get("status", StatusChoices.MANUAL), + passed_findings=req_dict["attributes"].get("passed_findings", 0), + failed_findings=req_dict["attributes"].get("failed_findings", 0), + total_findings=req_dict["attributes"].get("total_findings", 0), + checks=attributes_by_requirement_id.get(req_dict["id"], {}) + .get("attributes", {}) + .get("checks", []), + ) + requirements.append(req) + + return ComplianceData( + tenant_id=tenant_id, + scan_id=scan_id, + provider_id=provider_id, + compliance_id=compliance_id, + framework=framework, + name=name, + version=version, + description=description, + requirements=requirements, + attributes_by_requirement_id=attributes_by_requirement_id, + findings_by_check_id=findings_cache if findings_cache is not None else {}, + provider_obj=provider_obj, + prowler_provider=prowler_provider, + ) + + def _create_document( + self, output_path: str, data: ComplianceData + ) -> SimpleDocTemplate: + """Create the PDF document template. + + Args: + output_path: Path for the output PDF + data: Compliance data for metadata + + Returns: + Configured SimpleDocTemplate + """ + return SimpleDocTemplate( + output_path, + pagesize=letter, + title=f"{self.config.display_name} Report - {data.framework}", + author="Prowler", + subject=f"Compliance Report for {data.framework}", + creator="Prowler Engineering Team", + keywords=f"compliance,{data.framework},security,framework,prowler", + ) + + def _build_pdf( + self, + doc: SimpleDocTemplate, + elements: list, + data: ComplianceData, + ) -> None: + """Build the final PDF with footers. + + Args: + doc: Document template + elements: List of ReportLab elements + data: Compliance data + """ + + def add_footer( + canvas_obj: canvas.Canvas, + doc_template: SimpleDocTemplate, + ) -> None: + canvas_obj.saveState() + width, _ = doc_template.pagesize + left_text, right_text = self.get_footer_text(doc_template.page) + + canvas_obj.setFont("PlusJakartaSans", 9) + canvas_obj.setFillColorRGB(0.4, 0.4, 0.4) + canvas_obj.drawString(30, 20, left_text) + + text_width = canvas_obj.stringWidth(right_text, "PlusJakartaSans", 9) + canvas_obj.drawString(width - text_width - 30, 20, right_text) + canvas_obj.restoreState() + + doc.build( + elements, + onFirstPage=add_footer, + onLaterPages=add_footer, + ) + + def _create_findings_table(self, findings: list[FindingOutput]) -> Any: + """Create a findings table. + + Args: + findings: List of finding objects + + Returns: + ReportLab Table element + """ + + def get_finding_title(f): + metadata = getattr(f, "metadata", None) + if metadata: + return getattr(metadata, "CheckTitle", getattr(f, "check_id", "")) + return getattr(f, "check_id", "") + + def get_resource_name(f): + name = getattr(f, "resource_name", "") + if not name: + name = getattr(f, "resource_uid", "") + return name + + def get_severity(f): + metadata = getattr(f, "metadata", None) + if metadata: + return getattr(metadata, "Severity", "").capitalize() + return "" + + # Convert findings to dicts for the table + data = [] + for f in findings: + item = { + "title": get_finding_title(f), + "resource_name": get_resource_name(f), + "severity": get_severity(f), + "status": getattr(f, "status", "").upper(), + "region": getattr(f, "region", "global"), + } + data.append(item) + + columns = [ + ColumnConfig("Finding", 2.5 * inch, "title"), + ColumnConfig("Resource", 3 * inch, "resource_name"), + ColumnConfig("Severity", 0.9 * inch, "severity"), + ColumnConfig("Status", 0.9 * inch, "status"), + ColumnConfig("Region", 0.9 * inch, "region"), + ] + + return create_data_table( + data=data, + columns=columns, + header_color=self.config.primary_color, + normal_style=self.styles["normal_center"], + ) diff --git a/api/src/backend/tasks/jobs/reports/charts.py b/api/src/backend/tasks/jobs/reports/charts.py new file mode 100644 index 0000000000..0f0338acab --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/charts.py @@ -0,0 +1,404 @@ +import gc +import io +import math +from typing import Callable + +import matplotlib + +# Use non-interactive Agg backend for memory efficiency in server environments +# This MUST be set before importing pyplot +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +from .config import ( # noqa: E402 + CHART_COLOR_BLUE, + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + CHART_DPI_DEFAULT, +) + +# Use centralized DPI setting from config +DEFAULT_CHART_DPI = CHART_DPI_DEFAULT + + +def get_chart_color_for_percentage(percentage: float) -> str: + """Get chart color string based on percentage. + + Args: + percentage: Value between 0 and 100 + + Returns: + Hex color string for matplotlib + """ + if percentage >= 80: + return CHART_COLOR_GREEN_1 + if percentage >= 60: + return CHART_COLOR_GREEN_2 + if percentage >= 40: + return CHART_COLOR_YELLOW + if percentage >= 20: + return CHART_COLOR_ORANGE + return CHART_COLOR_RED + + +def create_vertical_bar_chart( + labels: list[str], + values: list[float], + ylabel: str = "Compliance Score (%)", + xlabel: str = "Section", + title: str | None = None, + color_func: Callable[[float], str] | None = None, + colors: list[str] | None = None, + figsize: tuple[int, int] = (10, 6), + dpi: int = DEFAULT_CHART_DPI, + y_limit: tuple[float, float] = (0, 100), + show_labels: bool = True, + rotation: int = 45, +) -> io.BytesIO: + """Create a vertical bar chart. + + Args: + labels: X-axis labels + values: Bar heights (numeric values) + ylabel: Y-axis label + xlabel: X-axis label + title: Optional chart title + color_func: Function to determine bar color based on value + colors: Explicit list of colors (overrides color_func) + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + y_limit: Y-axis limits (min, max) + show_labels: Whether to show value labels on bars + rotation: X-axis label rotation angle + + Returns: + BytesIO buffer containing the PNG image + """ + if color_func is None: + color_func = get_chart_color_for_percentage + + fig, ax = plt.subplots(figsize=figsize) + + # Determine colors + if colors is None: + colors_list = [color_func(v) for v in values] + else: + colors_list = colors + + bars = ax.bar(labels, values, color=colors_list) + + ax.set_ylabel(ylabel, fontsize=12) + ax.set_xlabel(xlabel, fontsize=12) + ax.set_ylim(*y_limit) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + # Add value labels on bars + if show_labels: + for bar_item, value in zip(bars, values): + height = bar_item.get_height() + ax.text( + bar_item.get_x() + bar_item.get_width() / 2.0, + height + 1, + f"{value:.1f}%", + ha="center", + va="bottom", + fontweight="bold", + ) + + plt.xticks(rotation=rotation, ha="right") + ax.grid(True, alpha=0.3, axis="y") + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_horizontal_bar_chart( + labels: list[str], + values: list[float], + xlabel: str = "Compliance (%)", + title: str | None = None, + color_func: Callable[[float], str] | None = None, + colors: list[str] | None = None, + figsize: tuple[int, int] | None = None, + dpi: int = DEFAULT_CHART_DPI, + x_limit: tuple[float, float] = (0, 100), + show_labels: bool = True, + label_fontsize: int = 16, +) -> io.BytesIO: + """Create a horizontal bar chart. + + Args: + labels: Y-axis labels (bar names) + values: Bar widths (numeric values) + xlabel: X-axis label + title: Optional chart title + color_func: Function to determine bar color based on value + colors: Explicit list of colors (overrides color_func) + figsize: Figure size (auto-calculated if None based on label count) + dpi: Resolution for output image + x_limit: X-axis limits (min, max) + show_labels: Whether to show value labels on bars + label_fontsize: Font size for y-axis labels + + Returns: + BytesIO buffer containing the PNG image + """ + if color_func is None: + color_func = get_chart_color_for_percentage + + # Auto-calculate figure size based on number of items + if figsize is None: + figsize = (10, max(6, int(len(labels) * 0.4))) + + fig, ax = plt.subplots(figsize=figsize) + + # Determine colors + if colors is None: + colors_list = [color_func(v) for v in values] + else: + colors_list = colors + + y_pos = range(len(labels)) + bars = ax.barh(y_pos, values, color=colors_list) + + ax.set_yticks(y_pos) + ax.set_yticklabels(labels, fontsize=label_fontsize) + ax.set_xlabel(xlabel, fontsize=14) + ax.set_xlim(*x_limit) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + # Add value labels + if show_labels: + for bar_item, value in zip(bars, values): + width = bar_item.get_width() + ax.text( + width + 1, + bar_item.get_y() + bar_item.get_height() / 2.0, + f"{value:.1f}%", + ha="left", + va="center", + fontweight="bold", + fontsize=10, + ) + + ax.grid(True, alpha=0.3, axis="x") + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_radar_chart( + labels: list[str], + values: list[float], + color: str = CHART_COLOR_BLUE, + fill_alpha: float = 0.25, + figsize: tuple[int, int] = (8, 8), + dpi: int = DEFAULT_CHART_DPI, + y_limit: tuple[float, float] = (0, 100), + y_ticks: list[int] | None = None, + label_fontsize: int = 14, + title: str | None = None, +) -> io.BytesIO: + """Create a radar/spider chart. + + Args: + labels: Category names around the chart + values: Values for each category (should have same length as labels) + color: Line and fill color + fill_alpha: Transparency of the fill (0-1) + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + y_limit: Radial axis limits (min, max) + y_ticks: Custom tick values for radial axis + label_fontsize: Font size for category labels + title: Optional chart title + + Returns: + BytesIO buffer containing the PNG image + """ + num_vars = len(labels) + angles = [n / float(num_vars) * 2 * math.pi for n in range(num_vars)] + + # Close the polygon + values_closed = list(values) + [values[0]] + angles_closed = angles + [angles[0]] + + fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"}) + + ax.plot(angles_closed, values_closed, "o-", linewidth=2, color=color) + ax.fill(angles_closed, values_closed, alpha=fill_alpha, color=color) + + ax.set_xticks(angles) + ax.set_xticklabels(labels, fontsize=label_fontsize) + ax.set_ylim(*y_limit) + + if y_ticks is None: + y_ticks = [20, 40, 60, 80, 100] + ax.set_yticks(y_ticks) + ax.set_yticklabels([f"{t}%" for t in y_ticks], fontsize=12) + + ax.grid(True, alpha=0.3) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold", y=1.08) + + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_pie_chart( + labels: list[str], + values: list[float], + colors: list[str] | None = None, + figsize: tuple[int, int] = (6, 6), + dpi: int = DEFAULT_CHART_DPI, + autopct: str = "%1.1f%%", + startangle: int = 90, + title: str | None = None, +) -> io.BytesIO: + """Create a pie chart. + + Args: + labels: Slice labels + values: Slice values + colors: Optional list of colors for slices + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + autopct: Format string for percentage labels + startangle: Starting angle for first slice + title: Optional chart title + + Returns: + BytesIO buffer containing the PNG image + """ + fig, ax = plt.subplots(figsize=figsize) + + _, _, autotexts = ax.pie( + values, + labels=labels, + colors=colors, + autopct=autopct, + startangle=startangle, + ) + + # Style the text + for autotext in autotexts: + autotext.set_fontweight("bold") + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_stacked_bar_chart( + labels: list[str], + data_series: dict[str, list[float]], + colors: dict[str, str] | None = None, + xlabel: str = "", + ylabel: str = "Count", + title: str | None = None, + figsize: tuple[int, int] = (10, 6), + dpi: int = DEFAULT_CHART_DPI, + rotation: int = 45, + show_legend: bool = True, +) -> io.BytesIO: + """Create a stacked bar chart. + + Args: + labels: X-axis labels + data_series: Dictionary mapping series name to list of values + colors: Dictionary mapping series name to color + xlabel: X-axis label + ylabel: Y-axis label + title: Optional chart title + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + rotation: X-axis label rotation angle + show_legend: Whether to show the legend + + Returns: + BytesIO buffer containing the PNG image + """ + fig, ax = plt.subplots(figsize=figsize) + + # Default colors if not provided + default_colors = { + "Pass": CHART_COLOR_GREEN_1, + "Fail": CHART_COLOR_RED, + "Manual": CHART_COLOR_YELLOW, + } + if colors is None: + colors = default_colors + + bottom = [0] * len(labels) + for series_name, values in data_series.items(): + color = colors.get(series_name, CHART_COLOR_BLUE) + ax.bar(labels, values, bottom=bottom, label=series_name, color=color) + bottom = [b + v for b, v in zip(bottom, values)] + + ax.set_xlabel(xlabel, fontsize=12) + ax.set_ylabel(ylabel, fontsize=12) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + plt.xticks(rotation=rotation, ha="right") + + if show_legend: + ax.legend() + + ax.grid(True, alpha=0.3, axis="y") + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer diff --git a/api/src/backend/tasks/jobs/reports/components.py b/api/src/backend/tasks/jobs/reports/components.py new file mode 100644 index 0000000000..323c4547e6 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/components.py @@ -0,0 +1,599 @@ +from dataclasses import dataclass +from typing import Any, Callable + +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import LongTable, Paragraph, Spacer, Table, TableStyle + +from .config import ( + ALTERNATE_ROWS_MAX_SIZE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_DARK_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_LIGHT_GRAY, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_SAFE, + COLOR_WHITE, + LONG_TABLE_THRESHOLD, + PADDING_LARGE, + PADDING_MEDIUM, + PADDING_SMALL, + PADDING_XLARGE, +) + + +def get_color_for_risk_level(risk_level: int) -> colors.Color: + """ + Get color based on risk level. + + Args: + risk_level (int): Numeric risk level (0-5). + + Returns: + colors.Color: Appropriate color for the risk level. + """ + if risk_level >= 4: + return COLOR_HIGH_RISK + if risk_level >= 3: + return COLOR_MEDIUM_RISK + if risk_level >= 2: + return COLOR_LOW_RISK + return COLOR_SAFE + + +def get_color_for_weight(weight: int) -> colors.Color: + """ + Get color based on weight value. + + Args: + weight (int): Numeric weight value. + + Returns: + colors.Color: Appropriate color for the weight. + """ + if weight > 100: + return COLOR_HIGH_RISK + if weight > 50: + return COLOR_LOW_RISK + return COLOR_SAFE + + +def get_color_for_compliance(percentage: float) -> colors.Color: + """ + Get color based on compliance percentage. + + Args: + percentage (float): Compliance percentage (0-100). + + Returns: + colors.Color: Appropriate color for the compliance level. + """ + if percentage >= 80: + return COLOR_SAFE + if percentage >= 60: + return COLOR_LOW_RISK + return COLOR_HIGH_RISK + + +def get_status_color(status: str) -> colors.Color: + """ + Get color for a status value. + + Args: + status (str): Status string (PASS, FAIL, MANUAL, etc.). + + Returns: + colors.Color: Appropriate color for the status. + """ + status_upper = status.upper() + if status_upper == "PASS": + return COLOR_SAFE + if status_upper == "FAIL": + return COLOR_HIGH_RISK + return COLOR_DARK_GRAY + + +def create_badge( + text: str, + bg_color: colors.Color, + text_color: colors.Color = COLOR_WHITE, + width: float = 1.4 * inch, + font: str = "FiraCode", + font_size: int = 11, +) -> Table: + """ + Create a generic colored badge component. + + Args: + text (str): Text to display in the badge. + bg_color (colors.Color): Background color. + text_color (colors.Color): Text color (default white). + width (float): Badge width in inches. + font (str): Font name to use. + font_size (int): Font size. + + Returns: + Table: A Table object styled as a badge. + """ + data = [[text]] + table = Table(data, colWidths=[width]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), bg_color), + ("TEXTCOLOR", (0, 0), (0, 0), text_color), + ("FONTNAME", (0, 0), (0, 0), font), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), font_size), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +def create_status_badge(status: str) -> Table: + """ + Create a PASS/FAIL/MANUAL status badge. + + Args: + status (str): Status value (e.g., "PASS", "FAIL", "MANUAL"). + + Returns: + Table: A styled Table badge for the status. + """ + status_upper = status.upper() + status_color = get_status_color(status_upper) + + data = [["State:", status_upper]] + table = Table(data, colWidths=[0.6 * inch, 0.8 * inch]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (1, 0), (1, 0), status_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 12), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ] + ) + ) + + return table + + +def create_multi_badge_row( + badges: list[tuple[str, colors.Color]], + badge_width: float = 0.4 * inch, + font: str = "FiraCode", +) -> Table: + """ + Create a row of multiple small badges. + + Args: + badges (list[tuple[str, colors.Color]]): List of (text, color) tuples for each badge. + badge_width (float): Width of each badge. + font (str): Font name to use. + + Returns: + Table: A Table with multiple colored badges in a row. + """ + if not badges: + data = [["N/A"]] + table = Table(data, colWidths=[1 * inch]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ] + ) + ) + return table + + data = [[text for text, _ in badges]] + col_widths = [badge_width] * len(badges) + table = Table(data, colWidths=col_widths) + + styles = [ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, -1), font), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("TEXTCOLOR", (0, 0), (-1, -1), COLOR_WHITE), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ] + + for idx, (_, badge_color) in enumerate(badges): + styles.append(("BACKGROUND", (idx, 0), (idx, 0), badge_color)) + + table.setStyle(TableStyle(styles)) + return table + + +def create_risk_component( + risk_level: int, + weight: int, + score: int = 0, +) -> Table: + """ + Create a visual risk component showing risk level, weight, and score. + + Args: + risk_level (int): The risk level (0-5). + weight (int): The weight value. + score (int): The calculated score (default 0). + + Returns: + Table: A styled Table showing risk metrics. + """ + risk_color = get_color_for_risk_level(risk_level) + weight_color = get_color_for_weight(weight) + + data = [ + [ + "Risk Level:", + str(risk_level), + "Weight:", + str(weight), + "Score:", + str(score), + ] + ] + + table = Table( + data, + colWidths=[ + 0.8 * inch, + 0.4 * inch, + 0.6 * inch, + 0.4 * inch, + 0.5 * inch, + 0.4 * inch, + ], + ) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), + ("BACKGROUND", (1, 0), (1, 0), risk_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("BACKGROUND", (2, 0), (2, 0), COLOR_LIGHT_GRAY), + ("BACKGROUND", (3, 0), (3, 0), weight_color), + ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE), + ("FONTNAME", (3, 0), (3, 0), "FiraCode"), + ("BACKGROUND", (4, 0), (4, 0), COLOR_LIGHT_GRAY), + ("BACKGROUND", (5, 0), (5, 0), COLOR_DARK_GRAY), + ("TEXTCOLOR", (5, 0), (5, 0), COLOR_WHITE), + ("FONTNAME", (5, 0), (5, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +def create_info_table( + rows: list[tuple[str, Any]], + label_width: float = 2 * inch, + value_width: float = 4 * inch, + label_color: colors.Color = COLOR_BLUE, + value_bg_color: colors.Color | None = None, + normal_style: ParagraphStyle | None = None, +) -> Table: + """ + Create a key-value information table. + + Args: + rows (list[tuple[str, Any]]): List of (label, value) tuples. + label_width (float): Width of the label column. + value_width (float): Width of the value column. + label_color (colors.Color): Background color for labels. + value_bg_color (colors.Color | None): Background color for values (optional). + normal_style (ParagraphStyle | None): ParagraphStyle for wrapping long values. + + Returns: + Table: A styled Table with key-value pairs. + """ + from .config import COLOR_BG_BLUE + + if value_bg_color is None: + value_bg_color = COLOR_BG_BLUE + + # Handle empty rows case - Table requires at least one row + if not rows: + table = Table([["", ""]], colWidths=[label_width, value_width]) + table.setStyle(TableStyle([("FONTSIZE", (0, 0), (-1, -1), 0)])) + return table + + # Process rows - wrap long values in Paragraph if style provided + table_data = [] + for label, value in rows: + if normal_style and isinstance(value, str) and len(value) > 50: + value = Paragraph(value, normal_style) + table_data.append([label, value]) + + table = Table(table_data, colWidths=[label_width, value_width]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), label_color), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), value_bg_color), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_DARK_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +@dataclass +class ColumnConfig: + """ + Configuration for a table column. + + Attributes: + header (str): Column header text. + width (float): Column width in inches. + field (str | Callable[[Any], str]): Field name or callable to extract value from data. + align (str): Text alignment (LEFT, CENTER, RIGHT). + """ + + header: str + width: float + field: str | Callable[[Any], str] + align: str = "CENTER" + + +def create_data_table( + data: list[dict[str, Any]], + columns: list[ColumnConfig], + header_color: colors.Color = COLOR_BLUE, + alternate_rows: bool = True, + normal_style: ParagraphStyle | None = None, +) -> Table | LongTable: + """ + Create a data table with configurable columns. + + Uses LongTable for large datasets (>50 rows) for better memory efficiency + and page splitting. LongTable repeats headers on each page and has + optimized memory handling for large tables. + + Args: + data (list[dict[str, Any]]): List of data dictionaries. + columns (list[ColumnConfig]): Column configuration list. + header_color (colors.Color): Background color for header row. + alternate_rows (bool): Whether to alternate row backgrounds. + normal_style (ParagraphStyle | None): ParagraphStyle for cell values. + + Returns: + Table or LongTable: A styled table with data. + """ + # Build header row + header_row = [col.header for col in columns] + table_data = [header_row] + + # Build data rows + for item in data: + row = [] + for col in columns: + if callable(col.field): + value = col.field(item) + else: + value = item.get(col.field, "") + + if normal_style and isinstance(value, str): + value = Paragraph(value, normal_style) + row.append(value) + table_data.append(row) + + col_widths = [col.width for col in columns] + + # Use LongTable for large datasets - it handles page breaks better + # and has optimized memory handling for tables with many rows + use_long_table = len(data) > LONG_TABLE_THRESHOLD + if use_long_table: + table = LongTable(table_data, colWidths=col_widths, repeatRows=1) + else: + table = Table(table_data, colWidths=col_widths) + + styles = [ + ("BACKGROUND", (0, 0), (-1, 0), header_color), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ] + + # Apply column alignments + for idx, col in enumerate(columns): + styles.append(("ALIGN", (idx, 0), (idx, -1), col.align)) + + # Alternate row backgrounds - skip for very large tables as it adds memory overhead + if ( + alternate_rows + and len(table_data) > 1 + and len(table_data) <= ALTERNATE_ROWS_MAX_SIZE + ): + for i in range(1, len(table_data)): + if i % 2 == 0: + styles.append( + ("BACKGROUND", (0, i), (-1, i), colors.Color(0.98, 0.98, 0.98)) + ) + + table.setStyle(TableStyle(styles)) + return table + + +def create_findings_table( + findings: list[Any], + columns: list[ColumnConfig] | None = None, + header_color: colors.Color = COLOR_BLUE, + normal_style: ParagraphStyle | None = None, +) -> Table: + """ + Create a findings table with default or custom columns. + + Args: + findings (list[Any]): List of finding objects. + columns (list[ColumnConfig] | None): Optional column configuration (defaults to standard columns). + header_color (colors.Color): Background color for header row. + normal_style (ParagraphStyle | None): ParagraphStyle for cell values. + + Returns: + Table: A styled Table with findings data. + """ + if columns is None: + columns = [ + ColumnConfig("Finding", 2.5 * inch, "title"), + ColumnConfig("Resource", 3 * inch, "resource_name"), + ColumnConfig("Severity", 0.9 * inch, "severity"), + ColumnConfig("Status", 0.9 * inch, "status"), + ColumnConfig("Region", 0.9 * inch, "region"), + ] + + # Convert findings to dicts + data = [] + for finding in findings: + item = {} + for col in columns: + if callable(col.field): + item[col.header.lower()] = col.field(finding) + elif hasattr(finding, col.field): + item[col.field] = getattr(finding, col.field, "") + elif isinstance(finding, dict): + item[col.field] = finding.get(col.field, "") + data.append(item) + + return create_data_table( + data=data, + columns=columns, + header_color=header_color, + alternate_rows=True, + normal_style=normal_style, + ) + + +def create_section_header( + text: str, + style: ParagraphStyle, + add_spacer: bool = True, + spacer_height: float = 0.2, +) -> list: + """ + Create a section header with optional spacer. + + Args: + text (str): Header text. + style (ParagraphStyle): ParagraphStyle to apply. + add_spacer (bool): Whether to add a spacer after the header. + spacer_height (float): Height of the spacer in inches. + + Returns: + list: List of elements (Paragraph and optional Spacer). + """ + elements = [Paragraph(text, style)] + if add_spacer: + elements.append(Spacer(1, spacer_height * inch)) + return elements + + +def create_summary_table( + label: str, + value: str, + value_color: colors.Color, + label_width: float = 2.5 * inch, + value_width: float = 2 * inch, +) -> Table: + """ + Create a summary metric table (e.g., for ThreatScore display). + + Args: + label (str): Label text (e.g., "ThreatScore:"). + value (str): Value text (e.g., "85.5%"). + value_color (colors.Color): Background color for the value cell. + label_width (float): Width of the label column. + value_width (float): Width of the value column. + + Returns: + Table: A styled summary Table. + """ + data = [[label, value]] + table = Table(data, colWidths=[label_width, value_width]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), value_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + + return table diff --git a/api/src/backend/tasks/jobs/reports/config.py b/api/src/backend/tasks/jobs/reports/config.py new file mode 100644 index 0000000000..0785505820 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/config.py @@ -0,0 +1,286 @@ +from dataclasses import dataclass, field + +from reportlab.lib import colors +from reportlab.lib.units import inch + +# ============================================================================= +# Performance & Memory Optimization Settings +# ============================================================================= +# These settings control memory usage and performance for large reports. +# Adjust these values if workers are running out of memory. + +# Chart settings - lower DPI = less memory, 150 is good quality for PDF +CHART_DPI_DEFAULT = 150 + +# LongTable threshold - use LongTable for tables with more rows than this +# LongTable handles page breaks better and has optimized memory for large tables +LONG_TABLE_THRESHOLD = 50 + +# Skip alternating row colors for tables larger than this (reduces memory) +ALTERNATE_ROWS_MAX_SIZE = 200 + +# Database query batch size for findings (matches Django settings) +# Larger = fewer queries but more memory per batch +FINDINGS_BATCH_SIZE = 2000 + + +# ============================================================================= +# Base colors +# ============================================================================= +COLOR_PROWLER_DARK_GREEN = colors.Color(0.1, 0.5, 0.2) +COLOR_BLUE = colors.Color(0.2, 0.4, 0.6) +COLOR_LIGHT_BLUE = colors.Color(0.3, 0.5, 0.7) +COLOR_LIGHTER_BLUE = colors.Color(0.4, 0.6, 0.8) +COLOR_BG_BLUE = colors.Color(0.95, 0.97, 1.0) +COLOR_BG_LIGHT_BLUE = colors.Color(0.98, 0.99, 1.0) +COLOR_GRAY = colors.Color(0.2, 0.2, 0.2) +COLOR_LIGHT_GRAY = colors.Color(0.9, 0.9, 0.9) +COLOR_BORDER_GRAY = colors.Color(0.7, 0.8, 0.9) +COLOR_GRID_GRAY = colors.Color(0.7, 0.7, 0.7) +COLOR_DARK_GRAY = colors.Color(0.4, 0.4, 0.4) +COLOR_HEADER_DARK = colors.Color(0.1, 0.3, 0.5) +COLOR_HEADER_MEDIUM = colors.Color(0.15, 0.35, 0.55) +COLOR_WHITE = colors.white + +# Risk and status colors +COLOR_HIGH_RISK = colors.Color(0.8, 0.2, 0.2) +COLOR_MEDIUM_RISK = colors.Color(0.9, 0.6, 0.2) +COLOR_LOW_RISK = colors.Color(0.9, 0.9, 0.2) +COLOR_SAFE = colors.Color(0.2, 0.8, 0.2) + +# ENS specific colors +COLOR_ENS_ALTO = colors.Color(0.8, 0.2, 0.2) +COLOR_ENS_MEDIO = colors.Color(0.98, 0.75, 0.13) +COLOR_ENS_BAJO = colors.Color(0.06, 0.72, 0.51) +COLOR_ENS_OPCIONAL = colors.Color(0.42, 0.45, 0.50) +COLOR_ENS_TIPO = colors.Color(0.2, 0.4, 0.6) +COLOR_ENS_AUTO = colors.Color(0.30, 0.69, 0.31) +COLOR_ENS_MANUAL = colors.Color(0.96, 0.60, 0.0) + +# NIS2 specific colors +COLOR_NIS2_PRIMARY = colors.Color(0.12, 0.23, 0.54) +COLOR_NIS2_SECONDARY = colors.Color(0.23, 0.51, 0.96) +COLOR_NIS2_BG_BLUE = colors.Color(0.96, 0.97, 0.99) + +# Chart colors (hex strings for matplotlib) +CHART_COLOR_GREEN_1 = "#4CAF50" +CHART_COLOR_GREEN_2 = "#8BC34A" +CHART_COLOR_YELLOW = "#FFEB3B" +CHART_COLOR_ORANGE = "#FF9800" +CHART_COLOR_RED = "#F44336" +CHART_COLOR_BLUE = "#2196F3" + +# ENS dimension mappings: dimension name -> (abbreviation, color) +DIMENSION_MAPPING = { + "trazabilidad": ("T", colors.Color(0.26, 0.52, 0.96)), + "autenticidad": ("A", colors.Color(0.30, 0.69, 0.31)), + "integridad": ("I", colors.Color(0.61, 0.15, 0.69)), + "confidencialidad": ("C", colors.Color(0.96, 0.26, 0.21)), + "disponibilidad": ("D", colors.Color(1.0, 0.60, 0.0)), +} + +# ENS tipo icons +TIPO_ICONS = { + "requisito": "\u26a0\ufe0f", + "refuerzo": "\U0001f6e1\ufe0f", + "recomendacion": "\U0001f4a1", + "medida": "\U0001f4cb", +} + +# Dimension names for charts (Spanish) +DIMENSION_NAMES = [ + "Trazabilidad", + "Autenticidad", + "Integridad", + "Confidencialidad", + "Disponibilidad", +] + +DIMENSION_KEYS = [ + "trazabilidad", + "autenticidad", + "integridad", + "confidencialidad", + "disponibilidad", +] + +# ENS nivel and tipo order +ENS_NIVEL_ORDER = ["alto", "medio", "bajo", "opcional"] +ENS_TIPO_ORDER = ["requisito", "refuerzo", "recomendacion", "medida"] + +# ThreatScore sections +THREATSCORE_SECTIONS = [ + "1. IAM", + "2. Attack Surface", + "3. Logging and Monitoring", + "4. Encryption", +] + +# NIS2 sections +NIS2_SECTIONS = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "9", + "11", + "12", +] + +NIS2_SECTION_TITLES = { + "1": "1. Policy on Security", + "2": "2. Risk Management", + "3": "3. Incident Handling", + "4": "4. Business Continuity", + "5": "5. Supply Chain", + "6": "6. Acquisition & Dev", + "7": "7. Effectiveness", + "9": "9. Cryptography", + "11": "11. Access Control", + "12": "12. Asset Management", +} + +# Table column widths +COL_WIDTH_SMALL = 0.4 * inch +COL_WIDTH_MEDIUM = 0.9 * inch +COL_WIDTH_LARGE = 1.5 * inch +COL_WIDTH_XLARGE = 2 * inch +COL_WIDTH_XXLARGE = 3 * inch + +# Common padding values +PADDING_SMALL = 4 +PADDING_MEDIUM = 6 +PADDING_LARGE = 8 +PADDING_XLARGE = 10 + + +@dataclass +class FrameworkConfig: + """ + Configuration for a compliance framework PDF report. + + This dataclass defines all the configurable aspects of a compliance framework + report, including visual styling, metadata fields, and feature flags. + + Attributes: + name (str): Internal framework identifier (e.g., "prowler_threatscore"). + display_name (str): Human-readable framework name for the report title. + logo_filename (str | None): Optional filename of the framework logo in assets/img/. + primary_color (colors.Color): Main color used for headers and important elements. + secondary_color (colors.Color): Secondary color for sub-headers and accents. + bg_color (colors.Color): Background color for highlighted sections. + attribute_fields (list[str]): List of metadata field names to extract from requirements. + sections (list[str] | None): Optional ordered list of section names for grouping. + language (str): Report language ("en" for English, "es" for Spanish). + has_risk_levels (bool): Whether the framework uses numeric risk levels. + has_dimensions (bool): Whether the framework uses security dimensions (ENS). + has_niveles (bool): Whether the framework uses nivel classification (ENS). + has_weight (bool): Whether requirements have weight values. + """ + + name: str + display_name: str + logo_filename: str | None = None + primary_color: colors.Color = field(default_factory=lambda: COLOR_BLUE) + secondary_color: colors.Color = field(default_factory=lambda: COLOR_LIGHT_BLUE) + bg_color: colors.Color = field(default_factory=lambda: COLOR_BG_BLUE) + attribute_fields: list[str] = field(default_factory=list) + sections: list[str] | None = None + language: str = "en" + has_risk_levels: bool = False + has_dimensions: bool = False + has_niveles: bool = False + has_weight: bool = False + + +FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = { + "prowler_threatscore": FrameworkConfig( + name="prowler_threatscore", + display_name="Prowler ThreatScore", + logo_filename=None, + primary_color=COLOR_BLUE, + secondary_color=COLOR_LIGHT_BLUE, + bg_color=COLOR_BG_BLUE, + attribute_fields=[ + "Title", + "Section", + "SubSection", + "LevelOfRisk", + "Weight", + "AttributeDescription", + "AdditionalInformation", + ], + sections=THREATSCORE_SECTIONS, + language="en", + has_risk_levels=True, + has_weight=True, + ), + "ens": FrameworkConfig( + name="ens", + display_name="ENS RD2022", + logo_filename="ens_logo.png", + primary_color=COLOR_ENS_ALTO, + secondary_color=COLOR_ENS_MEDIO, + bg_color=COLOR_BG_BLUE, + attribute_fields=[ + "IdGrupoControl", + "Marco", + "Categoria", + "DescripcionControl", + "Tipo", + "Nivel", + "Dimensiones", + "ModoEjecucion", + ], + sections=None, + language="es", + has_risk_levels=False, + has_dimensions=True, + has_niveles=True, + has_weight=False, + ), + "nis2": FrameworkConfig( + name="nis2", + display_name="NIS2 Directive", + logo_filename="nis2_logo.png", + primary_color=COLOR_NIS2_PRIMARY, + secondary_color=COLOR_NIS2_SECONDARY, + bg_color=COLOR_NIS2_BG_BLUE, + attribute_fields=[ + "Section", + "SubSection", + "Description", + ], + sections=NIS2_SECTIONS, + language="en", + has_risk_levels=False, + has_dimensions=False, + has_niveles=False, + has_weight=False, + ), +} + + +def get_framework_config(compliance_id: str) -> FrameworkConfig | None: + """ + Get framework configuration based on compliance ID. + + Args: + compliance_id (str): The compliance framework identifier (e.g., "prowler_threatscore_aws"). + + Returns: + FrameworkConfig | None: The framework configuration if found, None otherwise. + """ + compliance_lower = compliance_id.lower() + + if "threatscore" in compliance_lower: + return FRAMEWORK_REGISTRY["prowler_threatscore"] + if "ens" in compliance_lower: + return FRAMEWORK_REGISTRY["ens"] + if "nis2" in compliance_lower: + return FRAMEWORK_REGISTRY["nis2"] + + return None diff --git a/api/src/backend/tasks/jobs/reports/ens.py b/api/src/backend/tasks/jobs/reports/ens.py new file mode 100644 index 0000000000..56ee4bc40f --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/ens.py @@ -0,0 +1,1004 @@ +import os +from collections import defaultdict + +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from api.models import StatusChoices + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + get_requirement_metadata, +) +from .charts import create_horizontal_bar_chart, create_radar_chart +from .components import get_color_for_compliance +from .config import ( + COLOR_BG_BLUE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_ENS_ALTO, + COLOR_ENS_AUTO, + COLOR_ENS_BAJO, + COLOR_ENS_MANUAL, + COLOR_ENS_MEDIO, + COLOR_ENS_OPCIONAL, + COLOR_ENS_TIPO, + COLOR_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_SAFE, + COLOR_WHITE, + DIMENSION_KEYS, + DIMENSION_MAPPING, + DIMENSION_NAMES, + ENS_NIVEL_ORDER, + ENS_TIPO_ORDER, +) + + +class ENSReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for ENS RD2022 framework. + + This generator creates comprehensive PDF reports containing: + - Cover page with both Prowler and ENS logos + - Executive summary with overall compliance score + - Marco/CategorΓ­a analysis with charts + - Security dimensions radar chart + - Requirement type distribution + - Execution mode distribution + - Critical failed requirements (nivel alto) + - Requirements index + - Detailed findings for failed and manual requirements + """ + + def create_cover_page(self, data: ComplianceData) -> list: + """ + Create the ENS report cover page with both logos and legend. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Create logos side by side + prowler_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + ens_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/ens_logo.png" + ) + + prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch) + ens_logo = Image(ens_logo_path, width=1.5 * inch, height=2 * inch) + + logos_table = Table( + [[prowler_logo, ens_logo]], colWidths=[4 * inch, 2.5 * inch] + ) + logos_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("VALIGN", (1, 0), (1, 0), "TOP"), + ] + ) + ) + elements.append(logos_table) + elements.append(Spacer(1, 0.3 * inch)) + elements.append( + Paragraph("Informe de Cumplimiento ENS RD 311/2022", self.styles["title"]) + ) + elements.append(Spacer(1, 0.5 * inch)) + + # Compliance info table - use base class helper for consistency + info_rows = self._build_info_rows(data, language="es") + # Convert tuples to lists and wrap long text in Paragraphs + info_data = [] + for label, value in info_rows: + if label in ("Nombre:", "DescripciΓ³n:") and value: + info_data.append( + [label, Paragraph(value, self.styles["normal_center"])] + ) + else: + info_data.append([label, value]) + + info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) + info_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(info_table) + elements.append(Spacer(1, 0.5 * inch)) + + # Warning about excluded manual requirements + manual_count = self._count_manual_requirements(data) + auto_count = len( + [r for r in data.requirements if r.status != StatusChoices.MANUAL] + ) + + warning_text = ( + f"AVISO: Este informe no incluye los requisitos de ejecuciΓ³n manual. " + f"El compliance {data.compliance_id} contiene un total de " + f"{manual_count} requisitos manuales que no han sido evaluados " + f"automΓ‘ticamente y por tanto no estΓ‘n reflejados en las estadΓ­sticas de este reporte. " + f"El anΓ‘lisis se basa ΓΊnicamente en los {auto_count} requisitos automatizados." + ) + warning_paragraph = Paragraph(warning_text, self.styles["normal"]) + warning_table = Table([[warning_paragraph]], colWidths=[6 * inch]) + warning_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(1.0, 0.95, 0.7)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.4, 0.3, 0.0)), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.9, 0.7, 0.0)), + ("LEFTPADDING", (0, 0), (-1, -1), 15), + ("RIGHTPADDING", (0, 0), (-1, -1), 15), + ("TOPPADDING", (0, 0), (-1, -1), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 12), + ] + ) + ) + elements.append(warning_table) + elements.append(Spacer(1, 0.5 * inch)) + + # Legend + elements.append(self._create_legend()) + + return elements + + def create_executive_summary(self, data: ComplianceData) -> list: + """ + Create the executive summary with compliance metrics. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Resumen Ejecutivo", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Filter out manual requirements + auto_requirements = [ + r for r in data.requirements if r.status != StatusChoices.MANUAL + ] + total = len(auto_requirements) + passed = sum(1 for r in auto_requirements if r.status == StatusChoices.PASS) + failed = sum(1 for r in auto_requirements if r.status == StatusChoices.FAIL) + + overall_compliance = (passed / total * 100) if total > 0 else 0 + compliance_color = get_color_for_compliance(overall_compliance) + + # Summary table + summary_data = [["Nivel de Cumplimiento Global:", f"{overall_compliance:.2f}%"]] + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), compliance_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + elements.append(summary_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Counts table + counts_data = [ + ["Estado", "Cantidad", "Porcentaje"], + [ + "CUMPLE", + str(passed), + f"{(passed / total * 100):.1f}%" if total > 0 else "0%", + ], + [ + "NO CUMPLE", + str(failed), + f"{(failed / total * 100):.1f}%" if total > 0 else "0%", + ], + ["TOTAL", str(total), "100%"], + ] + counts_table = Table(counts_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch]) + counts_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("BACKGROUND", (0, 1), (0, 1), COLOR_SAFE), + ("TEXTCOLOR", (0, 1), (0, 1), COLOR_WHITE), + ("BACKGROUND", (0, 2), (0, 2), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE), + ("BACKGROUND", (0, 3), (0, 3), colors.Color(0.4, 0.4, 0.4)), + ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(counts_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Compliance by Nivel + elements.extend(self._create_nivel_table(data)) + + return elements + + def create_charts_section(self, data: ComplianceData) -> list: + """ + Create the charts section with Marco analysis and radar chart. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Critical failed requirements section (nivel alto) - new page + elements.append(PageBreak()) + elements.extend(self._create_critical_failed_section(data)) + + # Marco y CategorΓ­as chart - new page + elements.append(PageBreak()) + elements.append( + Paragraph("AnΓ‘lisis por Marcos y CategorΓ­as", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + marco_cat_chart = self._create_marco_category_chart(data) + marco_cat_image = Image(marco_cat_chart, width=7 * inch, height=5.5 * inch) + elements.append(marco_cat_image) + + # Security dimensions radar chart - new page + elements.append(PageBreak()) + elements.append( + Paragraph("AnΓ‘lisis por Dimensiones de Seguridad", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + radar_buffer = self._create_dimensions_radar_chart(data) + radar_image = Image(radar_buffer, width=6 * inch, height=6 * inch) + elements.append(radar_image) + elements.append(PageBreak()) + + # Type distribution + elements.extend(self._create_tipo_section(data)) + + return elements + + def create_requirements_index(self, data: ComplianceData) -> list: + """ + Create the requirements index organized by Marco and Categoria. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Índice de Requisitos", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Organize by Marco and Categoria + marcos = {} + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + marco = getattr(m, "Marco", "Otros") + categoria = getattr(m, "Categoria", "Sin categorΓ­a") + descripcion = getattr(m, "DescripcionControl", req.description) + nivel = getattr(m, "Nivel", "") + + if marco not in marcos: + marcos[marco] = {} + if categoria not in marcos[marco]: + marcos[marco][categoria] = [] + + marcos[marco][categoria].append( + { + "id": req.id, + "descripcion": descripcion, + "nivel": nivel, + "status": req.status, + } + ) + + for marco_name, categorias in marcos.items(): + elements.append(Paragraph(f"Marco: {marco_name}", self.styles["h2"])) + + for categoria_name, reqs in categorias.items(): + elements.append(Paragraph(f"{categoria_name}", self.styles["h3"])) + + for req in reqs: + status_indicator = ( + "βœ“" if req["status"] == StatusChoices.PASS else "βœ—" + ) + nivel_badge = f"[{req['nivel'].upper()}]" if req["nivel"] else "" + elements.append( + Paragraph( + f"{status_indicator} {req['id']} {nivel_badge}", + self.styles["normal"], + ) + ) + + elements.append(Spacer(1, 0.1 * inch)) + + return elements + + def get_footer_text(self, page_num: int) -> tuple[str, str]: + """ + Get Spanish footer text for ENS report. + + Args: + page_num: Current page number. + + Returns: + Tuple of (left_text, right_text) for the footer. + """ + return f"PΓ‘gina {page_num}", "Powered by Prowler" + + def _count_manual_requirements(self, data: ComplianceData) -> int: + """Count requirements with manual execution mode.""" + return sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL) + + def _create_legend(self) -> Table: + """Create the ENS values legend table.""" + legend_text = """ + Nivel (Criticidad del requisito):
+ β€’ Alto: Requisitos crΓ­ticos que deben cumplirse prioritariamente
+ β€’ Medio: Requisitos importantes con impacto moderado
+ β€’ Bajo: Requisitos complementarios de menor criticidad
+ β€’ Opcional: Recomendaciones adicionales no obligatorias
+
+ Tipo (ClasificaciΓ³n del requisito):
+ β€’ Requisito: ObligaciΓ³n establecida por el ENS
+ β€’ Refuerzo: Medida adicional que refuerza un requisito
+ β€’ RecomendaciΓ³n: Buena prΓ‘ctica sugerida
+ β€’ Medida: AcciΓ³n concreta de implementaciΓ³n
+
+ Dimensiones de Seguridad:
+ β€’ C (Confidencialidad): ProtecciΓ³n contra accesos no autorizados
+ β€’ I (Integridad): GarantΓ­a de exactitud y completitud
+ β€’ T (Trazabilidad): Capacidad de rastrear acciones
+ β€’ A (Autenticidad): VerificaciΓ³n de identidad
+ β€’ D (Disponibilidad): Acceso cuando se necesita + """ + legend_paragraph = Paragraph(legend_text, self.styles["normal"]) + legend_table = Table([[legend_paragraph]], colWidths=[6.5 * inch]) + legend_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_BG_BLUE), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_GRAY), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("VALIGN", (0, 0), (0, 0), "TOP"), + ("BOX", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.8)), + ("LEFTPADDING", (0, 0), (-1, -1), 15), + ("RIGHTPADDING", (0, 0), (-1, -1), 15), + ("TOPPADDING", (0, 0), (-1, -1), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 12), + ] + ) + ) + return legend_table + + def _create_nivel_table(self, data: ComplianceData) -> list: + """Create compliance by nivel table.""" + elements = [] + elements.append(Paragraph("Cumplimiento por Nivel", self.styles["h2"])) + + nivel_data = defaultdict(lambda: {"passed": 0, "total": 0}) + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + nivel = getattr(m, "Nivel", "").lower() + nivel_data[nivel]["total"] += 1 + if req.status == StatusChoices.PASS: + nivel_data[nivel]["passed"] += 1 + + table_data = [["Nivel", "Cumplidos", "Total", "Porcentaje"]] + nivel_colors = { + "alto": COLOR_ENS_ALTO, + "medio": COLOR_ENS_MEDIO, + "bajo": COLOR_ENS_BAJO, + "opcional": COLOR_ENS_OPCIONAL, + } + + for nivel in ENS_NIVEL_ORDER: + if nivel in nivel_data: + d = nivel_data[nivel] + pct = (d["passed"] / d["total"] * 100) if d["total"] > 0 else 0 + table_data.append( + [ + nivel.capitalize(), + str(d["passed"]), + str(d["total"]), + f"{pct:.1f}%", + ] + ) + + table = Table( + table_data, colWidths=[1.5 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + + # Color nivel column + for idx, nivel in enumerate(ENS_NIVEL_ORDER): + if nivel in nivel_data: + row_idx = idx + 1 + if row_idx < len(table_data): + color = nivel_colors.get(nivel, COLOR_GRAY) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, row_idx), (0, row_idx), color), + ("TEXTCOLOR", (0, row_idx), (0, row_idx), COLOR_WHITE), + ] + ) + ) + + elements.append(table) + return elements + + def _create_marco_category_chart(self, data: ComplianceData): + """Create Marco - CategorΓ­a combined compliance chart.""" + # Group by marco + categoria combination + marco_cat_scores = defaultdict(lambda: {"passed": 0, "total": 0}) + + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + marco = getattr(m, "Marco", "otros") + categoria = getattr(m, "Categoria", "sin categorΓ­a") + # Combined key: "marco - categorΓ­a" + key = f"{marco} - {categoria}" + marco_cat_scores[key]["total"] += 1 + if req.status == StatusChoices.PASS: + marco_cat_scores[key]["passed"] += 1 + + labels = [] + values = [] + for key, scores in sorted(marco_cat_scores.items()): + if scores["total"] > 0: + pct = (scores["passed"] / scores["total"]) * 100 + labels.append(key) + values.append(pct) + + return create_horizontal_bar_chart( + labels=labels, + values=values, + xlabel="Porcentaje de Cumplimiento (%)", + ) + + def _create_dimensions_radar_chart(self, data: ComplianceData): + """Create security dimensions radar chart.""" + dimension_scores = {dim: {"passed": 0, "total": 0} for dim in DIMENSION_KEYS} + + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + dimensiones = getattr(m, "Dimensiones", []) + if isinstance(dimensiones, str): + dimensiones = [d.strip().lower() for d in dimensiones.split(",")] + elif isinstance(dimensiones, list): + dimensiones = [ + d.lower() if isinstance(d, str) else d for d in dimensiones + ] + + for dim in dimensiones: + if dim in dimension_scores: + dimension_scores[dim]["total"] += 1 + if req.status == StatusChoices.PASS: + dimension_scores[dim]["passed"] += 1 + + values = [] + for dim in DIMENSION_KEYS: + scores = dimension_scores[dim] + if scores["total"] > 0: + pct = (scores["passed"] / scores["total"]) * 100 + else: + pct = 100 + values.append(pct) + + return create_radar_chart( + labels=DIMENSION_NAMES, + values=values, + color="#2196F3", + ) + + def _create_tipo_section(self, data: ComplianceData) -> list: + """Create type distribution section.""" + elements = [] + elements.append( + Paragraph("DistribuciΓ³n por Tipo de Requisito", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + tipo_data = defaultdict(lambda: {"passed": 0, "total": 0}) + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + tipo = getattr(m, "Tipo", "").lower() + tipo_data[tipo]["total"] += 1 + if req.status == StatusChoices.PASS: + tipo_data[tipo]["passed"] += 1 + + table_data = [["Tipo", "Cumplidos", "Total", "Porcentaje"]] + for tipo in ENS_TIPO_ORDER: + if tipo in tipo_data: + d = tipo_data[tipo] + pct = (d["passed"] / d["total"] * 100) if d["total"] > 0 else 0 + table_data.append( + [ + tipo.capitalize(), + str(d["passed"]), + str(d["total"]), + f"{pct:.1f}%", + ] + ) + + table = Table( + table_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(table) + return elements + + def _create_critical_failed_section(self, data: ComplianceData) -> list: + """Create section for critical failed requirements (nivel alto).""" + elements = [] + + elements.append( + Paragraph("Requisitos CrΓ­ticos No Cumplidos", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Get failed requirements with nivel alto + critical_failed = [] + for req in data.requirements: + if req.status != StatusChoices.FAIL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + nivel = getattr(m, "Nivel", "").lower() + if nivel == "alto": + critical_failed.append( + { + "id": req.id, + "descripcion": getattr( + m, "DescripcionControl", req.description + ), + "marco": getattr(m, "Marco", ""), + "categoria": getattr(m, "Categoria", ""), + "tipo": getattr(m, "Tipo", ""), + } + ) + + if not critical_failed: + elements.append( + Paragraph( + "βœ… No hay requisitos crΓ­ticos (nivel ALTO) que hayan fallado.", + self.styles["normal"], + ) + ) + return elements + + elements.append( + Paragraph( + f"Se encontraron {len(critical_failed)} requisitos de nivel ALTO " + "que no cumplen y requieren atenciΓ³n inmediata:", + self.styles["normal"], + ) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Create table - use a cell style without leftIndent for proper alignment + cell_style = ParagraphStyle( + "CellStyle", + parent=self.styles["normal"], + leftIndent=0, + spaceBefore=0, + spaceAfter=0, + ) + table_data: list = [["ID Requisito", "Marco", "CategorΓ­a", "Tipo"]] + for req in critical_failed: + table_data.append( + [ + req["id"], + req["marco"], + Paragraph(req["categoria"], cell_style), + req["tipo"].capitalize() if req["tipo"] else "", + ] + ) + + table = Table( + table_data, + colWidths=[2 * inch, 1.5 * inch, 1.8 * inch, 1.2 * inch], + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_ENS_ALTO), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, colors.Color(1.0, 0.95, 0.95)], + ), + ] + ) + ) + elements.append(table) + + return elements + + def create_detailed_findings(self, data: ComplianceData, **kwargs) -> list: + """ + Create detailed findings section with ENS-specific format. + + Shows each failed requirement with: + - Requirement ID as title + - Status, Nivel, Tipo, ModoEjecucion badges + - Dimensiones badges + - Info table with DescripciΓ³n, Marco, CategorΓ­a, etc. + + Args: + data: Aggregated compliance data. + **kwargs: Additional options. + + Returns: + List of ReportLab elements. + """ + elements = [] + include_manual = kwargs.get("include_manual", True) + + elements.append(Paragraph("Detalle de Requisitos", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Get failed requirements, and optionally manual requirements + if include_manual: + failed_requirements = [ + r + for r in data.requirements + if r.status in (StatusChoices.FAIL, StatusChoices.MANUAL) + ] + else: + failed_requirements = [ + r for r in data.requirements if r.status == StatusChoices.FAIL + ] + + if not failed_requirements: + elements.append( + Paragraph( + "No hay requisitos fallidos para mostrar.", + self.styles["normal"], + ) + ) + return elements + + elements.append( + Paragraph( + f"Se muestran {len(failed_requirements)} requisitos que requieren " + "atenciΓ³n:", + self.styles["normal"], + ) + ) + elements.append(Spacer(1, 0.3 * inch)) + + # Nivel colors mapping + nivel_colors = { + "alto": COLOR_ENS_ALTO, + "medio": COLOR_ENS_MEDIO, + "bajo": COLOR_ENS_BAJO, + "opcional": COLOR_ENS_OPCIONAL, + } + + for req in failed_requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + + if not m: + continue + + nivel = getattr(m, "Nivel", "").lower() + tipo = getattr(m, "Tipo", "") + modo = getattr(m, "ModoEjecucion", "") + dimensiones = getattr(m, "Dimensiones", []) + descripcion = getattr(m, "DescripcionControl", req.description) + marco = getattr(m, "Marco", "") + categoria = getattr(m, "Categoria", "") + id_grupo = getattr(m, "IdGrupoControl", "") + + # Requirement ID title + req_title = Table([[req.id]], colWidths=[6.5 * inch]) + req_title.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_BG_BLUE), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_BLUE), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 14), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("BOX", (0, 0), (-1, -1), 2, COLOR_BLUE), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + elements.append(req_title) + elements.append(Spacer(1, 0.15 * inch)) + + # Status and Nivel badges row + status_color = COLOR_HIGH_RISK # FAIL + nivel_color = nivel_colors.get(nivel, COLOR_GRAY) + + badges_row1 = [ + ["State:", "FAIL", "", f"Nivel: {nivel.upper()}"], + ] + badges_table1 = Table( + badges_row1, + colWidths=[0.7 * inch, 0.8 * inch, 1.5 * inch, 1.5 * inch], + ) + badges_table1.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (1, 0), (1, 0), status_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("BACKGROUND", (3, 0), (3, 0), nivel_color), + ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE), + ("FONTNAME", (3, 0), (3, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (1, 0), 0.5, colors.black), + ("GRID", (3, 0), (3, 0), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(badges_table1) + elements.append(Spacer(1, 0.1 * inch)) + + # Tipo and Modo badges row + tipo_display = f"☰ {tipo.capitalize()}" if tipo else "N/A" + modo_display = f"☰ {modo.capitalize()}" if modo else "N/A" + modo_color = ( + COLOR_ENS_AUTO if modo.lower() == "automatico" else COLOR_ENS_MANUAL + ) + + badges_row2 = [[tipo_display, "", modo_display]] + badges_table2 = Table( + badges_row2, colWidths=[2.2 * inch, 0.5 * inch, 2.2 * inch] + ) + badges_table2.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_ENS_TIPO), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (2, 0), (2, 0), modo_color), + ("TEXTCOLOR", (2, 0), (2, 0), COLOR_WHITE), + ("FONTNAME", (2, 0), (2, 0), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (0, 0), 0.5, colors.black), + ("GRID", (2, 0), (2, 0), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(badges_table2) + elements.append(Spacer(1, 0.1 * inch)) + + # Dimensiones badges + if dimensiones: + if isinstance(dimensiones, str): + dim_list = [d.strip().lower() for d in dimensiones.split(",")] + else: + dim_list = [ + d.lower() if isinstance(d, str) else str(d) for d in dimensiones + ] + + dim_badges = [] + for dim in dim_list: + if dim in DIMENSION_MAPPING: + abbrev, dim_color = DIMENSION_MAPPING[dim] + dim_badges.append((abbrev, dim_color)) + + if dim_badges: + dim_label = [["Dimensiones:"] + [b[0] for b in dim_badges]] + dim_widths = [1.2 * inch] + [0.4 * inch] * len(dim_badges) + dim_table = Table(dim_label, colWidths=dim_widths) + + dim_styles = [ + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + for idx, (_, dim_color) in enumerate(dim_badges): + col_idx = idx + 1 + dim_styles.extend( + [ + ( + "BACKGROUND", + (col_idx, 0), + (col_idx, 0), + dim_color, + ), + ("TEXTCOLOR", (col_idx, 0), (col_idx, 0), COLOR_WHITE), + ("FONTNAME", (col_idx, 0), (col_idx, 0), "FiraCode"), + ("GRID", (col_idx, 0), (col_idx, 0), 0.5, colors.black), + ] + ) + + dim_table.setStyle(TableStyle(dim_styles)) + elements.append(dim_table) + elements.append(Spacer(1, 0.15 * inch)) + + # Info table - use Paragraph for text wrapping + info_data = [ + [ + "DescripciΓ³n:", + Paragraph(descripcion, self.styles["normal_center"]), + ], + ["Marco:", marco], + [ + "CategorΓ­a:", + Paragraph(categoria, self.styles["normal_center"]), + ], + ["ID Grupo Control:", id_grupo], + ] + info_table = Table(info_data, colWidths=[2 * inch, 4.5 * inch]) + info_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("FONTSIZE", (0, 0), (0, -1), 10), + ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("FONTSIZE", (1, 0), (1, -1), 10), + ("ALIGN", (0, 0), (0, -1), "LEFT"), + ("ALIGN", (1, 0), (1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(info_table) + elements.append(Spacer(1, 0.3 * inch)) + + return elements diff --git a/api/src/backend/tasks/jobs/reports/nis2.py b/api/src/backend/tasks/jobs/reports/nis2.py new file mode 100644 index 0000000000..4ac5fa3d15 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/nis2.py @@ -0,0 +1,471 @@ +import os +from collections import defaultdict + +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from api.models import StatusChoices + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + get_requirement_metadata, +) +from .charts import create_horizontal_bar_chart, get_chart_color_for_percentage +from .config import ( + COLOR_BORDER_GRAY, + COLOR_DARK_GRAY, + COLOR_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_NIS2_BG_BLUE, + COLOR_NIS2_PRIMARY, + COLOR_SAFE, + COLOR_WHITE, + NIS2_SECTION_TITLES, + NIS2_SECTIONS, +) + + +def _extract_section_number(section_string: str) -> str: + """Extract the section number from a full NIS2 section title. + + NIS2 section strings are formatted like: + "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS..." + + This function extracts just the leading number. + + Args: + section_string: Full section title string. + + Returns: + Section number as string (e.g., "1", "2", "11"). + """ + if not section_string: + return "Other" + parts = section_string.split() + if parts and parts[0].isdigit(): + return parts[0] + return "Other" + + +class NIS2ReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for NIS2 Directive (EU) 2022/2555. + + This generator creates comprehensive PDF reports containing: + - Cover page with both Prowler and NIS2 logos + - Executive summary with overall compliance score + - Section analysis with horizontal bar chart + - SubSection breakdown table + - Critical failed requirements + - Requirements index organized by section and subsection + - Detailed findings for failed requirements + """ + + def create_cover_page(self, data: ComplianceData) -> list: + """ + Create the NIS2 report cover page with both logos. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Create logos side by side + prowler_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + nis2_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/nis2_logo.png" + ) + + prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch) + nis2_logo = Image(nis2_logo_path, width=2.3 * inch, height=1.5 * inch) + + logos_table = Table( + [[prowler_logo, nis2_logo]], colWidths=[4 * inch, 2.5 * inch] + ) + logos_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("VALIGN", (1, 0), (1, 0), "MIDDLE"), + ] + ) + ) + elements.append(logos_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Title + title = Paragraph( + "NIS2 Compliance Report
Directive (EU) 2022/2555", + self.styles["title"], + ) + elements.append(title) + elements.append(Spacer(1, 0.3 * inch)) + + # Compliance metadata table - use base class helper for consistency + info_rows = self._build_info_rows(data, language="en") + # Convert tuples to lists and wrap long text in Paragraphs + metadata_data = [] + for label, value in info_rows: + if label in ("Name:", "Description:") and value: + metadata_data.append( + [label, Paragraph(value, self.styles["normal_center"])] + ) + else: + metadata_data.append([label, value]) + + metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch]) + metadata_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_NIS2_PRIMARY), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), COLOR_NIS2_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(metadata_table) + + return elements + + def create_executive_summary(self, data: ComplianceData) -> list: + """ + Create the executive summary with compliance metrics. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Executive Summary", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + # Calculate statistics + total = len(data.requirements) + passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS) + failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL) + manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL) + + # Calculate compliance excluding manual + evaluated = passed + failed + overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100 + + # Summary statistics table + summary_data = [ + ["Metric", "Value"], + ["Total Requirements", str(total)], + ["Passed βœ“", str(passed)], + ["Failed βœ—", str(failed)], + ["Manual βŠ™", str(manual)], + ["Overall Compliance", f"{overall_compliance:.1f}%"], + ] + + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE), + ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE), + ("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE), + ("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY), + ("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"), + ("FONTSIZE", (0, 0), (-1, 0), 12), + ("FONTSIZE", (0, 1), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, 0), 10), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), + ( + "ROWBACKGROUNDS", + (1, 1), + (1, -1), + [COLOR_WHITE, COLOR_NIS2_BG_BLUE], + ), + ] + ) + ) + elements.append(summary_table) + + return elements + + def create_charts_section(self, data: ComplianceData) -> list: + """ + Create the charts section with section analysis. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Section chart + elements.append(Paragraph("Compliance by Section", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + "The following chart shows compliance percentage for each main section " + "of the NIS2 directive:", + self.styles["normal_center"], + ) + ) + elements.append(Spacer(1, 0.1 * inch)) + + chart_buffer = self._create_section_chart(data) + chart_buffer.seek(0) + chart_image = Image(chart_buffer, width=6.5 * inch, height=5 * inch) + elements.append(chart_image) + elements.append(PageBreak()) + + # SubSection breakdown table + elements.append(Paragraph("SubSection Breakdown", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + subsection_table = self._create_subsection_table(data) + elements.append(subsection_table) + + return elements + + def create_requirements_index(self, data: ComplianceData) -> list: + """ + Create the requirements index organized by section and subsection. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Requirements Index", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + # Organize by section number and subsection + sections = {} + for req in data.requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + full_section = getattr(m, "Section", "Other") + # Extract section number from full title (e.g., "1 POLICY..." -> "1") + section_num = _extract_section_number(full_section) + subsection = getattr(m, "SubSection", "") + description = getattr(m, "Description", req.description) + + if section_num not in sections: + sections[section_num] = {} + if subsection not in sections[section_num]: + sections[section_num][subsection] = [] + + sections[section_num][subsection].append( + { + "id": req.id, + "description": description, + "status": req.status, + } + ) + + # Sort by NIS2 section order + for section in NIS2_SECTIONS: + if section not in sections: + continue + + section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}") + elements.append(Paragraph(section_title, self.styles["h2"])) + + for subsection_name, reqs in sections[section].items(): + if subsection_name: + # Truncate long subsection names for display + display_subsection = ( + subsection_name[:80] + "..." + if len(subsection_name) > 80 + else subsection_name + ) + elements.append(Paragraph(display_subsection, self.styles["h3"])) + + for req in reqs: + status_indicator = ( + "βœ“" if req["status"] == StatusChoices.PASS else "βœ—" + ) + if req["status"] == StatusChoices.MANUAL: + status_indicator = "βŠ™" + + desc = ( + req["description"][:60] + "..." + if len(req["description"]) > 60 + else req["description"] + ) + elements.append( + Paragraph( + f"{status_indicator} {req['id']}: {desc}", + self.styles["normal"], + ) + ) + + elements.append(Spacer(1, 0.1 * inch)) + + return elements + + def _create_section_chart(self, data: ComplianceData): + """ + Create the section compliance chart. + + Args: + data: Aggregated compliance data. + + Returns: + BytesIO buffer containing the chart image. + """ + section_scores = defaultdict(lambda: {"passed": 0, "total": 0}) + + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + full_section = getattr(m, "Section", "Other") + # Extract section number from full title (e.g., "1 POLICY..." -> "1") + section_num = _extract_section_number(full_section) + section_scores[section_num]["total"] += 1 + if req.status == StatusChoices.PASS: + section_scores[section_num]["passed"] += 1 + + # Build labels and values in NIS2 section order + labels = [] + values = [] + for section in NIS2_SECTIONS: + if section in section_scores and section_scores[section]["total"] > 0: + scores = section_scores[section] + pct = (scores["passed"] / scores["total"]) * 100 + section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}") + labels.append(section_title) + values.append(pct) + + return create_horizontal_bar_chart( + labels=labels, + values=values, + xlabel="Compliance (%)", + color_func=get_chart_color_for_percentage, + ) + + def _create_subsection_table(self, data: ComplianceData) -> Table: + """ + Create the subsection breakdown table. + + Args: + data: Aggregated compliance data. + + Returns: + ReportLab Table element. + """ + subsection_scores = defaultdict(lambda: {"passed": 0, "failed": 0, "manual": 0}) + + for req in data.requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + full_section = getattr(m, "Section", "") + subsection = getattr(m, "SubSection", "") + # Use section number + subsection for grouping + section_num = _extract_section_number(full_section) + # Create a shorter key using section number + if subsection: + # Extract subsection number if present (e.g., "1.1 Policy..." -> "1.1") + subsection_parts = subsection.split() + if subsection_parts: + key = subsection_parts[0] # Just the number like "1.1" + else: + key = f"{section_num}" + else: + key = section_num + + if req.status == StatusChoices.PASS: + subsection_scores[key]["passed"] += 1 + elif req.status == StatusChoices.FAIL: + subsection_scores[key]["failed"] += 1 + else: + subsection_scores[key]["manual"] += 1 + + table_data = [["Section", "Passed", "Failed", "Manual", "Compliance"]] + for key, scores in sorted( + subsection_scores.items(), key=lambda x: self._sort_section_key(x[0]) + ): + total = scores["passed"] + scores["failed"] + pct = (scores["passed"] / total * 100) if total > 0 else 100 + table_data.append( + [ + key, + str(scores["passed"]), + str(scores["failed"]), + str(scores["manual"]), + f"{pct:.1f}%", + ] + ) + + table = Table( + table_data, + colWidths=[1.2 * inch, 0.9 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch], + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_NIS2_BG_BLUE], + ), + ] + ) + ) + + return table + + def _sort_section_key(self, key: str) -> tuple: + """Sort section keys numerically (e.g., 1, 1.1, 1.2, 2, 11).""" + parts = key.split(".") + result = [] + for part in parts: + try: + result.append(int(part)) + except ValueError: + result.append(float("inf")) + return tuple(result) diff --git a/api/src/backend/tasks/jobs/reports/threatscore.py b/api/src/backend/tasks/jobs/reports/threatscore.py new file mode 100644 index 0000000000..e23085b1c3 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/threatscore.py @@ -0,0 +1,509 @@ +import gc + +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from api.models import StatusChoices + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + get_requirement_metadata, +) +from .charts import create_vertical_bar_chart, get_chart_color_for_percentage +from .components import get_color_for_compliance, get_color_for_weight +from .config import COLOR_HIGH_RISK, COLOR_WHITE + + +class ThreatScoreReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for Prowler ThreatScore framework. + + This generator creates comprehensive PDF reports containing: + - Compliance overview and metadata + - Section-by-section compliance scores with charts + - Overall ThreatScore calculation + - Critical failed requirements + - Detailed findings for each requirement + """ + + def create_executive_summary(self, data: ComplianceData) -> list: + """ + Create the executive summary section with ThreatScore calculation. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Compliance Score by Sections", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Create section score chart + chart_buffer = self._create_section_score_chart(data) + chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) + elements.append(chart_image) + + # Calculate overall ThreatScore + overall_compliance = self._calculate_threatscore(data) + + elements.append(Spacer(1, 0.3 * inch)) + + # Summary table + summary_data = [["ThreatScore:", f"{overall_compliance:.2f}%"]] + compliance_color = get_color_for_compliance(overall_compliance) + + summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.white), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), compliance_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + + elements.append(summary_table) + + return elements + + def _build_body_sections(self, data: ComplianceData) -> list: + """Override section order: Requirements Index before Critical Requirements.""" + elements = [] + + # Page break to separate from executive summary + elements.append(PageBreak()) + + # Requirements index first + elements.extend(self.create_requirements_index(data)) + + # Critical requirements section (already starts with PageBreak internally) + elements.extend(self.create_charts_section(data)) + elements.append(PageBreak()) + gc.collect() + + return elements + + def create_charts_section(self, data: ComplianceData) -> list: + """ + Create the critical failed requirements section. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + min_risk_level = getattr(self, "_min_risk_level", 4) + + # Start on a new page + elements.append(PageBreak()) + elements.append( + Paragraph("Top Requirements by Level of Risk", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + f"Critical Failed Requirements (Risk Level β‰₯ {min_risk_level})", + self.styles["h2"], + ) + ) + elements.append(Spacer(1, 0.2 * inch)) + + critical_failed = self._get_critical_failed_requirements(data, min_risk_level) + + if not critical_failed: + elements.append( + Paragraph( + "βœ… No critical failed requirements found. Great job!", + self.styles["normal"], + ) + ) + else: + elements.append( + Paragraph( + f"Found {len(critical_failed)} critical failed requirements " + "that require immediate attention:", + self.styles["normal"], + ) + ) + elements.append(Spacer(1, 0.5 * inch)) + + table = self._create_critical_requirements_table(critical_failed) + elements.append(table) + + # Immediate action required banner + elements.append(Spacer(1, 0.3 * inch)) + elements.append(self._create_action_required_banner()) + + return elements + + def create_requirements_index(self, data: ComplianceData) -> list: + """ + Create the requirements index organized by section and subsection. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Requirements Index", self.styles["h1"])) + + # Organize requirements by section and subsection + sections = {} + for req_id in data.attributes_by_requirement_id: + m = get_requirement_metadata(req_id, data.attributes_by_requirement_id) + if m: + section = getattr(m, "Section", "N/A") + subsection = getattr(m, "SubSection", "N/A") + title = getattr(m, "Title", "N/A") + + if section not in sections: + sections[section] = {} + if subsection not in sections[section]: + sections[section][subsection] = [] + + sections[section][subsection].append({"id": req_id, "title": title}) + + section_num = 1 + for section_name, subsections in sections.items(): + elements.append( + Paragraph(f"{section_num}. {section_name}", self.styles["h2"]) + ) + + for subsection_name, requirements in subsections.items(): + elements.append(Paragraph(f"{subsection_name}", self.styles["h3"])) + + for req in requirements: + elements.append( + Paragraph( + f"{req['id']} - {req['title']}", self.styles["normal"] + ) + ) + + section_num += 1 + elements.append(Spacer(1, 0.1 * inch)) + + return elements + + def _create_section_score_chart(self, data: ComplianceData): + """ + Create the section compliance score chart using weighted ThreatScore formula. + + The section score uses the same weighted formula as the overall ThreatScore: + Score = Ξ£(rate_i * total_findings_i * weight_i * rfac_i) / Ξ£(total_findings_i * weight_i * rfac_i) + Where rfac_i = 1 + 0.25 * risk_level + + Sections without findings are shown with 100% score. + + Args: + data: Aggregated compliance data. + + Returns: + BytesIO buffer containing the chart image. + """ + # First, collect ALL sections from requirements (including those without findings) + all_sections = set() + sections_data = {} + + for req in data.requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + section = getattr(m, "Section", "Other") + all_sections.add(section) + + # Only calculate scores for requirements with findings + if req.total_findings == 0: + continue + + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) + # Ensure numeric types for calculations (compliance data may have str) + try: + risk_level = int(risk_level_raw) if risk_level_raw else 0 + except (ValueError, TypeError): + risk_level = 0 + try: + weight = int(weight_raw) if weight_raw else 0 + except (ValueError, TypeError): + weight = 0 + + # ThreatScore formula components + rate_i = req.passed_findings / req.total_findings + rfac_i = 1 + 0.25 * risk_level + + if section not in sections_data: + sections_data[section] = { + "numerator": 0, + "denominator": 0, + } + + sections_data[section]["numerator"] += ( + rate_i * req.total_findings * weight * rfac_i + ) + sections_data[section]["denominator"] += ( + req.total_findings * weight * rfac_i + ) + + # Calculate percentages for all sections + labels = [] + values = [] + for section in sorted(all_sections): + if section in sections_data and sections_data[section]["denominator"] > 0: + pct = ( + sections_data[section]["numerator"] + / sections_data[section]["denominator"] + ) * 100 + else: + # Sections without findings get 100% + pct = 100.0 + labels.append(section) + values.append(pct) + + return create_vertical_bar_chart( + labels=labels, + values=values, + ylabel="Compliance Score (%)", + xlabel="", + color_func=get_chart_color_for_percentage, + rotation=0, + ) + + def _calculate_threatscore(self, data: ComplianceData) -> float: + """ + Calculate the overall ThreatScore using the weighted formula. + + Args: + data: Aggregated compliance data. + + Returns: + Overall ThreatScore percentage. + """ + numerator = 0 + denominator = 0 + has_findings = False + + for req in data.requirements: + if req.total_findings == 0: + continue + + has_findings = True + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + + if m: + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) + # Ensure numeric types for calculations (compliance data may have str) + try: + risk_level = int(risk_level_raw) if risk_level_raw else 0 + except (ValueError, TypeError): + risk_level = 0 + try: + weight = int(weight_raw) if weight_raw else 0 + except (ValueError, TypeError): + weight = 0 + + rate_i = req.passed_findings / req.total_findings + rfac_i = 1 + 0.25 * risk_level + + numerator += rate_i * req.total_findings * weight * rfac_i + denominator += req.total_findings * weight * rfac_i + + if not has_findings: + return 100.0 + if denominator > 0: + return (numerator / denominator) * 100 + return 0.0 + + def _get_critical_failed_requirements( + self, data: ComplianceData, min_risk_level: int + ) -> list[dict]: + """ + Get critical failed requirements sorted by risk level and weight. + + Args: + data: Aggregated compliance data. + min_risk_level: Minimum risk level threshold. + + Returns: + List of critical failed requirement dictionaries. + """ + critical = [] + + for req in data.requirements: + if req.status != StatusChoices.FAIL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + + if m: + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) + # Ensure numeric types for calculations (compliance data may have str) + try: + risk_level = int(risk_level_raw) if risk_level_raw else 0 + except (ValueError, TypeError): + risk_level = 0 + try: + weight = int(weight_raw) if weight_raw else 0 + except (ValueError, TypeError): + weight = 0 + + if risk_level >= min_risk_level: + critical.append( + { + "id": req.id, + "risk_level": risk_level, + "weight": weight, + "title": getattr(m, "Title", "N/A"), + "section": getattr(m, "Section", "N/A"), + } + ) + + critical.sort(key=lambda x: (x["risk_level"], x["weight"]), reverse=True) + return critical + + def _create_critical_requirements_table(self, critical_requirements: list) -> Table: + """ + Create the critical requirements table. + + Args: + critical_requirements: List of critical requirement dictionaries. + + Returns: + ReportLab Table element. + """ + table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] + + for req in critical_requirements: + title = req["title"] + if len(title) > 50: + title = title[:47] + "..." + + table_data.append( + [ + str(req["risk_level"]), + str(req["weight"]), + req["id"], + title, + req["section"], + ] + ) + + table = Table( + table_data, + colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch], + ) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("BACKGROUND", (0, 1), (0, -1), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 1), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 1), (0, -1), "FiraCode"), + ("ALIGN", (0, 1), (0, -1), "CENTER"), + ("FONTSIZE", (0, 1), (0, -1), 12), + ("ALIGN", (1, 1), (1, -1), "CENTER"), + ("FONTNAME", (1, 1), (1, -1), "FiraCode"), + ("FONTNAME", (2, 1), (2, -1), "FiraCode"), + ("FONTSIZE", (2, 1), (2, -1), 9), + ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), + ("FONTSIZE", (3, 1), (-1, -1), 8), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("BACKGROUND", (1, 1), (-1, -1), colors.Color(0.98, 0.98, 0.98)), + ] + ) + ) + + # Color weight column based on value + for idx, req in enumerate(critical_requirements): + row_idx = idx + 1 + weight_color = get_color_for_weight(req["weight"]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), + ("TEXTCOLOR", (1, row_idx), (1, row_idx), COLOR_WHITE), + ] + ) + ) + + return table + + def _create_action_required_banner(self) -> Table: + """ + Create the 'Immediate Action Required' banner for critical requirements. + + Returns: + ReportLab Table element styled as a red-bordered alert banner. + """ + banner_style = ParagraphStyle( + "ActionRequired", + fontName="PlusJakartaSans", + fontSize=11, + textColor=COLOR_HIGH_RISK, + leading=16, + ) + + banner_content = Paragraph( + "IMMEDIATE ACTION REQUIRED:
" + "These requirements have the highest risk levels and have failed " + "compliance checks. Please prioritize addressing these issues to " + "improve your security posture.", + banner_style, + ) + + banner_table = Table( + [[banner_content]], + colWidths=[6.5 * inch], + ) + banner_table.setStyle( + TableStyle( + [ + ( + "BACKGROUND", + (0, 0), + (0, 0), + colors.Color(0.98, 0.92, 0.92), + ), + ("BOX", (0, 0), (0, 0), 2, COLOR_HIGH_RISK), + ("LEFTPADDING", (0, 0), (0, 0), 20), + ("RIGHTPADDING", (0, 0), (0, 0), 20), + ("TOPPADDING", (0, 0), (0, 0), 15), + ("BOTTOMPADDING", (0, 0), (0, 0), 15), + ] + ) + ) + + return banner_table diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 9e40e2df03..9697359065 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -45,6 +45,7 @@ from api.models import ( ResourceTag, Scan, ScanCategorySummary, + ScanGroupSummary, ScanSummary, StateChoices, ) @@ -127,6 +128,50 @@ def aggregate_category_counts( cache[key]["new_failed"] += 1 +def aggregate_resource_group_counts( + resource_group: str | None, + severity: str, + status: str, + delta: str | None, + muted: bool, + resource_uid: str, + cache: dict[tuple[str, str], dict[str, int]], + group_resources_cache: dict[str, set], +) -> None: + """ + Increment resource group counters in-place for a finding. + + Args: + resource_group: Resource group from check metadata (e.g., "database", "compute"). + severity: Severity level (e.g., "high", "medium"). + status: Finding status as string ("FAIL", "PASS"). + delta: Delta value as string ("new", "changed") or None. + muted: Whether the finding is muted. + resource_uid: Unique identifier for the resource to count distinct resources. + cache: Dict {(resource_group, severity): {"total", "failed", "new_failed"}} to update. + group_resources_cache: Dict {resource_group: set(resource_uids)} for group-level resource tracking. + """ + if not resource_group: + return + + is_failed = status == "FAIL" and not muted + is_new_failed = is_failed and delta == "new" + + key = (resource_group, severity) + if key not in cache: + cache[key] = {"total": 0, "failed": 0, "new_failed": 0} + if not muted: + cache[key]["total"] += 1 + if is_failed: + cache[key]["failed"] += 1 + if is_new_failed: + cache[key]["new_failed"] += 1 + + # Track resources at GROUP level (not per-severity) to avoid over-counting + if resource_uid and not muted: + group_resources_cache.setdefault(resource_group, set()).add(resource_uid) + + def _get_attack_surface_mapping_from_provider(provider_type: str) -> dict: global _ATTACK_SURFACE_MAPPING_CACHE @@ -438,6 +483,8 @@ def _process_finding_micro_batch( scan_resource_cache: set, mute_rules_cache: dict, scan_categories_cache: dict[tuple[str, str], dict[str, int]], + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]], + group_resources_cache: dict[str, set], ) -> None: """ Process a micro-batch of findings and persist them using bulk operations. @@ -459,6 +506,8 @@ def _process_finding_micro_batch( scan_resource_cache: Set of tuples used to create `ResourceScanSummary` rows. mute_rules_cache: Map of finding UID -> mute reason gathered before the scan. scan_categories_cache: Dict tracking category counts {(category, severity): {"total", "failed", "new_failed"}}. + scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}. + group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}. """ # Accumulate objects for bulk operations findings_to_create = [] @@ -499,6 +548,8 @@ def _process_finding_micro_batch( with rls_transaction(tenant_id): resource_uid = finding.resource_uid if resource_uid not in resource_cache: + check_metadata = finding.get_metadata() + group = check_metadata.get("resourcegroup") or None resource_instance, _ = Resource.objects.get_or_create( tenant_id=tenant_id, provider=provider_instance, @@ -508,6 +559,7 @@ def _process_finding_micro_batch( "service": finding.service_name, "type": finding.resource_type, "name": finding.resource_name, + "groups": [group] if group else None, }, ) resource_cache[resource_uid] = resource_instance @@ -528,6 +580,8 @@ def _process_finding_micro_batch( # Track resource field changes (defer save) updated = False + check_metadata = finding.get_metadata() + group = check_metadata.get("resourcegroup") or None if finding.region and resource_instance.region != finding.region: resource_instance.region = finding.region updated = True @@ -548,6 +602,11 @@ def _process_finding_micro_batch( if resource_instance.partition != finding.partition: resource_instance.partition = finding.partition updated = True + if group and ( + not resource_instance.groups or group not in resource_instance.groups + ): + resource_instance.groups = (resource_instance.groups or []) + [group] + updated = True if updated: dirty_resources[resource_uid] = resource_instance @@ -633,6 +692,7 @@ def _process_finding_micro_batch( muted_reason=muted_reason, compliance=finding.compliance, categories=check_metadata.get("categories", []) or [], + resource_groups=check_metadata.get("resourcegroup") or None, ) findings_to_create.append(finding_instance) resource_denormalized_data.append((finding_instance, resource_instance)) @@ -657,6 +717,18 @@ def _process_finding_micro_batch( cache=scan_categories_cache, ) + # Track resource groups with counts for ScanGroupSummary + aggregate_resource_group_counts( + resource_group=check_metadata.get("resourcegroup") or None, + severity=finding.severity.value, + status=status.value, + delta=delta.value if delta else None, + muted=is_muted, + resource_uid=resource_instance.uid if resource_instance else "", + cache=scan_resource_groups_cache, + group_resources_cache=group_resources_cache, + ) + # Bulk operations within single transaction with rls_transaction(tenant_id): # Bulk create findings @@ -714,7 +786,15 @@ def _process_finding_micro_batch( tenant_id=tenant_id, model=Resource, objects=list(dirty_resources.values()), - fields=["metadata", "details", "partition", "region", "service", "type"], + fields=[ + "metadata", + "details", + "partition", + "region", + "service", + "type", + "groups", + ], batch_size=1000, ) @@ -757,6 +837,8 @@ def perform_prowler_scan( unique_resources = set() scan_resource_cache: set[tuple[str, str, str, str]] = set() scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} start_time = time.time() exc = None @@ -847,6 +929,8 @@ def perform_prowler_scan( scan_resource_cache=scan_resource_cache, mute_rules_cache=mute_rules_cache, scan_categories_cache=scan_categories_cache, + scan_resource_groups_cache=scan_resource_groups_cache, + group_resources_cache=group_resources_cache, ) # Update scan progress @@ -933,6 +1017,38 @@ def perform_prowler_scan( sentry_sdk.capture_exception(cat_exception) logger.error(f"Error storing categories for scan {scan_id}: {cat_exception}") + try: + if scan_resource_groups_cache: + # Compute group-level resource counts (same value for all severity rows in a group) + group_resource_counts = { + grp: len(uids) for grp, uids in group_resources_cache.items() + } + resource_group_summaries = [ + ScanGroupSummary( + tenant_id=tenant_id, + scan_id=scan_id, + resource_group=grp, + severity=severity, + total_findings=counts["total"], + failed_findings=counts["failed"], + new_failed_findings=counts["new_failed"], + resources_count=group_resource_counts.get(grp, 0), + ) + for ( + grp, + severity, + ), counts in scan_resource_groups_cache.items() + ] + with rls_transaction(tenant_id): + ScanGroupSummary.objects.bulk_create( + resource_group_summaries, batch_size=500, ignore_conflicts=True + ) + except Exception as rg_exception: + sentry_sdk.capture_exception(rg_exception) + logger.error( + f"Error storing resource groups for scan {scan_id}: {rg_exception}" + ) + serializer = ScanTaskSerializer(instance=scan_instance) return serializer.data diff --git a/api/src/backend/tasks/jobs/threatscore.py b/api/src/backend/tasks/jobs/threatscore.py index 414f2d20f2..a9a7516e55 100644 --- a/api/src/backend/tasks/jobs/threatscore.py +++ b/api/src/backend/tasks/jobs/threatscore.py @@ -131,9 +131,11 @@ def compute_threatscore_metrics( continue m = metadata[0] - risk_level = getattr(m, "LevelOfRisk", 0) - weight = getattr(m, "Weight", 0) + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) section = getattr(m, "Section", "Unknown") + risk_level = int(risk_level_raw) if risk_level_raw else 0 + weight = int(weight_raw) if weight_raw else 0 # Calculate ThreatScore components using formula from UI rate_i = req_passed_findings / req_total_findings diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 78adb7842b..c46c279bf4 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -1,9 +1,6 @@ -from collections import defaultdict - from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE from django.db.models import Count, Q -from tasks.utils import batched from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction @@ -154,6 +151,12 @@ def _load_findings_for_requirement_checks( Supports optional caching to avoid duplicate queries when generating multiple reports for the same scan. + Memory optimizations: + - Uses database iterator with chunk_size for streaming large result sets + - Shares references between cache and return dict (no duplication) + - Only selects required fields from database + - Processes findings in batches to reduce memory pressure + Args: tenant_id (str): The tenant ID for Row-Level Security context. scan_id (str): The ID of the scan to retrieve findings for. @@ -171,69 +174,73 @@ def _load_findings_for_requirement_checks( 'aws_s3_bucket_public_access': [FindingOutput(...)] } """ - findings_by_check_id = defaultdict(list) - if not check_ids: - return dict(findings_by_check_id) + return {} # Initialize cache if not provided if findings_cache is None: findings_cache = {} + # Deduplicate check_ids to avoid redundant processing + unique_check_ids = list(set(check_ids)) + # Separate cached and non-cached check_ids check_ids_to_load = [] cache_hits = 0 - cache_misses = 0 - for check_id in check_ids: + for check_id in unique_check_ids: if check_id in findings_cache: - # Reuse from cache - findings_by_check_id[check_id] = findings_cache[check_id] cache_hits += 1 else: - # Need to load from database check_ids_to_load.append(check_id) - cache_misses += 1 if cache_hits > 0: + total_checks = len(unique_check_ids) logger.info( - f"Findings cache: {cache_hits} hits, {cache_misses} misses " - f"({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)" + f"Findings cache: {cache_hits}/{total_checks} hits " + f"({cache_hits / total_checks * 100:.1f}% hit rate)" ) - # If all check_ids were in cache, return early - if not check_ids_to_load: - return dict(findings_by_check_id) - - logger.info(f"Loading findings for {len(check_ids_to_load)} checks on-demand") - - findings_queryset = ( - Finding.all_objects.filter( - tenant_id=tenant_id, scan_id=scan_id, check_id__in=check_ids_to_load + # Load missing check_ids from database + if check_ids_to_load: + logger.info( + f"Loading findings for {len(check_ids_to_load)} checks from database" ) - .order_by("uid") - .iterator() - ) - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - for batch, is_last_batch in batched( - findings_queryset, DJANGO_FINDINGS_BATCH_SIZE - ): - for finding_model in batch: + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Use iterator with chunk_size for memory-efficient streaming + # chunk_size controls how many rows Django fetches from DB at once + findings_queryset = ( + Finding.all_objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + check_id__in=check_ids_to_load, + ) + .order_by("check_id", "uid") + .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + ) + + # Pre-initialize empty lists for all check_ids to load + # This avoids repeated dict lookups and 'if not in' checks + for check_id in check_ids_to_load: + findings_cache[check_id] = [] + + findings_count = 0 + for finding_model in findings_queryset: finding_output = FindingOutput.transform_api_finding( finding_model, prowler_provider ) - findings_by_check_id[finding_output.check_id].append(finding_output) - # Update cache with newly loaded findings - if finding_output.check_id not in findings_cache: - findings_cache[finding_output.check_id] = [] findings_cache[finding_output.check_id].append(finding_output) + findings_count += 1 - total_findings_loaded = sum( - len(findings) for findings in findings_by_check_id.values() - ) - logger.info( - f"Loaded {total_findings_loaded} findings for {len(findings_by_check_id)} checks" - ) + logger.info( + f"Loaded {findings_count} findings for {len(check_ids_to_load)} checks" + ) - return dict(findings_by_check_id) + # Build result dict using cache references (no data duplication) + # This shares the same list objects between cache and result + result = { + check_id: findings_cache.get(check_id, []) for check_id in unique_check_ids + } + + return result diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 542f01ef65..0dd3b0905b 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -8,12 +8,17 @@ from celery.utils.log import get_task_logger from config.celery import RLSTask from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY from django_celery_beat.models import PeriodicTask +from tasks.jobs.attack_paths import ( + attack_paths_scan, + can_provider_run_attack_paths_scan, +) from tasks.jobs.backfill import ( backfill_compliance_summaries, backfill_daily_severity_summaries, backfill_provider_compliance_scores, backfill_resource_scan_summaries, backfill_scan_category_summaries, + backfill_scan_resource_group_summaries, ) from tasks.jobs.connection import ( check_integration_connection, @@ -47,7 +52,11 @@ from tasks.jobs.scan import ( perform_prowler_scan, update_provider_compliance_scores, ) -from tasks.utils import batched, get_next_execution_datetime +from tasks.utils import ( + _get_or_create_scheduled_scan, + batched, + get_next_execution_datetime, +) from api.compliance import get_compliance_frameworks from api.db_router import READ_REPLICA_ALIAS @@ -152,6 +161,11 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str) ), ).apply_async() + if can_provider_run_attack_paths_scan(tenant_id, provider_id): + perform_attack_paths_scan_task.apply_async( + kwargs={"tenant_id": tenant_id, "scan_id": scan_id} + ) + @shared_task(base=RLSTask, name="provider-connection-check") @set_tenant @@ -264,44 +278,38 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): periodic_task_instance = PeriodicTask.objects.get( name=f"scan-perform-scheduled-{provider_id}" ) - - executed_scan = Scan.objects.filter( - tenant_id=tenant_id, - provider_id=provider_id, - task__task_runner_task__task_id=task_id, - ).order_by("completed_at") - - if ( + executing_scan = ( Scan.objects.filter( tenant_id=tenant_id, provider_id=provider_id, trigger=Scan.TriggerChoices.SCHEDULED, state=StateChoices.EXECUTING, - scheduler_task_id=periodic_task_instance.id, - scheduled_at__date=datetime.now(timezone.utc).date(), - ).exists() - or executed_scan.exists() - ): - # Duplicated task execution due to visibility timeout or scan is already running - logger.warning(f"Duplicated scheduled scan for provider {provider_id}.") - try: - affected_scan = executed_scan.first() - if not affected_scan: - raise ValueError( - "Error retrieving affected scan details after detecting duplicated scheduled " - "scan." - ) - # Return the affected scan details to avoid losing data - serializer = ScanTaskSerializer(instance=affected_scan) - except Exception as duplicated_scan_exception: - logger.error( - f"Duplicated scheduled scan for provider {provider_id}. Error retrieving affected scan details: " - f"{str(duplicated_scan_exception)}" - ) - raise duplicated_scan_exception - return serializer.data + ) + .order_by("-started_at") + .first() + ) + if executing_scan: + logger.warning( + f"Scheduled scan already executing for provider {provider_id}. Skipping." + ) + return ScanTaskSerializer(instance=executing_scan).data + executed_scan = Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + task__task_runner_task__task_id=task_id, + ).first() + + if executed_scan: + # Duplicated task execution due to visibility timeout + logger.warning(f"Duplicated scheduled scan for provider {provider_id}.") + return ScanTaskSerializer(instance=executed_scan).data + + interval = periodic_task_instance.interval next_scan_datetime = get_next_execution_datetime(task_id, provider_id) + current_scan_datetime = next_scan_datetime - timedelta( + **{interval.period: interval.every} + ) # TEMPORARY WORKAROUND: Clean up orphan scans from transaction isolation issue _cleanup_orphan_scheduled_scans( @@ -310,19 +318,12 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): scheduler_task_id=periodic_task_instance.id, ) - scan_instance, _ = Scan.objects.get_or_create( + scan_instance = _get_or_create_scheduled_scan( tenant_id=tenant_id, provider_id=provider_id, - trigger=Scan.TriggerChoices.SCHEDULED, - state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), scheduler_task_id=periodic_task_instance.id, - defaults={ - "state": StateChoices.SCHEDULED, - "name": "Daily scheduled scan", - "scheduled_at": next_scan_datetime - timedelta(days=1), - }, + scheduled_at=current_scan_datetime, ) - scan_instance.task_id = task_id scan_instance.save() @@ -332,18 +333,19 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): scan_id=str(scan_instance.id), provider_id=provider_id, ) - except Exception as e: - raise e finally: with rls_transaction(tenant_id): - Scan.objects.get_or_create( + now = datetime.now(timezone.utc) + if next_scan_datetime <= now: + interval_delta = timedelta(**{interval.period: interval.every}) + while next_scan_datetime <= now: + next_scan_datetime += interval_delta + _get_or_create_scheduled_scan( tenant_id=tenant_id, - name="Daily scheduled scan", provider_id=provider_id, - trigger=Scan.TriggerChoices.SCHEDULED, - state=StateChoices.SCHEDULED, - scheduled_at=next_scan_datetime, scheduler_task_id=periodic_task_instance.id, + scheduled_at=next_scan_datetime, + update_state=True, ) _perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id) @@ -357,6 +359,29 @@ def perform_scan_summary_task(tenant_id: str, scan_id: str): return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id) +@shared_task( + base=RLSTask, + bind=True, + name="attack-paths-scan-perform", + queue="attack-paths-scans", +) +def perform_attack_paths_scan_task(self, tenant_id: str, scan_id: str): + """ + Execute an Attack Paths scan for the given provider within the current tenant RLS context. + + Args: + self: The task instance (automatically passed when bind=True). + tenant_id (str): The tenant identifier for RLS context. + scan_id (str): The Prowler scan identifier for obtaining the tenant and provider context. + + Returns: + Any: The result from `attack_paths_scan`, including any per-scan failure details. + """ + return attack_paths_scan( + tenant_id=tenant_id, scan_id=scan_id, task_id=self.request.id + ) + + @shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,)) def delete_tenant_task(tenant_id: str): return delete_tenant(pk=tenant_id) @@ -613,6 +638,21 @@ def backfill_scan_category_summaries_task(tenant_id: str, scan_id: str): return backfill_scan_category_summaries(tenant_id=tenant_id, scan_id=scan_id) +@shared_task(name="backfill-scan-resource-group-summaries", queue="backfill") +@handle_provider_deletion +def backfill_scan_resource_group_summaries_task(tenant_id: str, scan_id: str): + """ + Backfill ScanGroupSummary for a completed scan. + + Aggregates unique resource groups from findings and creates a summary row. + + Args: + tenant_id (str): The tenant identifier. + scan_id (str): The scan identifier. + """ + return backfill_scan_resource_group_summaries(tenant_id=tenant_id, scan_id=scan_id) + + @shared_task(name="backfill-provider-compliance-scores", queue="backfill") def backfill_provider_compliance_scores_task(tenant_id: str): """ diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py new file mode 100644 index 0000000000..0309e7162a --- /dev/null +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -0,0 +1,708 @@ +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +import pytest +from tasks.jobs.attack_paths import prowler as prowler_module +from tasks.jobs.attack_paths.scan import run as attack_paths_run + +from api.models import ( + AttackPathsScan, + Finding, + Provider, + Resource, + ResourceFindingMapping, + Scan, + StateChoices, + StatusChoices, +) +from prowler.lib.check.models import Severity + + +@pytest.mark.django_db +class TestAttackPathsRun: + def test_run_success_flow(self, tenants_fixture, providers_fixture, scans_fixture): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.SCHEDULED, + ) + + mock_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + ingestion_result = {"organizations": "warning"} + ingestion_fn = MagicMock(return_value=ingestion_result) + + with ( + patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_uri", + return_value="bolt://neo4j", + ), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + return_value="db-scan-id", + ) as mock_get_db_name, + patch( + "tasks.jobs.attack_paths.scan.graph_database.create_database" + ) as mock_create_db, + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ) as mock_get_session, + patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache"), + patch( + "tasks.jobs.attack_paths.scan.cartography_create_indexes.run" + ) as mock_cartography_indexes, + patch( + "tasks.jobs.attack_paths.scan.cartography_analysis.run" + ) as mock_cartography_analysis, + patch( + "tasks.jobs.attack_paths.scan.cartography_ontology.run" + ) as mock_cartography_ontology, + patch( + "tasks.jobs.attack_paths.scan.prowler.create_indexes" + ) as mock_prowler_indexes, + patch( + "tasks.jobs.attack_paths.scan.prowler.analysis" + ) as mock_prowler_analysis, + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ) as mock_retrieve_scan, + patch( + "tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan" + ) as mock_starting, + patch( + "tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress" + ) as mock_update_progress, + patch( + "tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan" + ) as mock_finish, + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=ingestion_fn, + ) as mock_get_ingestion, + patch( + "tasks.jobs.attack_paths.scan._call_within_event_loop", + side_effect=lambda fn, *a, **kw: fn(*a, **kw), + ) as mock_event_loop, + ): + result = attack_paths_run(str(tenant.id), str(scan.id), "task-123") + + assert result == ingestion_result + mock_retrieve_scan.assert_called_once_with(str(tenant.id), str(scan.id)) + mock_starting.assert_called_once() + config = mock_starting.call_args[0][2] + assert config.neo4j_database == "db-scan-id" + + mock_create_db.assert_called_once_with("db-scan-id") + mock_get_session.assert_called_once_with("db-scan-id") + mock_cartography_indexes.assert_called_once_with(mock_session, config) + mock_prowler_indexes.assert_called_once_with(mock_session) + mock_cartography_analysis.assert_called_once_with(mock_session, config) + mock_cartography_ontology.assert_called_once_with(mock_session, config) + mock_prowler_analysis.assert_called_once_with( + mock_session, + provider, + str(scan.id), + config, + ) + mock_get_ingestion.assert_called_once_with(provider.provider) + mock_event_loop.assert_called_once() + mock_update_progress.assert_any_call(attack_paths_scan, 1) + mock_update_progress.assert_any_call(attack_paths_scan, 2) + mock_update_progress.assert_any_call(attack_paths_scan, 95) + mock_finish.assert_called_once_with( + attack_paths_scan, StateChoices.COMPLETED, ingestion_result + ) + mock_get_db_name.assert_called_once_with(attack_paths_scan.id) + + def test_run_failure_marks_scan_failed( + self, tenants_fixture, providers_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.SCHEDULED, + ) + + mock_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom")) + + with ( + patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ), + patch("tasks.jobs.attack_paths.scan.graph_database.get_uri"), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + return_value="db-scan-id", + ), + patch("tasks.jobs.attack_paths.scan.graph_database.create_database"), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"), + patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"), + patch("tasks.jobs.attack_paths.scan.prowler.create_indexes"), + patch("tasks.jobs.attack_paths.scan.prowler.analysis"), + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ), + patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"), + patch( + "tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress" + ), + patch( + "tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan" + ) as mock_finish, + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=ingestion_fn, + ), + patch( + "tasks.jobs.attack_paths.scan._call_within_event_loop", + side_effect=lambda fn, *a, **kw: fn(*a, **kw), + ), + patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Cartography failed: ingestion boom", + ), + ): + with pytest.raises(RuntimeError, match="ingestion boom"): + attack_paths_run(str(tenant.id), str(scan.id), "task-456") + + failure_args = mock_finish.call_args[0] + assert failure_args[0] is attack_paths_scan + assert failure_args[1] == StateChoices.FAILED + assert failure_args[2] == { + "global_cartography_error": "Cartography failed: ingestion boom" + } + + def test_run_returns_early_for_unsupported_provider(self, tenants_fixture): + tenant = tenants_fixture[0] + provider = Provider.objects.create( + provider=Provider.ProviderChoices.GCP, + uid="gcp-account", + alias="gcp", + tenant_id=tenant.id, + ) + scan = Scan.objects.create( + name="GCP Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + tenant_id=tenant.id, + ) + + with ( + patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(), + ), + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=None, + ) as mock_get_ingestion, + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan" + ) as mock_retrieve, + ): + mock_retrieve.return_value = None + result = attack_paths_run(str(tenant.id), str(scan.id), "task-789") + + assert result == { + "global_error": "Provider gcp is not supported for Attack Paths scans" + } + mock_get_ingestion.assert_called_once_with(provider.provider) + mock_retrieve.assert_called_once_with(str(tenant.id), str(scan.id)) + + +@pytest.mark.django_db +class TestAttackPathsProwlerHelpers: + def test_create_indexes_executes_all_statements(self): + mock_session = MagicMock() + with patch("tasks.jobs.attack_paths.prowler.run_write_query") as mock_run_write: + prowler_module.create_indexes(mock_session) + + assert mock_run_write.call_count == len(prowler_module.INDEX_STATEMENTS) + mock_run_write.assert_has_calls( + [call(mock_session, stmt) for stmt in prowler_module.INDEX_STATEMENTS] + ) + + def test_load_findings_batches_requests(self, providers_fixture): + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + # Create a generator that yields two batches + def findings_generator(): + yield [{"id": "1", "resource_uid": "r-1"}] + yield [{"id": "2", "resource_uid": "r-2"}] + + config = SimpleNamespace(update_tag=12345) + mock_session = MagicMock() + + with ( + patch( + "tasks.jobs.attack_paths.prowler.get_root_node_label", + return_value="AWSAccount", + ), + patch( + "tasks.jobs.attack_paths.prowler.get_node_uid_field", + return_value="arn", + ), + ): + prowler_module.load_findings( + mock_session, findings_generator(), provider, config + ) + + assert mock_session.run.call_count == 2 + for call_args in mock_session.run.call_args_list: + params = call_args.args[1] + assert params["provider_uid"] == str(provider.uid) + assert params["last_updated"] == config.update_tag + assert "findings_data" in params + + def test_cleanup_findings_runs_batches(self, providers_fixture): + provider = providers_fixture[0] + config = SimpleNamespace(update_tag=1024) + mock_session = MagicMock() + + first_batch = MagicMock() + first_batch.single.return_value = {"deleted_findings_count": 3} + second_batch = MagicMock() + second_batch.single.return_value = {"deleted_findings_count": 0} + mock_session.run.side_effect = [first_batch, second_batch] + + prowler_module.cleanup_findings(mock_session, provider, config) + + assert mock_session.run.call_count == 2 + params = mock_session.run.call_args.args[1] + assert params["provider_uid"] == str(provider.uid) + assert params["last_updated"] == config.update_tag + + def test_get_provider_last_scan_findings_returns_latest_scan_data( + self, + tenants_fixture, + providers_fixture, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="resource-uid", + name="Resource", + region="us-east-1", + service="ec2", + type="instance", + ) + + older_scan = Scan.objects.create( + name="Older", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + ) + old_finding = Finding.objects.create( + tenant_id=tenant.id, + uid="older-finding", + scan=older_scan, + delta=Finding.DeltaChoices.NEW, + status=StatusChoices.PASS, + status_extended="ok", + severity=Severity.low, + impact=Severity.low, + impact_extended="", + raw_result={}, + check_id="check-old", + check_metadata={"checktitle": "Old"}, + first_seen_at=older_scan.inserted_at, + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + resource=resource, + finding=old_finding, + ) + + latest_scan = Scan.objects.create( + name="Latest", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + ) + finding = Finding.objects.create( + tenant_id=tenant.id, + uid="finding-uid", + scan=latest_scan, + delta=Finding.DeltaChoices.NEW, + status=StatusChoices.FAIL, + status_extended="failed", + severity=Severity.high, + impact=Severity.high, + impact_extended="", + raw_result={}, + check_id="check-1", + check_metadata={"checktitle": "Check title"}, + first_seen_at=latest_scan.inserted_at, + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + resource=resource, + finding=finding, + ) + + latest_scan.refresh_from_db() + + with patch( + "tasks.jobs.attack_paths.prowler.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), patch( + "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS", + "default", + ): + # Generator yields batches, collect all findings from all batches + findings_batches = prowler_module.get_provider_last_scan_findings( + provider, + str(latest_scan.id), + ) + findings_data = [] + for batch in findings_batches: + findings_data.extend(batch) + + assert len(findings_data) == 1 + finding_dict = findings_data[0] + assert finding_dict["id"] == str(finding.id) + assert finding_dict["resource_uid"] == resource.uid + assert finding_dict["check_title"] == "Check title" + assert finding_dict["scan_id"] == str(latest_scan.id) + + def test_enrich_and_flatten_batch_single_resource( + self, + tenants_fixture, + providers_fixture, + ): + """One finding + one resource = one output dict""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="resource-uid-1", + name="Resource 1", + region="us-east-1", + service="ec2", + type="instance", + ) + + scan = Scan.objects.create( + name="Test Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + ) + + finding = Finding.objects.create( + tenant_id=tenant.id, + uid="finding-uid", + scan=scan, + delta=Finding.DeltaChoices.NEW, + status=StatusChoices.FAIL, + status_extended="failed", + severity=Severity.high, + impact=Severity.high, + impact_extended="", + raw_result={}, + check_id="check-1", + check_metadata={"checktitle": "Check title"}, + first_seen_at=scan.inserted_at, + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + resource=resource, + finding=finding, + ) + + # Simulate the dict returned by .values() + finding_dict = { + "id": finding.id, + "uid": finding.uid, + "inserted_at": finding.inserted_at, + "updated_at": finding.updated_at, + "first_seen_at": finding.first_seen_at, + "scan_id": scan.id, + "delta": finding.delta, + "status": finding.status, + "status_extended": finding.status_extended, + "severity": finding.severity, + "check_id": finding.check_id, + "check_metadata__checktitle": finding.check_metadata["checktitle"], + "muted": finding.muted, + "muted_reason": finding.muted_reason, + } + + # _enrich_and_flatten_batch queries ResourceFindingMapping directly + # No RLS mock needed - test DB doesn't enforce RLS policies + with patch( + "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS", + "default", + ): + result = prowler_module._enrich_and_flatten_batch([finding_dict]) + + assert len(result) == 1 + assert result[0]["resource_uid"] == resource.uid + assert result[0]["id"] == str(finding.id) + assert result[0]["status"] == "FAIL" + + def test_enrich_and_flatten_batch_multiple_resources( + self, + tenants_fixture, + providers_fixture, + ): + """One finding + three resources = three output dicts""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + resources = [] + for i in range(3): + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid=f"resource-uid-{i}", + name=f"Resource {i}", + region="us-east-1", + service="ec2", + type="instance", + ) + resources.append(resource) + + scan = Scan.objects.create( + name="Test Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + ) + + finding = Finding.objects.create( + tenant_id=tenant.id, + uid="finding-uid", + scan=scan, + delta=Finding.DeltaChoices.NEW, + status=StatusChoices.FAIL, + status_extended="failed", + severity=Severity.high, + impact=Severity.high, + impact_extended="", + raw_result={}, + check_id="check-1", + check_metadata={"checktitle": "Check title"}, + first_seen_at=scan.inserted_at, + ) + + # Map finding to all 3 resources + for resource in resources: + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + resource=resource, + finding=finding, + ) + + finding_dict = { + "id": finding.id, + "uid": finding.uid, + "inserted_at": finding.inserted_at, + "updated_at": finding.updated_at, + "first_seen_at": finding.first_seen_at, + "scan_id": scan.id, + "delta": finding.delta, + "status": finding.status, + "status_extended": finding.status_extended, + "severity": finding.severity, + "check_id": finding.check_id, + "check_metadata__checktitle": finding.check_metadata["checktitle"], + "muted": finding.muted, + "muted_reason": finding.muted_reason, + } + + # _enrich_and_flatten_batch queries ResourceFindingMapping directly + # No RLS mock needed - test DB doesn't enforce RLS policies + with patch( + "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS", + "default", + ): + result = prowler_module._enrich_and_flatten_batch([finding_dict]) + + assert len(result) == 3 + result_resource_uids = {r["resource_uid"] for r in result} + assert result_resource_uids == {r.uid for r in resources} + + # All should have same finding data + for r in result: + assert r["id"] == str(finding.id) + assert r["status"] == "FAIL" + + def test_enrich_and_flatten_batch_no_resources_skips( + self, + tenants_fixture, + providers_fixture, + ): + """Finding without resources should be skipped""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + scan = Scan.objects.create( + name="Test Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + ) + + finding = Finding.objects.create( + tenant_id=tenant.id, + uid="orphan-finding", + scan=scan, + delta=Finding.DeltaChoices.NEW, + status=StatusChoices.FAIL, + status_extended="failed", + severity=Severity.high, + impact=Severity.high, + impact_extended="", + raw_result={}, + check_id="check-1", + check_metadata={"checktitle": "Check title"}, + first_seen_at=scan.inserted_at, + ) + # Note: No ResourceFindingMapping created + + finding_dict = { + "id": finding.id, + "uid": finding.uid, + "inserted_at": finding.inserted_at, + "updated_at": finding.updated_at, + "first_seen_at": finding.first_seen_at, + "scan_id": scan.id, + "delta": finding.delta, + "status": finding.status, + "status_extended": finding.status_extended, + "severity": finding.severity, + "check_id": finding.check_id, + "check_metadata__checktitle": finding.check_metadata["checktitle"], + "muted": finding.muted, + "muted_reason": finding.muted_reason, + } + + # Mock logger to verify no warning is emitted + with ( + patch( + "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS", + "default", + ), + patch("tasks.jobs.attack_paths.prowler.logger") as mock_logger, + ): + result = prowler_module._enrich_and_flatten_batch([finding_dict]) + + assert len(result) == 0 + mock_logger.warning.assert_not_called() + + def test_generator_is_lazy(self, providers_fixture): + """Generator should not execute queries until iterated""" + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan_id = "some-scan-id" + + with ( + patch("tasks.jobs.attack_paths.prowler.rls_transaction") as mock_rls, + patch("tasks.jobs.attack_paths.prowler.Finding") as mock_finding, + ): + # Create generator but don't iterate + prowler_module.get_provider_last_scan_findings(provider, scan_id) + + # Nothing should be called yet + mock_rls.assert_not_called() + mock_finding.objects.filter.assert_not_called() + + def test_load_findings_empty_generator(self, providers_fixture): + """Empty generator should not call neo4j""" + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + mock_session = MagicMock() + config = SimpleNamespace(update_tag=12345) + + def empty_gen(): + return + yield # Make it a generator + + with ( + patch( + "tasks.jobs.attack_paths.prowler.get_root_node_label", + return_value="AWSAccount", + ), + patch( + "tasks.jobs.attack_paths.prowler.get_node_uid_field", + return_value="arn", + ), + ): + prowler_module.load_findings(mock_session, empty_gen(), provider, config) + + mock_session.run.assert_not_called() diff --git a/api/src/backend/tasks/tests/test_backfill.py b/api/src/backend/tasks/tests/test_backfill.py index 4c9780d101..04b3158d22 100644 --- a/api/src/backend/tasks/tests/test_backfill.py +++ b/api/src/backend/tasks/tests/test_backfill.py @@ -8,6 +8,7 @@ from tasks.jobs.backfill import ( backfill_provider_compliance_scores, backfill_resource_scan_summaries, backfill_scan_category_summaries, + backfill_scan_resource_group_summaries, ) from api.models import ( @@ -16,6 +17,7 @@ from api.models import ( ResourceScanSummary, Scan, ScanCategorySummary, + ScanGroupSummary, StateChoices, ) from prowler.lib.check.models import Severity @@ -265,6 +267,94 @@ class TestBackfillScanCategorySummaries: assert summary.new_failed_findings == 1 +@pytest.fixture(scope="function") +def findings_with_group_fixture(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource = resources_fixture[0] + + finding = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_with_group", + scan=scan, + delta="new", + status=Status.FAIL, + status_extended="test status", + impact=Severity.high, + impact_extended="test impact", + severity=Severity.high, + raw_result={"status": Status.FAIL}, + check_id="test_check", + check_metadata={"CheckId": "test_check"}, + resource_groups="ai_ml", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + return finding + + +@pytest.fixture(scope="function") +def scan_resource_group_summary_fixture(scans_fixture): + scan = scans_fixture[0] + return ScanGroupSummary.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + resource_group="existing-group", + severity=Severity.high, + total_findings=1, + failed_findings=0, + new_failed_findings=0, + resources_count=1, + ) + + +@pytest.mark.django_db +class TestBackfillScanGroupSummaries: + def test_already_backfilled(self, scan_resource_group_summary_fixture): + tenant_id = scan_resource_group_summary_fixture.tenant_id + scan_id = scan_resource_group_summary_fixture.scan_id + + result = backfill_scan_resource_group_summaries(str(tenant_id), str(scan_id)) + + assert result == {"status": "already backfilled"} + + def test_not_completed_scan(self, get_not_completed_scans): + for scan in get_not_completed_scans: + result = backfill_scan_resource_group_summaries( + str(scan.tenant_id), str(scan.id) + ) + assert result == {"status": "scan is not completed"} + + def test_no_resource_groups_to_backfill(self, scans_fixture): + scan = scans_fixture[1] # Failed scan with no findings + result = backfill_scan_resource_group_summaries( + str(scan.tenant_id), str(scan.id) + ) + assert result == {"status": "no resource groups to backfill"} + + def test_successful_backfill(self, findings_with_group_fixture): + finding = findings_with_group_fixture + tenant_id = str(finding.tenant_id) + scan_id = str(finding.scan_id) + + result = backfill_scan_resource_group_summaries(tenant_id, scan_id) + + # 1 resource group Γ— 1 severity = 1 row + assert result == {"status": "backfilled", "resource_groups_count": 1} + + summaries = ScanGroupSummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ) + assert summaries.count() == 1 + + summary = summaries.first() + assert summary.resource_group == "ai_ml" + assert summary.severity == Severity.high + assert summary.total_findings == 1 + assert summary.failed_findings == 1 + assert summary.new_failed_findings == 1 + assert summary.resources_count == 1 + + @pytest.mark.django_db class TestBackfillProviderComplianceScores: def test_no_completed_scans(self, tenants_fixture): diff --git a/api/src/backend/tasks/tests/test_connection.py b/api/src/backend/tasks/tests/test_connection.py index 30973f98bf..e5e39d8778 100644 --- a/api/src/backend/tasks/tests/test_connection.py +++ b/api/src/backend/tasks/tests/test_connection.py @@ -82,7 +82,7 @@ def test_check_provider_connection_exception( [ { "name": "OpenAI", - "api_key_decoded": "sk-test1234567890T3BlbkFJtest1234567890", + "api_key_decoded": "sk-fake-test-key-for-unit-testing-only", "model": "gpt-4o", "temperature": 0, "max_tokens": 4000, diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index 81cdb44daa..fc90bee0e3 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -1,27 +1,60 @@ +from unittest.mock import call, patch + import pytest + from django.core.exceptions import ObjectDoesNotExist -from tasks.jobs.deletion import delete_provider, delete_tenant from api.models import Provider, Tenant +from tasks.jobs.deletion import delete_provider, delete_tenant @pytest.mark.django_db class TestDeleteProvider: def test_delete_provider_success(self, providers_fixture): - instance = providers_fixture[0] - tenant_id = str(instance.tenant_id) - result = delete_provider(tenant_id, instance.id) + with patch( + "tasks.jobs.deletion.get_provider_graph_database_names" + ) as mock_get_provider_graph_database_names, patch( + "tasks.jobs.deletion.graph_database.drop_database" + ) as mock_drop_database: + graph_db_names = ["graph-db-1", "graph-db-2"] + mock_get_provider_graph_database_names.return_value = graph_db_names - assert result - with pytest.raises(ObjectDoesNotExist): - Provider.objects.get(pk=instance.id) + instance = providers_fixture[0] + tenant_id = str(instance.tenant_id) + result = delete_provider(tenant_id, instance.id) + + assert result + with pytest.raises(ObjectDoesNotExist): + Provider.objects.get(pk=instance.id) + + mock_get_provider_graph_database_names.assert_called_once_with( + tenant_id, instance.id + ) + mock_drop_database.assert_has_calls( + [call(graph_db_name) for graph_db_name in graph_db_names] + ) def test_delete_provider_does_not_exist(self, tenants_fixture): - tenant_id = str(tenants_fixture[0].id) - non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645" + with patch( + "tasks.jobs.deletion.get_provider_graph_database_names" + ) as mock_get_provider_graph_database_names, patch( + "tasks.jobs.deletion.graph_database.drop_database" + ) as mock_drop_database: + graph_db_names = ["graph-db-1"] + mock_get_provider_graph_database_names.return_value = graph_db_names - with pytest.raises(ObjectDoesNotExist): - delete_provider(tenant_id, non_existent_pk) + tenant_id = str(tenants_fixture[0].id) + non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645" + + with pytest.raises(ObjectDoesNotExist): + delete_provider(tenant_id, non_existent_pk) + + mock_get_provider_graph_database_names.assert_called_once_with( + tenant_id, non_existent_pk + ) + mock_drop_database.assert_has_calls( + [call(graph_db_name) for graph_db_name in graph_db_names] + ) @pytest.mark.django_db @@ -30,33 +63,68 @@ class TestDeleteTenant: """ Test successful deletion of a tenant and its related data. """ - tenant = tenants_fixture[0] - providers = Provider.objects.filter(tenant_id=tenant.id) + with patch( + "tasks.jobs.deletion.get_provider_graph_database_names" + ) as mock_get_provider_graph_database_names, patch( + "tasks.jobs.deletion.graph_database.drop_database" + ) as mock_drop_database: + tenant = tenants_fixture[0] + providers = list(Provider.objects.filter(tenant_id=tenant.id)) - # Ensure the tenant and related providers exist before deletion - assert Tenant.objects.filter(id=tenant.id).exists() - assert providers.exists() + graph_db_names_per_provider = [ + [f"graph-db-{provider.id}"] for provider in providers + ] + mock_get_provider_graph_database_names.side_effect = ( + graph_db_names_per_provider + ) - # Call the function and validate the result - deletion_summary = delete_tenant(tenant.id) + # Ensure the tenant and related providers exist before deletion + assert Tenant.objects.filter(id=tenant.id).exists() + assert providers - assert deletion_summary is not None - assert not Tenant.objects.filter(id=tenant.id).exists() - assert not Provider.objects.filter(tenant_id=tenant.id).exists() + # Call the function and validate the result + deletion_summary = delete_tenant(tenant.id) + + assert deletion_summary is not None + assert not Tenant.objects.filter(id=tenant.id).exists() + assert not Provider.objects.filter(tenant_id=tenant.id).exists() + + expected_calls = [ + call(provider.tenant_id, provider.id) for provider in providers + ] + mock_get_provider_graph_database_names.assert_has_calls( + expected_calls, any_order=True + ) + assert mock_get_provider_graph_database_names.call_count == len( + expected_calls + ) + expected_drop_calls = [ + call(graph_db_name[0]) for graph_db_name in graph_db_names_per_provider + ] + mock_drop_database.assert_has_calls(expected_drop_calls, any_order=True) + assert mock_drop_database.call_count == len(expected_drop_calls) def test_delete_tenant_with_no_providers(self, tenants_fixture): """ Test deletion of a tenant with no related providers. """ - tenant = tenants_fixture[1] # Assume this tenant has no providers - providers = Provider.objects.filter(tenant_id=tenant.id) + with patch( + "tasks.jobs.deletion.get_provider_graph_database_names" + ) as mock_get_provider_graph_database_names, patch( + "tasks.jobs.deletion.graph_database.drop_database" + ) as mock_drop_database: + tenant = tenants_fixture[1] # Assume this tenant has no providers + providers = Provider.objects.filter(tenant_id=tenant.id) - # Ensure the tenant exists but has no related providers - assert Tenant.objects.filter(id=tenant.id).exists() - assert not providers.exists() + # Ensure the tenant exists but has no related providers + assert Tenant.objects.filter(id=tenant.id).exists() + assert not providers.exists() - # Call the function and validate the result - deletion_summary = delete_tenant(tenant.id) + # Call the function and validate the result + deletion_summary = delete_tenant(tenant.id) - assert deletion_summary == {} # No providers, so empty summary - assert not Tenant.objects.filter(id=tenant.id).exists() + assert deletion_summary == {} # No providers, so empty summary + assert not Tenant.objects.filter(id=tenant.id).exists() + + mock_get_provider_graph_database_names.assert_not_called() + mock_drop_database.assert_not_called() diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index d37b27e320..954c645998 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -417,9 +417,8 @@ class TestProwlerIntegrationConnectionTest: raise_on_exception=False, ) - @patch("api.utils.AwsProvider") @patch("api.utils.S3") - def test_s3_integration_connection_failure(self, mock_s3_class, mock_aws_provider): + def test_s3_integration_connection_failure(self, mock_s3_class): """Test S3 integration connection failure.""" integration = MagicMock() integration.integration_type = Integration.IntegrationChoices.AMAZON_S3 @@ -429,9 +428,6 @@ class TestProwlerIntegrationConnectionTest: } integration.configuration = {"bucket_name": "test-bucket"} - mock_session = MagicMock() - mock_aws_provider.return_value.session.current_session = mock_session - mock_connection = Connection( is_connected=False, error=Exception("Bucket not found") ) diff --git a/api/src/backend/tasks/tests/test_report.py b/api/src/backend/tasks/tests/test_report.py deleted file mode 100644 index 16dd19e0ab..0000000000 --- a/api/src/backend/tasks/tests/test_report.py +++ /dev/null @@ -1,1807 +0,0 @@ -import io -import uuid -from unittest.mock import MagicMock, Mock, patch - -import matplotlib -import pytest -from reportlab.lib import colors -from reportlab.platypus import Table, TableStyle -from tasks.jobs.report import ( - CHART_COLOR_GREEN_1, - CHART_COLOR_GREEN_2, - CHART_COLOR_ORANGE, - CHART_COLOR_RED, - CHART_COLOR_YELLOW, - COLOR_BLUE, - COLOR_ENS_ALTO, - COLOR_ENS_BAJO, - COLOR_ENS_MEDIO, - COLOR_ENS_OPCIONAL, - COLOR_HIGH_RISK, - COLOR_LOW_RISK, - COLOR_MEDIUM_RISK, - COLOR_NIS2_PRIMARY, - COLOR_SAFE, - _create_dimensions_radar_chart, - _create_ens_dimension_badges, - _create_ens_nivel_badge, - _create_ens_tipo_badge, - _create_findings_table_style, - _create_header_table_style, - _create_info_table_style, - _create_marco_category_chart, - _create_nis2_requirements_index, - _create_nis2_section_chart, - _create_nis2_subsection_table, - _create_pdf_styles, - _create_risk_component, - _create_section_score_chart, - _create_status_component, - _get_chart_color_for_percentage, - _get_color_for_compliance, - _get_color_for_risk_level, - _get_color_for_weight, - _get_ens_nivel_color, - _load_findings_for_requirement_checks, - _safe_getattr, - generate_compliance_reports_job, - generate_nis2_report, - generate_threatscore_report, -) -from tasks.jobs.threatscore_utils import ( - _aggregate_requirement_statistics_from_database, - _calculate_requirements_data_from_statistics, -) - -from api.models import Finding, StatusChoices -from prowler.lib.check.models import Severity - -matplotlib.use("Agg") # Use non-interactive backend for tests - - -@pytest.mark.django_db -class TestAggregateRequirementStatistics: - """Test suite for _aggregate_requirement_statistics_from_database function.""" - - def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture): - """Verify correct pass/total counts per check are aggregated from database.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create findings with different check_ids and statuses - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-1", - check_id="check_1", - status=StatusChoices.PASS, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-2", - check_id="check_1", - status=StatusChoices.FAIL, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-3", - check_id="check_2", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == { - "check_1": {"passed": 1, "total": 2}, - "check_2": {"passed": 1, "total": 1}, - } - - def test_handles_empty_scan(self, tenants_fixture, scans_fixture): - """Return empty dict when no findings exist for the scan.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == {} - - def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture): - """Aggregate multiple findings for same check_id correctly.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create 5 findings for same check, 3 passed - for i in range(3): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-pass-{i}", - check_id="check_same", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - for i in range(2): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-fail-{i}", - check_id="check_same", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == {"check_same": {"passed": 3, "total": 5}} - - def test_only_failed_findings(self, tenants_fixture, scans_fixture): - """Correctly count when all findings are FAIL status.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-fail-1", - check_id="check_fail", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-fail-2", - check_id="check_fail", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == {"check_fail": {"passed": 0, "total": 2}} - - def test_mixed_statuses(self, tenants_fixture, scans_fixture): - """Test with PASS, FAIL, and MANUAL statuses mixed.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-pass", - check_id="check_mixed", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-fail", - check_id="check_mixed", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-manual", - check_id="check_mixed", - status=StatusChoices.MANUAL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - # Only PASS status is counted as passed, MANUAL findings are excluded from total - assert result == {"check_mixed": {"passed": 1, "total": 2}} - - -@pytest.mark.django_db -class TestLoadFindingsForChecks: - """Test suite for _load_findings_for_requirement_checks function.""" - - def test_loads_only_requested_checks( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Verify only findings for specified check_ids are loaded.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - providers_fixture[0] - - # Create findings with different check_ids - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-1", - check_id="check_requested", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-2", - check_id="check_not_requested", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_requested" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_requested"], mock_provider - ) - - # Only one finding should be loaded - assert "check_requested" in result - assert "check_not_requested" not in result - assert len(result["check_requested"]) == 1 - assert mock_transform.call_count == 1 - - def test_empty_check_ids_returns_empty( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Return empty dict when check_ids list is empty.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - mock_provider = MagicMock() - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), [], mock_provider - ) - - assert result == {} - - def test_groups_by_check_id( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Multiple findings for same check are grouped correctly.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create multiple findings for same check - for i in range(3): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-{i}", - check_id="check_group", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_group" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_group"], mock_provider - ) - - assert len(result["check_group"]) == 3 - - def test_transforms_to_finding_output( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Findings are transformed using FindingOutput.transform_api_finding.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-transform", - check_id="check_transform", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_transform" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_transform"], mock_provider - ) - - # Verify transform was called - mock_transform.assert_called_once() - # Verify the transformed output is in the result - assert result["check_transform"][0] == mock_finding_output - - def test_batched_iteration(self, tenants_fixture, scans_fixture, providers_fixture): - """Works correctly with multiple batches of findings.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create enough findings to ensure batching (assuming batch size > 1) - for i in range(10): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-batch-{i}", - check_id="check_batch", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_batch" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_batch"], mock_provider - ) - - # All 10 findings should be loaded regardless of batching - assert len(result["check_batch"]) == 10 - assert mock_transform.call_count == 10 - - -@pytest.mark.django_db -class TestCalculateRequirementsData: - """Test suite for _calculate_requirements_data_from_statistics function.""" - - def test_requirement_status_all_pass(self): - """Status is PASS when all findings for requirement checks pass.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_1" - mock_requirement.Description = "Test requirement" - mock_requirement.Checks = ["check_1", "check_2"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = { - "check_1": {"passed": 5, "total": 5}, - "check_2": {"passed": 3, "total": 3}, - } - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - assert requirements_list[0]["attributes"]["status"] == StatusChoices.PASS - assert requirements_list[0]["attributes"]["passed_findings"] == 8 - assert requirements_list[0]["attributes"]["total_findings"] == 8 - - def test_requirement_status_some_fail(self): - """Status is FAIL when some findings fail.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_2" - mock_requirement.Description = "Test requirement with failures" - mock_requirement.Checks = ["check_3"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = { - "check_3": {"passed": 2, "total": 5}, - } - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL - assert requirements_list[0]["attributes"]["passed_findings"] == 2 - assert requirements_list[0]["attributes"]["total_findings"] == 5 - - def test_requirement_status_no_findings(self): - """Status is MANUAL when no findings exist for requirement.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_3" - mock_requirement.Description = "Manual requirement" - mock_requirement.Checks = ["check_nonexistent"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = {} - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - assert requirements_list[0]["attributes"]["status"] == StatusChoices.MANUAL - assert requirements_list[0]["attributes"]["passed_findings"] == 0 - assert requirements_list[0]["attributes"]["total_findings"] == 0 - - def test_aggregates_multiple_checks(self): - """Correctly sum stats across multiple checks in requirement.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_4" - mock_requirement.Description = "Multi-check requirement" - mock_requirement.Checks = ["check_a", "check_b", "check_c"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = { - "check_a": {"passed": 10, "total": 15}, - "check_b": {"passed": 5, "total": 10}, - "check_c": {"passed": 0, "total": 5}, - } - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - # 10 + 5 + 0 = 15 passed - assert requirements_list[0]["attributes"]["passed_findings"] == 15 - # 15 + 10 + 5 = 30 total - assert requirements_list[0]["attributes"]["total_findings"] == 30 - # Not all passed, so should be FAIL - assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL - - def test_returns_correct_structure(self): - """Verify tuple structure and dict keys are correct.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_attribute = MagicMock() - mock_requirement = MagicMock() - mock_requirement.Id = "req_5" - mock_requirement.Description = "Structure test" - mock_requirement.Checks = ["check_struct"] - mock_requirement.Attributes = [mock_attribute] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = {"check_struct": {"passed": 1, "total": 1}} - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - # Verify attributes_by_id structure - assert "req_5" in attributes_by_id - assert "attributes" in attributes_by_id["req_5"] - assert "description" in attributes_by_id["req_5"] - assert "req_attributes" in attributes_by_id["req_5"]["attributes"] - assert "checks" in attributes_by_id["req_5"]["attributes"] - - # Verify requirements_list structure - assert len(requirements_list) == 1 - req = requirements_list[0] - assert "id" in req - assert "attributes" in req - assert "framework" in req["attributes"] - assert "version" in req["attributes"] - assert "status" in req["attributes"] - assert "description" in req["attributes"] - assert "passed_findings" in req["attributes"] - assert "total_findings" in req["attributes"] - - -@pytest.mark.django_db -class TestGenerateThreatscoreReportFunction: - def setup_method(self): - self.scan_id = str(uuid.uuid4()) - self.provider_id = str(uuid.uuid4()) - self.tenant_id = str(uuid.uuid4()) - self.compliance_id = "prowler_threatscore_aws" - self.output_path = "/tmp/test_threatscore_report.pdf" - - @patch("tasks.jobs.report.initialize_prowler_provider") - @patch("tasks.jobs.report.Provider.objects.get") - @patch("tasks.jobs.report.Compliance.get_bulk") - @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") - @patch("tasks.jobs.report._calculate_requirements_data_from_statistics") - @patch("tasks.jobs.report._load_findings_for_requirement_checks") - @patch("tasks.jobs.report.SimpleDocTemplate") - @patch("tasks.jobs.report.Image") - @patch("tasks.jobs.report.Spacer") - @patch("tasks.jobs.report.Paragraph") - @patch("tasks.jobs.report.PageBreak") - @patch("tasks.jobs.report.Table") - @patch("tasks.jobs.report.TableStyle") - @patch("tasks.jobs.report.plt.subplots") - @patch("tasks.jobs.report.plt.savefig") - @patch("tasks.jobs.report.io.BytesIO") - def test_generate_threatscore_report_success( - self, - mock_bytesio, - mock_savefig, - mock_subplots, - mock_table_style, - mock_table, - mock_page_break, - mock_paragraph, - mock_spacer, - mock_image, - mock_doc_template, - mock_load_findings, - mock_calculate_requirements, - mock_aggregate_statistics, - mock_compliance_get_bulk, - mock_provider_get, - mock_initialize_provider, - ): - """Test the updated generate_threatscore_report using new memory-efficient architecture.""" - mock_provider = MagicMock() - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - prowler_provider = MagicMock() - mock_initialize_provider.return_value = prowler_provider - - # Mock compliance object with requirements - mock_compliance_obj = MagicMock() - mock_compliance_obj.Framework = "ProwlerThreatScore" - mock_compliance_obj.Version = "1.0" - mock_compliance_obj.Description = "Test Description" - - # Configure requirement with properly set numeric attributes for chart generation - mock_requirement = MagicMock() - mock_requirement.Id = "req_1" - mock_requirement.Description = "Test requirement" - mock_requirement.Checks = ["check_1"] - - # Create a properly configured attribute mock with numeric values - mock_requirement_attr = MagicMock() - mock_requirement_attr.Section = "1. IAM" - mock_requirement_attr.SubSection = "1.1 Identity" - mock_requirement_attr.Title = "Test Requirement Title" - mock_requirement_attr.LevelOfRisk = 3 - mock_requirement_attr.Weight = 100 - mock_requirement_attr.AttributeDescription = "Test requirement description" - mock_requirement_attr.AdditionalInformation = "Additional test information" - - mock_requirement.Attributes = [mock_requirement_attr] - mock_compliance_obj.Requirements = [mock_requirement] - - mock_compliance_get_bulk.return_value = { - self.compliance_id: mock_compliance_obj - } - - # Mock the aggregated statistics from database - mock_aggregate_statistics.return_value = {"check_1": {"passed": 5, "total": 10}} - - # Mock the calculated requirements data with properly configured attributes - mock_attributes_by_id = { - "req_1": { - "attributes": { - "req_attributes": [mock_requirement_attr], - "checks": ["check_1"], - }, - "description": "Test requirement", - } - } - mock_requirements_list = [ - { - "id": "req_1", - "attributes": { - "framework": "ProwlerThreatScore", - "version": "1.0", - "status": StatusChoices.FAIL, - "description": "Test requirement", - "passed_findings": 5, - "total_findings": 10, - }, - } - ] - mock_calculate_requirements.return_value = ( - mock_attributes_by_id, - mock_requirements_list, - ) - - # Mock the on-demand loaded findings - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_1" - mock_finding_output.status = "FAIL" - mock_finding_output.metadata = MagicMock() - mock_finding_output.metadata.CheckTitle = "Test Check" - mock_finding_output.metadata.Severity = "HIGH" - mock_finding_output.resource_name = "test-resource" - mock_finding_output.region = "us-east-1" - - mock_load_findings.return_value = {"check_1": [mock_finding_output]} - - # Mock PDF generation components - mock_doc = MagicMock() - mock_doc_template.return_value = mock_doc - - mock_fig, mock_ax = MagicMock(), MagicMock() - mock_subplots.return_value = (mock_fig, mock_ax) - mock_buffer = MagicMock() - mock_bytesio.return_value = mock_buffer - - mock_image.return_value = MagicMock() - mock_spacer.return_value = MagicMock() - mock_paragraph.return_value = MagicMock() - mock_page_break.return_value = MagicMock() - mock_table.return_value = MagicMock() - mock_table_style.return_value = MagicMock() - - # Execute the function - generate_threatscore_report( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id=self.compliance_id, - output_path=self.output_path, - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - - # Verify the new workflow was followed - mock_provider_get.assert_called_once_with(id=self.provider_id) - mock_initialize_provider.assert_called_once_with(mock_provider) - mock_compliance_get_bulk.assert_called_once_with("aws") - - # Verify the new functions were called in correct order with correct parameters - mock_aggregate_statistics.assert_called_once_with(self.tenant_id, self.scan_id) - mock_calculate_requirements.assert_called_once_with( - mock_compliance_obj, {"check_1": {"passed": 5, "total": 10}} - ) - mock_load_findings.assert_called_once_with( - self.tenant_id, self.scan_id, ["check_1"], prowler_provider, None - ) - - # Verify PDF was built - mock_doc_template.assert_called_once() - mock_doc.build.assert_called_once() - - @patch("tasks.jobs.report.initialize_prowler_provider") - @patch("tasks.jobs.report.Provider.objects.get") - @patch("tasks.jobs.report.Compliance.get_bulk") - @patch("tasks.jobs.threatscore_utils.Finding.all_objects.filter") - def test_generate_threatscore_report_exception_handling( - self, - mock_finding_filter, - mock_compliance_get_bulk, - mock_provider_get, - mock_initialize_provider, - ): - mock_provider_get.side_effect = Exception("Provider not found") - - with pytest.raises(Exception, match="Provider not found"): - generate_threatscore_report( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id=self.compliance_id, - output_path=self.output_path, - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - - -@pytest.mark.django_db -class TestColorHelperFunctions: - """Test suite for color selection helper functions.""" - - def test_get_color_for_risk_level_high(self): - """High risk level (>=4) returns red color.""" - assert _get_color_for_risk_level(4) == COLOR_HIGH_RISK - assert _get_color_for_risk_level(5) == COLOR_HIGH_RISK - - def test_get_color_for_risk_level_medium_high(self): - """Medium-high risk level (3) returns orange color.""" - assert _get_color_for_risk_level(3) == COLOR_MEDIUM_RISK - - def test_get_color_for_risk_level_medium(self): - """Medium risk level (2) returns yellow color.""" - assert _get_color_for_risk_level(2) == COLOR_LOW_RISK - - def test_get_color_for_risk_level_low(self): - """Low risk level (<2) returns green color.""" - assert _get_color_for_risk_level(0) == COLOR_SAFE - assert _get_color_for_risk_level(1) == COLOR_SAFE - - def test_get_color_for_weight_high(self): - """High weight (>100) returns red color.""" - assert _get_color_for_weight(101) == COLOR_HIGH_RISK - assert _get_color_for_weight(200) == COLOR_HIGH_RISK - - def test_get_color_for_weight_medium(self): - """Medium weight (51-100) returns yellow color.""" - assert _get_color_for_weight(51) == COLOR_LOW_RISK - assert _get_color_for_weight(100) == COLOR_LOW_RISK - - def test_get_color_for_weight_low(self): - """Low weight (<=50) returns green color.""" - assert _get_color_for_weight(0) == COLOR_SAFE - assert _get_color_for_weight(50) == COLOR_SAFE - - def test_get_color_for_compliance_high(self): - """High compliance (>=80%) returns green color.""" - assert _get_color_for_compliance(80.0) == COLOR_SAFE - assert _get_color_for_compliance(100.0) == COLOR_SAFE - - def test_get_color_for_compliance_medium(self): - """Medium compliance (60-79%) returns yellow color.""" - assert _get_color_for_compliance(60.0) == COLOR_LOW_RISK - assert _get_color_for_compliance(79.9) == COLOR_LOW_RISK - - def test_get_color_for_compliance_low(self): - """Low compliance (<60%) returns red color.""" - assert _get_color_for_compliance(0.0) == COLOR_HIGH_RISK - assert _get_color_for_compliance(59.9) == COLOR_HIGH_RISK - - def test_get_chart_color_for_percentage_excellent(self): - """Excellent percentage (>=80%) returns green.""" - assert _get_chart_color_for_percentage(80.0) == CHART_COLOR_GREEN_1 - assert _get_chart_color_for_percentage(100.0) == CHART_COLOR_GREEN_1 - - def test_get_chart_color_for_percentage_good(self): - """Good percentage (60-79%) returns light green.""" - assert _get_chart_color_for_percentage(60.0) == CHART_COLOR_GREEN_2 - assert _get_chart_color_for_percentage(79.9) == CHART_COLOR_GREEN_2 - - def test_get_chart_color_for_percentage_fair(self): - """Fair percentage (40-59%) returns yellow.""" - assert _get_chart_color_for_percentage(40.0) == CHART_COLOR_YELLOW - assert _get_chart_color_for_percentage(59.9) == CHART_COLOR_YELLOW - - def test_get_chart_color_for_percentage_poor(self): - """Poor percentage (20-39%) returns orange.""" - assert _get_chart_color_for_percentage(20.0) == CHART_COLOR_ORANGE - assert _get_chart_color_for_percentage(39.9) == CHART_COLOR_ORANGE - - def test_get_chart_color_for_percentage_critical(self): - """Critical percentage (<20%) returns red.""" - assert _get_chart_color_for_percentage(0.0) == CHART_COLOR_RED - assert _get_chart_color_for_percentage(19.9) == CHART_COLOR_RED - - def test_get_ens_nivel_color_alto(self): - """Alto nivel returns red color.""" - assert _get_ens_nivel_color("alto") == COLOR_ENS_ALTO - assert _get_ens_nivel_color("ALTO") == COLOR_ENS_ALTO - - def test_get_ens_nivel_color_medio(self): - """Medio nivel returns yellow/orange color.""" - assert _get_ens_nivel_color("medio") == COLOR_ENS_MEDIO - assert _get_ens_nivel_color("MEDIO") == COLOR_ENS_MEDIO - - def test_get_ens_nivel_color_bajo(self): - """Bajo nivel returns green color.""" - assert _get_ens_nivel_color("bajo") == COLOR_ENS_BAJO - assert _get_ens_nivel_color("BAJO") == COLOR_ENS_BAJO - - def test_get_ens_nivel_color_opcional(self): - """Opcional and unknown nivels return gray color.""" - assert _get_ens_nivel_color("opcional") == COLOR_ENS_OPCIONAL - assert _get_ens_nivel_color("unknown") == COLOR_ENS_OPCIONAL - - -class TestSafeGetattr: - """Test suite for _safe_getattr helper function.""" - - def test_safe_getattr_attribute_exists(self): - """Returns attribute value when it exists.""" - obj = Mock() - obj.test_attr = "value" - assert _safe_getattr(obj, "test_attr") == "value" - - def test_safe_getattr_attribute_missing_default(self): - """Returns default 'N/A' when attribute doesn't exist.""" - obj = Mock(spec=[]) - result = _safe_getattr(obj, "missing_attr") - assert result == "N/A" - - def test_safe_getattr_custom_default(self): - """Returns custom default when specified.""" - obj = Mock(spec=[]) - result = _safe_getattr(obj, "missing_attr", "custom") - assert result == "custom" - - def test_safe_getattr_none_value(self): - """Returns None if attribute value is None.""" - obj = Mock() - obj.test_attr = None - assert _safe_getattr(obj, "test_attr") is None - - -class TestPDFStylesCreation: - """Test suite for PDF styles creation and caching.""" - - def test_create_pdf_styles_returns_dict(self): - """Returns a dictionary with all required styles.""" - styles = _create_pdf_styles() - - assert isinstance(styles, dict) - assert "title" in styles - assert "h1" in styles - assert "h2" in styles - assert "h3" in styles - assert "normal" in styles - assert "normal_center" in styles - - def test_create_pdf_styles_caches_result(self): - """Subsequent calls return cached styles.""" - styles1 = _create_pdf_styles() - styles2 = _create_pdf_styles() - - # Should return the exact same object (not just equal) - assert styles1 is styles2 - - def test_pdf_styles_have_correct_fonts(self): - """Styles use the correct fonts.""" - styles = _create_pdf_styles() - - assert styles["title"].fontName == "PlusJakartaSans" - assert styles["h1"].fontName == "PlusJakartaSans" - assert styles["normal"].fontName == "PlusJakartaSans" - - -class TestTableStyleFactories: - """Test suite for table style factory functions.""" - - def test_create_info_table_style_returns_table_style(self): - """Returns a TableStyle object.""" - style = _create_info_table_style() - assert isinstance(style, TableStyle) - - def test_create_header_table_style_default_color(self): - """Uses default blue color when not specified.""" - style = _create_header_table_style() - assert isinstance(style, TableStyle) - # Verify it has styling commands - assert len(style.getCommands()) > 0 - - def test_create_header_table_style_custom_color(self): - """Uses custom color when specified.""" - custom_color = colors.red - style = _create_header_table_style(custom_color) - assert isinstance(style, TableStyle) - - def test_create_findings_table_style(self): - """Returns appropriate style for findings tables.""" - style = _create_findings_table_style() - assert isinstance(style, TableStyle) - assert len(style.getCommands()) > 0 - - -class TestRiskComponent: - """Test suite for _create_risk_component function.""" - - def test_create_risk_component_returns_table(self): - """Returns a Table object.""" - table = _create_risk_component(risk_level=3, weight=100, score=50) - assert isinstance(table, Table) - - def test_create_risk_component_high_risk(self): - """High risk level uses red color.""" - table = _create_risk_component(risk_level=4, weight=50, score=0) - assert isinstance(table, Table) - # Table is created successfully - - def test_create_risk_component_low_risk(self): - """Low risk level uses green color.""" - table = _create_risk_component(risk_level=1, weight=30, score=100) - assert isinstance(table, Table) - - def test_create_risk_component_default_score(self): - """Uses default score of 0 when not specified.""" - table = _create_risk_component(risk_level=2, weight=50) - assert isinstance(table, Table) - - -class TestStatusComponent: - """Test suite for _create_status_component function.""" - - def test_create_status_component_pass(self): - """PASS status uses green color.""" - table = _create_status_component("pass") - assert isinstance(table, Table) - - def test_create_status_component_fail(self): - """FAIL status uses red color.""" - table = _create_status_component("fail") - assert isinstance(table, Table) - - def test_create_status_component_manual(self): - """MANUAL status uses gray color.""" - table = _create_status_component("manual") - assert isinstance(table, Table) - - def test_create_status_component_uppercase(self): - """Handles uppercase status strings.""" - table = _create_status_component("PASS") - assert isinstance(table, Table) - - -class TestENSBadges: - """Test suite for ENS-specific badge creation functions.""" - - def test_create_ens_nivel_badge_alto(self): - """Creates badge for alto nivel.""" - table = _create_ens_nivel_badge("alto") - assert isinstance(table, Table) - - def test_create_ens_nivel_badge_medio(self): - """Creates badge for medio nivel.""" - table = _create_ens_nivel_badge("medio") - assert isinstance(table, Table) - - def test_create_ens_nivel_badge_bajo(self): - """Creates badge for bajo nivel.""" - table = _create_ens_nivel_badge("bajo") - assert isinstance(table, Table) - - def test_create_ens_nivel_badge_opcional(self): - """Creates badge for opcional nivel.""" - table = _create_ens_nivel_badge("opcional") - assert isinstance(table, Table) - - def test_create_ens_tipo_badge_requisito(self): - """Creates badge for requisito type.""" - table = _create_ens_tipo_badge("requisito") - assert isinstance(table, Table) - - def test_create_ens_tipo_badge_unknown(self): - """Handles unknown tipo gracefully.""" - table = _create_ens_tipo_badge("unknown") - assert isinstance(table, Table) - - def test_create_ens_dimension_badges_single(self): - """Creates badges for single dimension.""" - table = _create_ens_dimension_badges(["trazabilidad"]) - assert isinstance(table, Table) - - def test_create_ens_dimension_badges_multiple(self): - """Creates badges for multiple dimensions.""" - dimensiones = ["trazabilidad", "autenticidad", "integridad"] - table = _create_ens_dimension_badges(dimensiones) - assert isinstance(table, Table) - - def test_create_ens_dimension_badges_empty(self): - """Returns N/A table for empty dimensions list.""" - table = _create_ens_dimension_badges([]) - assert isinstance(table, Table) - - def test_create_ens_dimension_badges_invalid(self): - """Filters out invalid dimensions.""" - table = _create_ens_dimension_badges(["invalid", "trazabilidad"]) - assert isinstance(table, Table) - - -class TestChartCreation: - """Test suite for chart generation functions.""" - - @patch("tasks.jobs.report.plt.close") - @patch("tasks.jobs.report.plt.savefig") - @patch("tasks.jobs.report.plt.subplots") - def test_create_section_score_chart_with_data( - self, mock_subplots, mock_savefig, mock_close - ): - """Creates chart successfully with valid data.""" - mock_fig, mock_ax = MagicMock(), MagicMock() - mock_subplots.return_value = (mock_fig, mock_ax) - mock_ax.bar.return_value = [MagicMock(), MagicMock()] - - requirements_list = [ - { - "id": "req_1", - "attributes": { - "passed_findings": 10, - "total_findings": 10, - }, - } - ] - - mock_metadata = MagicMock() - mock_metadata.Section = "1. IAM" - mock_metadata.LevelOfRisk = 3 - mock_metadata.Weight = 100 - - attributes_by_id = { - "req_1": { - "attributes": { - "req_attributes": [mock_metadata], - } - } - } - - result = _create_section_score_chart(requirements_list, attributes_by_id) - - assert isinstance(result, io.BytesIO) - mock_subplots.assert_called_once() - mock_close.assert_called_once_with(mock_fig) - - @patch("tasks.jobs.report.plt.close") - @patch("tasks.jobs.report.plt.savefig") - @patch("tasks.jobs.report.plt.subplots") - def test_create_marco_category_chart_with_data( - self, mock_subplots, mock_savefig, mock_close - ): - """Creates marco/category chart successfully.""" - mock_fig, mock_ax = MagicMock(), MagicMock() - mock_subplots.return_value = (mock_fig, mock_ax) - mock_ax.barh.return_value = [MagicMock()] - - requirements_list = [ - { - "id": "req_1", - "attributes": { - "status": StatusChoices.PASS, - }, - } - ] - - mock_metadata = MagicMock() - mock_metadata.Marco = "Marco1" - mock_metadata.Categoria = "Cat1" - - attributes_by_id = { - "req_1": { - "attributes": { - "req_attributes": [mock_metadata], - } - } - } - - result = _create_marco_category_chart(requirements_list, attributes_by_id) - - assert isinstance(result, io.BytesIO) - mock_close.assert_called_once_with(mock_fig) - - @patch("tasks.jobs.report.plt.close") - @patch("tasks.jobs.report.plt.savefig") - @patch("tasks.jobs.report.plt.subplots") - def test_create_dimensions_radar_chart( - self, mock_subplots, mock_savefig, mock_close - ): - """Creates radar chart for dimensions.""" - mock_fig, mock_ax = MagicMock(), MagicMock() - mock_ax.plot = MagicMock() - mock_ax.fill = MagicMock() - mock_subplots.return_value = (mock_fig, mock_ax) - - requirements_list = [ - { - "id": "req_1", - "attributes": { - "status": StatusChoices.PASS, - }, - } - ] - - mock_metadata = MagicMock() - mock_metadata.Dimensiones = ["trazabilidad", "integridad"] - - attributes_by_id = { - "req_1": { - "attributes": { - "req_attributes": [mock_metadata], - } - } - } - - result = _create_dimensions_radar_chart(requirements_list, attributes_by_id) - - assert isinstance(result, io.BytesIO) - mock_close.assert_called_once_with(mock_fig) - - @patch("tasks.jobs.report.plt.close") - @patch("tasks.jobs.report.plt.savefig") - @patch("tasks.jobs.report.plt.subplots") - def test_create_chart_closes_figure_on_error( - self, mock_subplots, mock_savefig, mock_close - ): - """Ensures figure is closed even if savefig fails.""" - mock_fig, mock_ax = MagicMock(), MagicMock() - mock_subplots.return_value = (mock_fig, mock_ax) - mock_savefig.side_effect = Exception("Save failed") - - requirements_list = [] - attributes_by_id = {} - - with pytest.raises(Exception): - _create_section_score_chart(requirements_list, attributes_by_id) - - # Verify figure was still closed - mock_close.assert_called_with(mock_fig) - - -@pytest.mark.django_db -class TestOptimizationImprovements: - """Test suite to verify optimization improvements work correctly.""" - - def test_constants_are_color_objects(self): - """Verify color constants are properly instantiated Color objects.""" - assert isinstance(COLOR_BLUE, colors.Color) - assert isinstance(COLOR_HIGH_RISK, colors.Color) - assert isinstance(COLOR_SAFE, colors.Color) - - def test_chart_color_constants_are_strings(self): - """Verify chart color constants are hex strings.""" - assert isinstance(CHART_COLOR_GREEN_1, str) - assert CHART_COLOR_GREEN_1.startswith("#") - assert len(CHART_COLOR_GREEN_1) == 7 - - def test_style_cache_persists_across_calls(self): - """Verify style caching reduces object creation.""" - # Clear any existing cache by calling directly - styles1 = _create_pdf_styles() - styles2 = _create_pdf_styles() - - # Should be the exact same cached object - assert id(styles1) == id(styles2) - - def test_helper_functions_return_consistent_results(self): - """Verify helper functions return consistent results.""" - # Same input should always return same output - assert _get_color_for_risk_level(3) == _get_color_for_risk_level(3) - assert _get_color_for_weight(100) == _get_color_for_weight(100) - assert _get_chart_color_for_percentage(75.0) == _get_chart_color_for_percentage( - 75.0 - ) - - -@pytest.mark.django_db -class TestGenerateComplianceReportsOptimized: - """Test suite for the optimized generate_compliance_reports_job function.""" - - def setup_method(self): - self.scan_id = str(uuid.uuid4()) - self.provider_id = str(uuid.uuid4()) - self.tenant_id = str(uuid.uuid4()) - - def test_no_findings_returns_early_for_both_reports(self): - """Test that function returns early when no findings exist.""" - with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter: - mock_filter.return_value.exists.return_value = False - - result = generate_compliance_reports_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result["threatscore"] == {"upload": False, "path": ""} - assert result["ens"] == {"upload": False, "path": ""} - mock_filter.assert_called_once_with(scan_id=self.scan_id) - - @patch("tasks.jobs.report.rmtree") - @patch("tasks.jobs.report._upload_to_s3") - @patch("tasks.jobs.report.generate_nis2_report") - @patch("tasks.jobs.report.generate_ens_report") - @patch("tasks.jobs.report.generate_threatscore_report") - @patch("tasks.jobs.report._generate_compliance_output_directory") - @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") - @patch("tasks.jobs.report.Provider") - @patch("tasks.jobs.report.ScanSummary") - def test_generates_reports_with_shared_queries( - self, - mock_scan_summary, - mock_provider, - mock_aggregate_stats, - mock_gen_dir, - mock_gen_threatscore, - mock_gen_ens, - mock_gen_nis2, - mock_upload, - mock_rmtree, - ): - """Test that requested reports are generated with shared database queries.""" - # Setup mocks - mock_scan_summary.objects.filter.return_value.exists.return_value = True - mock_provider_obj = Mock() - mock_provider_obj.uid = "test-uid" - mock_provider_obj.provider = "aws" - mock_provider.objects.get.return_value = mock_provider_obj - - mock_aggregate_stats.return_value = {"check-1": {"passed": 10, "total": 15}} - # Mock returns different paths for different compliance_framework calls - mock_gen_dir.side_effect = [ - "/tmp/reports/threatscore/output", # First call with compliance_framework="threatscore" - "/tmp/reports/ens/output", # Second call with compliance_framework="ens" - "/tmp/reports/nis2/output", # Third call with compliance_framework="nis2" - ] - mock_upload.side_effect = [ - "s3://bucket/threatscore.pdf", - "s3://bucket/ens.pdf", - "s3://bucket/nis2.pdf", - ] - - result = generate_compliance_reports_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - generate_threatscore=True, - generate_ens=True, - ) - - # Verify Provider fetched only ONCE (optimization) - mock_provider.objects.get.assert_called_once_with(id=self.provider_id) - - # Verify aggregation called only ONCE (optimization) - mock_aggregate_stats.assert_called_once_with(self.tenant_id, self.scan_id) - - # Verify both report generation functions were called with shared data - assert mock_gen_threatscore.call_count == 1 - assert mock_gen_ens.call_count == 1 - assert mock_gen_nis2.call_count == 1 - - # Verify provider_obj and requirement_statistics were passed to both - threatscore_call_kwargs = mock_gen_threatscore.call_args[1] - assert threatscore_call_kwargs["provider_obj"] == mock_provider_obj - assert threatscore_call_kwargs["requirement_statistics"] == { - "check-1": {"passed": 10, "total": 15} - } - - ens_call_kwargs = mock_gen_ens.call_args[1] - assert ens_call_kwargs["provider_obj"] == mock_provider_obj - assert ens_call_kwargs["requirement_statistics"] == { - "check-1": {"passed": 10, "total": 15} - } - - nis2_call_kwargs = mock_gen_nis2.call_args[1] - assert nis2_call_kwargs["provider_obj"] == mock_provider_obj - assert nis2_call_kwargs["requirement_statistics"] == { - "check-1": {"passed": 10, "total": 15} - } - - # Verify both reports were uploaded successfully - assert result["threatscore"]["upload"] is True - assert result["threatscore"]["path"] == "s3://bucket/threatscore.pdf" - assert result["ens"]["upload"] is True - assert result["ens"]["path"] == "s3://bucket/ens.pdf" - assert result["nis2"]["upload"] is True - assert result["nis2"]["path"] == "s3://bucket/nis2.pdf" - - # Cleanup should remove the temporary parent directory when everything uploads - mock_rmtree.assert_called_once() - cleanup_path_arg = mock_rmtree.call_args[0][0] - assert str(cleanup_path_arg) == "/tmp/reports" - - @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") - @patch("tasks.jobs.report.Provider") - @patch("tasks.jobs.report.ScanSummary") - def test_skips_ens_for_unsupported_provider( - self, mock_scan_summary, mock_provider, mock_aggregate_stats - ): - """Test that ENS report is skipped for M365 provider.""" - mock_scan_summary.objects.filter.return_value.exists.return_value = True - mock_provider_obj = Mock() - mock_provider_obj.uid = "test-uid" - mock_provider_obj.provider = "m365" # Not supported for ENS - mock_provider.objects.get.return_value = mock_provider_obj - - result = generate_compliance_reports_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - # ENS should be skipped, only ThreatScore key should have error/status - assert "ens" in result - assert result["ens"]["upload"] is False - - def test_findings_cache_reuses_loaded_findings(self): - """Test that findings cache properly reuses findings across calls.""" - # Create mock findings - mock_finding1 = Mock() - mock_finding1.check_id = "check-1" - mock_finding2 = Mock() - mock_finding2.check_id = "check-2" - mock_finding3 = Mock() - mock_finding3.check_id = "check-1" - - mock_output1 = Mock() - mock_output1.check_id = "check-1" - mock_output2 = Mock() - mock_output2.check_id = "check-2" - mock_output3 = Mock() - mock_output3.check_id = "check-1" - - # Pre-populate cache - findings_cache = { - "check-1": [mock_output1, mock_output3], - } - - with ( - patch("tasks.jobs.threatscore_utils.Finding") as mock_finding_class, - patch("tasks.jobs.threatscore_utils.FindingOutput") as mock_finding_output, - patch("tasks.jobs.threatscore_utils.rls_transaction"), - patch("tasks.jobs.threatscore_utils.batched") as mock_batched, - ): - # Setup mocks - mock_finding_class.all_objects.filter.return_value.order_by.return_value.iterator.return_value = [ - mock_finding2 - ] - mock_batched.return_value = [([mock_finding2], True)] - mock_finding_output.transform_api_finding.return_value = mock_output2 - - mock_provider = Mock() - - # Call with cache containing check-1, requesting check-1 and check-2 - result = _load_findings_for_requirement_checks( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - check_ids=["check-1", "check-2"], - prowler_provider=mock_provider, - findings_cache=findings_cache, - ) - - # Verify check-1 was reused from cache (no DB query) - assert len(result["check-1"]) == 2 - assert result["check-1"] == [mock_output1, mock_output3] - - # Verify check-2 was loaded from DB - assert len(result["check-2"]) == 1 - assert result["check-2"][0] == mock_output2 - - # Verify cache was updated with check-2 - assert "check-2" in findings_cache - assert findings_cache["check-2"] == [mock_output2] - - # Verify DB was only queried for check-2 (not check-1) - filter_call = mock_finding_class.all_objects.filter.call_args - assert filter_call[1]["check_id__in"] == ["check-2"] - - -class TestNIS2SectionChart: - """Test suite for _create_nis2_section_chart function.""" - - @pytest.fixture(autouse=True) - def setup_matplotlib(self): - """Setup matplotlib backend for tests.""" - matplotlib.use("Agg") - - def test_creates_chart_with_sections(self): - """Verify chart is created with correct sections and compliance data.""" - # Mock requirement with NIS2 section attribute - mock_attr = Mock() - mock_attr.Section = ( - "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS" - ) - - requirements_list = [ - { - "id": "1.1.1.a", - "description": "Test requirement", - "attributes": { - "passed_findings": 5, - "total_findings": 10, - "status": StatusChoices.FAIL, - }, - } - ] - - attributes_by_requirement_id = { - "1.1.1.a": { - "attributes": { - "req_attributes": [mock_attr], - } - } - } - - # Call function - result = _create_nis2_section_chart( - requirements_list, attributes_by_requirement_id - ) - - # Verify result is a BytesIO buffer - assert isinstance(result, io.BytesIO) - assert result.tell() > 0 # Buffer has content - - def test_handles_empty_requirements(self): - """Verify chart handles empty requirements gracefully.""" - result = _create_nis2_section_chart([], {}) - - # Verify result is still a valid BytesIO buffer - assert isinstance(result, io.BytesIO) - - def test_calculates_compliance_percentage_correctly(self): - """Verify compliance percentage calculation is correct.""" - mock_attr1 = Mock() - mock_attr1.Section = "11 ACCESS CONTROL" - - mock_attr2 = Mock() - mock_attr2.Section = "11 ACCESS CONTROL" - - requirements_list = [ - { - "id": "11.1.1", - "description": "Test 1", - "attributes": { - "passed_findings": 8, - "total_findings": 10, # 80% - "status": StatusChoices.PASS, - }, - }, - { - "id": "11.1.2", - "description": "Test 2", - "attributes": { - "passed_findings": 10, - "total_findings": 10, # 100% - "status": StatusChoices.PASS, - }, - }, - ] - - attributes_by_requirement_id = { - "11.1.1": {"attributes": {"req_attributes": [mock_attr1]}}, - "11.1.2": {"attributes": {"req_attributes": [mock_attr2]}}, - } - - # Call function - result = _create_nis2_section_chart( - requirements_list, attributes_by_requirement_id - ) - - # Expected: (8+10)/(10+10) = 18/20 = 90% - assert isinstance(result, io.BytesIO) - - -class TestNIS2SubsectionTable: - """Test suite for _create_nis2_subsection_table function.""" - - def test_creates_table_with_subsections(self): - """Verify table is created with correct subsection breakdown.""" - mock_attr1 = Mock() - mock_attr1.SubSection = ( - "1.1 Policy on the security of network and information systems" - ) - - mock_attr2 = Mock() - mock_attr2.SubSection = "1.2 Roles, responsibilities and authorities" - - requirements_list = [ - { - "id": "1.1.1.a", - "description": "Test 1", - "attributes": {"status": StatusChoices.PASS}, - }, - { - "id": "1.1.1.b", - "description": "Test 2", - "attributes": {"status": StatusChoices.FAIL}, - }, - { - "id": "1.2.1", - "description": "Test 3", - "attributes": {"status": StatusChoices.MANUAL}, - }, - ] - - attributes_by_requirement_id = { - "1.1.1.a": {"attributes": {"req_attributes": [mock_attr1]}}, - "1.1.1.b": {"attributes": {"req_attributes": [mock_attr1]}}, - "1.2.1": {"attributes": {"req_attributes": [mock_attr2]}}, - } - - # Call function - result = _create_nis2_subsection_table( - requirements_list, attributes_by_requirement_id - ) - - # Verify result is a Table - assert isinstance(result, Table) - - # Verify table has correct structure (header + data rows) - assert len(result._cellvalues) > 1 # At least header + 1 row - - # Verify header row - assert result._cellvalues[0][0] == "SubSection" - assert result._cellvalues[0][1] == "Total" - assert result._cellvalues[0][2] == "Pass" - assert result._cellvalues[0][3] == "Fail" - assert result._cellvalues[0][4] == "Manual" - assert result._cellvalues[0][5] == "Compliance %" - - def test_table_has_correct_styling(self): - """Verify table has NIS2 styling applied.""" - mock_attr = Mock() - mock_attr.SubSection = "Test SubSection" - - requirements_list = [ - { - "id": "1.1.1.a", - "description": "Test", - "attributes": {"status": StatusChoices.PASS}, - } - ] - - attributes_by_requirement_id = { - "1.1.1.a": {"attributes": {"req_attributes": [mock_attr]}} - } - - result = _create_nis2_subsection_table( - requirements_list, attributes_by_requirement_id - ) - - # Verify styling is applied - assert isinstance(result._cellStyles, list) - assert len(result._cellStyles) > 0 - - -class TestNIS2RequirementsIndex: - """Test suite for _create_nis2_requirements_index function.""" - - def test_creates_hierarchical_index(self): - """Verify index creates hierarchical structure by Section and SubSection.""" - pdf_styles = _create_pdf_styles() - - mock_attr1 = Mock() - mock_attr1.Section = "1 POLICY ON SECURITY" - mock_attr1.SubSection = "1.1 Policy definition" - - mock_attr2 = Mock() - mock_attr2.Section = "1 POLICY ON SECURITY" - mock_attr2.SubSection = "1.2 Roles and responsibilities" - - requirements_list = [ - { - "id": "1.1.1.a", - "description": "Define security policies", - "attributes": {"status": StatusChoices.PASS}, - }, - { - "id": "1.2.1", - "description": "Assign security roles", - "attributes": {"status": StatusChoices.FAIL}, - }, - ] - - attributes_by_requirement_id = { - "1.1.1.a": {"attributes": {"req_attributes": [mock_attr1]}}, - "1.2.1": {"attributes": {"req_attributes": [mock_attr2]}}, - } - - # Call function - result = _create_nis2_requirements_index( - requirements_list, - attributes_by_requirement_id, - pdf_styles["h2"], - pdf_styles["h3"], - pdf_styles["normal"], - ) - - # Verify result is a list of elements - assert isinstance(result, list) - assert len(result) > 0 - - def test_includes_status_indicators(self): - """Verify index includes status indicators (βœ“, βœ—, βŠ™).""" - pdf_styles = _create_pdf_styles() - - mock_attr = Mock() - mock_attr.Section = "Test Section" - mock_attr.SubSection = "Test SubSection" - - requirements_list = [ - { - "id": "test.1", - "description": "Passed requirement", - "attributes": {"status": StatusChoices.PASS}, - }, - { - "id": "test.2", - "description": "Failed requirement", - "attributes": {"status": StatusChoices.FAIL}, - }, - { - "id": "test.3", - "description": "Manual requirement", - "attributes": {"status": StatusChoices.MANUAL}, - }, - ] - - attributes_by_requirement_id = { - "test.1": {"attributes": {"req_attributes": [mock_attr]}}, - "test.2": {"attributes": {"req_attributes": [mock_attr]}}, - "test.3": {"attributes": {"req_attributes": [mock_attr]}}, - } - - result = _create_nis2_requirements_index( - requirements_list, - attributes_by_requirement_id, - pdf_styles["h2"], - pdf_styles["h3"], - pdf_styles["normal"], - ) - - # Convert paragraphs to text and check for status indicators - str(result) - # Status indicators should be present in the generated content - assert len(result) > 0 - - -@pytest.mark.django_db -class TestGenerateNIS2Report: - """Test suite for generate_nis2_report function.""" - - @patch("tasks.jobs.report.initialize_prowler_provider") - @patch("tasks.jobs.report.Provider.objects.get") - @patch("tasks.jobs.report.ScanSummary.objects.filter") - @patch("tasks.jobs.report.Compliance.get_bulk") - @patch("tasks.jobs.report.SimpleDocTemplate") - def test_generates_nis2_report_successfully( - self, - mock_doc, - mock_compliance, - mock_scan_summary, - mock_provider_get, - mock_init_provider, - tenants_fixture, - scans_fixture, - ): - """Verify NIS2 report generation completes successfully.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Setup mocks - mock_provider = Mock() - mock_provider.provider = "aws" - mock_provider.uid = "provider-123" - mock_provider_get.return_value = mock_provider - - mock_scan_summary.return_value.exists.return_value = True - - # Mock compliance object - mock_compliance_obj = Mock() - mock_compliance_obj.Framework = "NIS2" - mock_compliance_obj.Name = "Network and Information Security Directive" - mock_compliance_obj.Version = "" - mock_compliance_obj.Description = "NIS2 Directive" - mock_compliance_obj.Requirements = [] - - mock_compliance.return_value = {"nis2_aws": mock_compliance_obj} - - mock_init_provider.return_value = MagicMock() - mock_doc_instance = Mock() - mock_doc.return_value = mock_doc_instance - - expected_output_path = "/tmp/test_nis2.pdf" - - # Call function - with patch("tasks.jobs.report.rls_transaction"): - with patch( - "tasks.jobs.report._aggregate_requirement_statistics_from_database" - ) as mock_aggregate: - mock_aggregate.return_value = {} - - with patch( - "tasks.jobs.report._calculate_requirements_data_from_statistics" - ) as mock_calculate: - mock_calculate.return_value = ({}, []) - - # Should not raise exception - generate_nis2_report( - tenant_id=str(tenant.id), - scan_id=str(scan.id), - compliance_id="nis2_aws", - output_path=expected_output_path, - provider_id="provider-123", - only_failed=True, - ) - - # Verify SimpleDocTemplate was initialized with correct output path - mock_doc.assert_called_once() - call_args = mock_doc.call_args - assert call_args[0][0] == expected_output_path, ( - f"Expected SimpleDocTemplate to be called with {expected_output_path}, " - f"but got {call_args[0][0]}" - ) - - # Verify PDF was built - mock_doc_instance.build.assert_called_once() - - # Verify initialize_prowler_provider was called with the provider - mock_init_provider.assert_called_once_with(mock_provider) - - def test_nis2_colors_are_defined(self): - """Verify NIS2 specific colors are defined.""" - # Check that NIS2 primary color exists - assert COLOR_NIS2_PRIMARY is not None - assert isinstance(COLOR_NIS2_PRIMARY, colors.Color) diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py new file mode 100644 index 0000000000..530e0af472 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports.py @@ -0,0 +1,410 @@ +import uuid +from unittest.mock import Mock, patch + +import matplotlib +import pytest +from reportlab.lib import colors +from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report +from tasks.jobs.reports import ( + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COLOR_BLUE, + COLOR_ENS_ALTO, + COLOR_HIGH_RISK, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_NIS2_PRIMARY, + COLOR_SAFE, + create_pdf_styles, + get_chart_color_for_percentage, + get_color_for_compliance, + get_color_for_risk_level, + get_color_for_weight, +) +from tasks.jobs.threatscore_utils import ( + _aggregate_requirement_statistics_from_database, + _load_findings_for_requirement_checks, +) + +from api.models import Finding, StatusChoices +from prowler.lib.check.models import Severity + +matplotlib.use("Agg") # Use non-interactive backend for tests + + +@pytest.mark.django_db +class TestAggregateRequirementStatistics: + """Test suite for _aggregate_requirement_statistics_from_database function.""" + + def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture): + """Verify correct pass/total counts per check are aggregated from database.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_1", + status=StatusChoices.PASS, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_1", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-3", + check_id="check_2", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert "check_1" in result + assert result["check_1"]["passed"] == 1 + assert result["check_1"]["total"] == 2 + + assert "check_2" in result + assert result["check_2"]["passed"] == 1 + assert result["check_2"]["total"] == 1 + + def test_handles_empty_scan(self, tenants_fixture, scans_fixture): + """Verify empty result is returned for scan with no findings.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result == {} + + def test_only_failed_findings(self, tenants_fixture, scans_fixture): + """Verify correct counts when all findings are FAIL.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_1", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_1", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result["check_1"]["passed"] == 0 + assert result["check_1"]["total"] == 2 + + def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture): + """Verify multiple findings for same check are correctly aggregated.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + for i in range(5): + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"finding-{i}", + check_id="check_1", + status=StatusChoices.PASS if i % 2 == 0 else StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result["check_1"]["passed"] == 3 + assert result["check_1"]["total"] == 5 + + def test_mixed_statuses(self, tenants_fixture, scans_fixture): + """Verify MANUAL status is counted in total but not passed.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_1", + status=StatusChoices.PASS, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_1", + status=StatusChoices.MANUAL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + # MANUAL findings are excluded from the aggregation query + # since it only counts PASS and FAIL statuses + assert result["check_1"]["passed"] == 1 + assert result["check_1"]["total"] == 1 + + +class TestColorHelperFunctions: + """Test suite for color helper functions.""" + + def test_get_color_for_risk_level_high(self): + """Test high risk level returns correct color.""" + result = get_color_for_risk_level(5) + assert result == COLOR_HIGH_RISK + + def test_get_color_for_risk_level_medium_high(self): + """Test risk level 4 returns high risk color.""" + result = get_color_for_risk_level(4) + assert result == COLOR_HIGH_RISK # >= 4 is high risk + + def test_get_color_for_risk_level_medium(self): + """Test risk level 3 returns medium risk color.""" + result = get_color_for_risk_level(3) + assert result == COLOR_MEDIUM_RISK # >= 3 is medium risk + + def test_get_color_for_risk_level_low(self): + """Test low risk level returns safe color.""" + result = get_color_for_risk_level(1) + assert result == COLOR_SAFE # < 2 is safe + + def test_get_color_for_weight_high(self): + """Test high weight returns correct color.""" + result = get_color_for_weight(150) + assert result == COLOR_HIGH_RISK # > 100 is high risk + + def test_get_color_for_weight_medium(self): + """Test medium weight returns low risk color.""" + result = get_color_for_weight(100) + assert result == COLOR_LOW_RISK # 51-100 is low risk + + def test_get_color_for_weight_low(self): + """Test low weight returns safe color.""" + result = get_color_for_weight(50) + assert result == COLOR_SAFE # <= 50 is safe + + def test_get_color_for_compliance_high(self): + """Test high compliance returns green color.""" + result = get_color_for_compliance(85) + assert result == COLOR_SAFE + + def test_get_color_for_compliance_medium(self): + """Test medium compliance returns yellow color.""" + result = get_color_for_compliance(70) + assert result == COLOR_LOW_RISK + + def test_get_color_for_compliance_low(self): + """Test low compliance returns red color.""" + result = get_color_for_compliance(50) + assert result == COLOR_HIGH_RISK + + def test_get_chart_color_for_percentage_excellent(self): + """Test excellent percentage returns correct chart color.""" + result = get_chart_color_for_percentage(90) + assert result == CHART_COLOR_GREEN_1 + + def test_get_chart_color_for_percentage_good(self): + """Test good percentage returns correct chart color.""" + result = get_chart_color_for_percentage(70) + assert result == CHART_COLOR_GREEN_2 + + def test_get_chart_color_for_percentage_fair(self): + """Test fair percentage returns correct chart color.""" + result = get_chart_color_for_percentage(50) + assert result == CHART_COLOR_YELLOW + + def test_get_chart_color_for_percentage_poor(self): + """Test poor percentage returns correct chart color.""" + result = get_chart_color_for_percentage(30) + assert result == CHART_COLOR_ORANGE + + def test_get_chart_color_for_percentage_critical(self): + """Test critical percentage returns correct chart color.""" + result = get_chart_color_for_percentage(10) + assert result == CHART_COLOR_RED + + +class TestPDFStylesCreation: + """Test suite for PDF styles creation.""" + + def test_create_pdf_styles_returns_dict(self): + """Test that create_pdf_styles returns a dictionary.""" + result = create_pdf_styles() + assert isinstance(result, dict) + + def test_create_pdf_styles_caches_result(self): + """Test that create_pdf_styles caches the result.""" + result1 = create_pdf_styles() + result2 = create_pdf_styles() + assert result1 is result2 + + def test_pdf_styles_have_correct_keys(self): + """Test that PDF styles dictionary has expected keys.""" + result = create_pdf_styles() + expected_keys = ["title", "h1", "h2", "h3", "normal", "normal_center"] + for key in expected_keys: + assert key in result + + +@pytest.mark.django_db +class TestLoadFindingsForChecks: + """Test suite for _load_findings_for_requirement_checks function.""" + + def test_empty_check_ids_returns_empty(self, tenants_fixture, providers_fixture): + """Test that empty check_ids list returns empty dict.""" + tenant = tenants_fixture[0] + + mock_prowler_provider = Mock() + mock_prowler_provider.identity.account = "test-account" + + result = _load_findings_for_requirement_checks( + str(tenant.id), str(uuid.uuid4()), [], mock_prowler_provider + ) + + assert result == {} + + +@pytest.mark.django_db +class TestGenerateThreatscoreReportFunction: + """Test suite for generate_threatscore_report function.""" + + @patch("tasks.jobs.reports.base.initialize_prowler_provider") + def test_generate_threatscore_report_exception_handling( + self, + mock_initialize_provider, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test that exceptions during report generation are properly handled.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + mock_initialize_provider.side_effect = Exception("Test exception") + + with pytest.raises(Exception) as exc_info: + generate_threatscore_report( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + compliance_id="prowler_threatscore_aws", + output_path="/tmp/test_report.pdf", + provider_id=str(provider.id), + ) + + assert "Test exception" in str(exc_info.value) + + +@pytest.mark.django_db +class TestGenerateComplianceReportsOptimized: + """Test suite for generate_compliance_reports function.""" + + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_threatscore_report") + @patch("tasks.jobs.report.generate_ens_report") + @patch("tasks.jobs.report.generate_nis2_report") + def test_no_findings_returns_early_for_both_reports( + self, + mock_nis2, + mock_ens, + mock_threatscore, + mock_upload, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test that function returns early when scan has no findings.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + result = generate_compliance_reports( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + provider_id=str(provider.id), + generate_threatscore=True, + generate_ens=True, + generate_nis2=True, + ) + + assert result["threatscore"]["upload"] is False + assert result["ens"]["upload"] is False + assert result["nis2"]["upload"] is False + + mock_threatscore.assert_not_called() + mock_ens.assert_not_called() + mock_nis2.assert_not_called() + + +class TestOptimizationImprovements: + """Test suite for optimization-related functionality.""" + + def test_chart_color_constants_are_strings(self): + """Verify chart color constants are valid hex color strings.""" + assert CHART_COLOR_GREEN_1.startswith("#") + assert CHART_COLOR_GREEN_2.startswith("#") + assert CHART_COLOR_YELLOW.startswith("#") + assert CHART_COLOR_ORANGE.startswith("#") + assert CHART_COLOR_RED.startswith("#") + + def test_color_constants_are_color_objects(self): + """Verify color constants are Color objects.""" + assert isinstance(COLOR_BLUE, colors.Color) + assert isinstance(COLOR_HIGH_RISK, colors.Color) + assert isinstance(COLOR_SAFE, colors.Color) + assert isinstance(COLOR_ENS_ALTO, colors.Color) + assert isinstance(COLOR_NIS2_PRIMARY, colors.Color) diff --git a/api/src/backend/tasks/tests/test_reports_base.py b/api/src/backend/tasks/tests/test_reports_base.py new file mode 100644 index 0000000000..d2fda4f830 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_base.py @@ -0,0 +1,1346 @@ +import io + +import pytest +from reportlab.lib.units import inch +from reportlab.platypus import Image, LongTable, Paragraph, Spacer, Table +from tasks.jobs.reports import ( # Configuration; Colors; Components; Charts; Base + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COLOR_BLUE, + COLOR_DARK_GRAY, + COLOR_HIGH_RISK, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_SAFE, + FRAMEWORK_REGISTRY, + BaseComplianceReportGenerator, + ColumnConfig, + ComplianceData, + FrameworkConfig, + RequirementData, + create_badge, + create_data_table, + create_findings_table, + create_horizontal_bar_chart, + create_info_table, + create_multi_badge_row, + create_pdf_styles, + create_pie_chart, + create_radar_chart, + create_risk_component, + create_section_header, + create_stacked_bar_chart, + create_status_badge, + create_summary_table, + create_vertical_bar_chart, + get_chart_color_for_percentage, + get_color_for_compliance, + get_color_for_risk_level, + get_color_for_weight, + get_framework_config, + get_status_color, +) + +# ============================================================================= +# Configuration Tests +# ============================================================================= + + +class TestFrameworkConfig: + """Tests for FrameworkConfig dataclass.""" + + def test_framework_config_creation(self): + """Test creating a FrameworkConfig with required fields.""" + config = FrameworkConfig( + name="test_framework", + display_name="Test Framework", + ) + + assert config.name == "test_framework" + assert config.display_name == "Test Framework" + assert config.logo_filename is None + assert config.language == "en" + assert config.has_risk_levels is False + + def test_framework_config_with_all_fields(self): + """Test creating a FrameworkConfig with all fields.""" + config = FrameworkConfig( + name="custom", + display_name="Custom Framework", + logo_filename="custom_logo.png", + primary_color=COLOR_BLUE, + secondary_color=COLOR_SAFE, + attribute_fields=["Section", "SubSection"], + sections=["1. Security", "2. Compliance"], + language="es", + has_risk_levels=True, + has_dimensions=True, + has_niveles=True, + has_weight=True, + ) + + assert config.name == "custom" + assert config.logo_filename == "custom_logo.png" + assert config.language == "es" + assert config.has_risk_levels is True + assert config.has_dimensions is True + assert len(config.attribute_fields) == 2 + assert len(config.sections) == 2 + + +class TestFrameworkRegistry: + """Tests for the framework registry.""" + + def test_registry_contains_threatscore(self): + """Test that ThreatScore is in the registry.""" + assert "prowler_threatscore" in FRAMEWORK_REGISTRY + config = FRAMEWORK_REGISTRY["prowler_threatscore"] + assert config.has_risk_levels is True + assert config.has_weight is True + + def test_registry_contains_ens(self): + """Test that ENS is in the registry.""" + assert "ens" in FRAMEWORK_REGISTRY + config = FRAMEWORK_REGISTRY["ens"] + assert config.language == "es" + assert config.has_niveles is True + assert config.has_dimensions is True + + def test_registry_contains_nis2(self): + """Test that NIS2 is in the registry.""" + assert "nis2" in FRAMEWORK_REGISTRY + config = FRAMEWORK_REGISTRY["nis2"] + assert config.language == "en" + + def test_get_framework_config_threatscore(self): + """Test getting ThreatScore config.""" + config = get_framework_config("prowler_threatscore_aws") + assert config is not None + assert config.name == "prowler_threatscore" + + def test_get_framework_config_ens(self): + """Test getting ENS config.""" + config = get_framework_config("ens_rd2022_aws") + assert config is not None + assert config.name == "ens" + + def test_get_framework_config_nis2(self): + """Test getting NIS2 config.""" + config = get_framework_config("nis2_aws") + assert config is not None + assert config.name == "nis2" + + def test_get_framework_config_unknown(self): + """Test getting unknown framework returns None.""" + config = get_framework_config("unknown_framework") + assert config is None + + +# ============================================================================= +# Color Helper Tests +# ============================================================================= + + +class TestColorHelpers: + """Tests for color helper functions.""" + + def test_get_color_for_risk_level_high(self): + """Test high risk level returns red.""" + assert get_color_for_risk_level(5) == COLOR_HIGH_RISK + assert get_color_for_risk_level(4) == COLOR_HIGH_RISK + + def test_get_color_for_risk_level_very_high(self): + """Test very high risk level (>5) still returns high risk color.""" + assert get_color_for_risk_level(10) == COLOR_HIGH_RISK + assert get_color_for_risk_level(100) == COLOR_HIGH_RISK + + def test_get_color_for_risk_level_medium(self): + """Test medium risk level returns orange.""" + assert get_color_for_risk_level(3) == COLOR_MEDIUM_RISK + + def test_get_color_for_risk_level_low(self): + """Test low risk level returns yellow.""" + assert get_color_for_risk_level(2) == COLOR_LOW_RISK + + def test_get_color_for_risk_level_safe(self): + """Test safe risk level returns green.""" + assert get_color_for_risk_level(1) == COLOR_SAFE + assert get_color_for_risk_level(0) == COLOR_SAFE + + def test_get_color_for_risk_level_negative(self): + """Test negative risk level returns safe color.""" + assert get_color_for_risk_level(-1) == COLOR_SAFE + + def test_get_color_for_weight_high(self): + """Test high weight returns red.""" + assert get_color_for_weight(150) == COLOR_HIGH_RISK + assert get_color_for_weight(101) == COLOR_HIGH_RISK + + def test_get_color_for_weight_medium(self): + """Test medium weight returns yellow.""" + assert get_color_for_weight(100) == COLOR_LOW_RISK + assert get_color_for_weight(51) == COLOR_LOW_RISK + + def test_get_color_for_weight_low(self): + """Test low weight returns green.""" + assert get_color_for_weight(50) == COLOR_SAFE + assert get_color_for_weight(0) == COLOR_SAFE + + def test_get_color_for_compliance_high(self): + """Test high compliance returns green.""" + assert get_color_for_compliance(100) == COLOR_SAFE + assert get_color_for_compliance(80) == COLOR_SAFE + + def test_get_color_for_compliance_medium(self): + """Test medium compliance returns yellow.""" + assert get_color_for_compliance(79) == COLOR_LOW_RISK + assert get_color_for_compliance(60) == COLOR_LOW_RISK + + def test_get_color_for_compliance_low(self): + """Test low compliance returns red.""" + assert get_color_for_compliance(59) == COLOR_HIGH_RISK + assert get_color_for_compliance(0) == COLOR_HIGH_RISK + + def test_get_status_color_pass(self): + """Test PASS status returns green.""" + assert get_status_color("PASS") == COLOR_SAFE + assert get_status_color("pass") == COLOR_SAFE + + def test_get_status_color_fail(self): + """Test FAIL status returns red.""" + assert get_status_color("FAIL") == COLOR_HIGH_RISK + assert get_status_color("fail") == COLOR_HIGH_RISK + + def test_get_status_color_manual(self): + """Test MANUAL status returns gray.""" + assert get_status_color("MANUAL") == COLOR_DARK_GRAY + + +class TestChartColorHelpers: + """Tests for chart color functions.""" + + def test_chart_color_for_high_percentage(self): + """Test high percentage returns green.""" + assert get_chart_color_for_percentage(100) == CHART_COLOR_GREEN_1 + assert get_chart_color_for_percentage(80) == CHART_COLOR_GREEN_1 + + def test_chart_color_for_medium_high_percentage(self): + """Test medium-high percentage returns light green.""" + assert get_chart_color_for_percentage(79) == CHART_COLOR_GREEN_2 + assert get_chart_color_for_percentage(60) == CHART_COLOR_GREEN_2 + + def test_chart_color_for_medium_percentage(self): + """Test medium percentage returns yellow.""" + assert get_chart_color_for_percentage(59) == CHART_COLOR_YELLOW + assert get_chart_color_for_percentage(40) == CHART_COLOR_YELLOW + + def test_chart_color_for_medium_low_percentage(self): + """Test medium-low percentage returns orange.""" + assert get_chart_color_for_percentage(39) == CHART_COLOR_ORANGE + assert get_chart_color_for_percentage(20) == CHART_COLOR_ORANGE + + def test_chart_color_for_low_percentage(self): + """Test low percentage returns red.""" + assert get_chart_color_for_percentage(19) == CHART_COLOR_RED + assert get_chart_color_for_percentage(0) == CHART_COLOR_RED + + def test_chart_color_boundary_values(self): + """Test chart color at exact boundary values.""" + # Exact boundaries + assert get_chart_color_for_percentage(80) == CHART_COLOR_GREEN_1 + assert get_chart_color_for_percentage(60) == CHART_COLOR_GREEN_2 + assert get_chart_color_for_percentage(40) == CHART_COLOR_YELLOW + assert get_chart_color_for_percentage(20) == CHART_COLOR_ORANGE + + +# ============================================================================= +# Component Tests +# ============================================================================= + + +class TestBadgeComponents: + """Tests for badge component functions.""" + + def test_create_badge_returns_table(self): + """Test create_badge returns a Table object.""" + badge = create_badge("Test", COLOR_BLUE) + assert isinstance(badge, Table) + + def test_create_badge_with_custom_width(self): + """Test create_badge with custom width.""" + badge = create_badge("Test", COLOR_BLUE, width=2 * inch) + assert badge is not None + + def test_create_status_badge_pass(self): + """Test status badge for PASS.""" + badge = create_status_badge("PASS") + assert isinstance(badge, Table) + + def test_create_status_badge_fail(self): + """Test status badge for FAIL.""" + badge = create_status_badge("FAIL") + assert badge is not None + + def test_create_multi_badge_row_with_badges(self): + """Test multi-badge row with data.""" + badges = [ + ("A", COLOR_BLUE), + ("B", COLOR_SAFE), + ] + table = create_multi_badge_row(badges) + assert isinstance(table, Table) + + def test_create_multi_badge_row_empty(self): + """Test multi-badge row with empty list.""" + table = create_multi_badge_row([]) + assert table is not None + + +class TestRiskComponent: + """Tests for risk component function.""" + + def test_create_risk_component_returns_table(self): + """Test risk component returns a Table.""" + component = create_risk_component(risk_level=4, weight=100, score=50) + assert isinstance(component, Table) + + def test_create_risk_component_high_risk(self): + """Test risk component with high risk level.""" + component = create_risk_component(risk_level=5, weight=150, score=100) + assert component is not None + + def test_create_risk_component_low_risk(self): + """Test risk component with low risk level.""" + component = create_risk_component(risk_level=1, weight=10, score=10) + assert component is not None + + +class TestTableComponents: + """Tests for table component functions.""" + + def test_create_info_table(self): + """Test info table creation.""" + rows = [ + ("Label 1:", "Value 1"), + ("Label 2:", "Value 2"), + ] + table = create_info_table(rows) + assert isinstance(table, Table) + + def test_create_info_table_with_custom_widths(self): + """Test info table with custom column widths.""" + rows = [("Test:", "Value")] + table = create_info_table(rows, label_width=3 * inch, value_width=3 * inch) + assert table is not None + + def test_create_data_table(self): + """Test data table creation.""" + data = [ + {"name": "Item 1", "value": "100"}, + {"name": "Item 2", "value": "200"}, + ] + columns = [ + ColumnConfig("Name", 2 * inch, "name"), + ColumnConfig("Value", 1 * inch, "value"), + ] + table = create_data_table(data, columns) + assert isinstance(table, Table) + + def test_create_data_table_with_callable_field(self): + """Test data table with callable field.""" + data = [{"raw_value": 100}] + columns = [ + ColumnConfig("Formatted", 2 * inch, lambda x: f"${x['raw_value']}"), + ] + table = create_data_table(data, columns) + assert table is not None + + def test_create_summary_table(self): + """Test summary table creation.""" + table = create_summary_table( + label="Score:", + value="85%", + value_color=COLOR_SAFE, + ) + assert isinstance(table, Table) + + def test_create_summary_table_with_custom_widths(self): + """Test summary table with custom widths.""" + table = create_summary_table( + label="ThreatScore:", + value="92.5%", + value_color=COLOR_SAFE, + label_width=3 * inch, + value_width=2.5 * inch, + ) + assert isinstance(table, Table) + + +class TestFindingsTable: + """Tests for findings table component.""" + + def test_create_findings_table_with_dicts(self): + """Test findings table creation with dict data.""" + findings = [ + { + "title": "Finding 1", + "resource_name": "resource-1", + "severity": "HIGH", + "status": "FAIL", + "region": "us-east-1", + }, + { + "title": "Finding 2", + "resource_name": "resource-2", + "severity": "LOW", + "status": "PASS", + "region": "eu-west-1", + }, + ] + table = create_findings_table(findings) + assert isinstance(table, Table) + + def test_create_findings_table_with_custom_columns(self): + """Test findings table with custom column configuration.""" + findings = [{"name": "Test", "value": "100"}] + columns = [ + ColumnConfig("Name", 2 * inch, "name"), + ColumnConfig("Value", 1 * inch, "value"), + ] + table = create_findings_table(findings, columns=columns) + assert table is not None + + def test_create_findings_table_empty(self): + """Test findings table with empty list.""" + table = create_findings_table([]) + assert table is not None + + +class TestSectionHeader: + """Tests for section header component.""" + + def test_create_section_header_with_spacer(self): + """Test section header with spacer.""" + styles = create_pdf_styles() + elements = create_section_header("Test Header", styles["h1"]) + + assert len(elements) == 2 + assert isinstance(elements[0], Paragraph) + assert isinstance(elements[1], Spacer) + + def test_create_section_header_without_spacer(self): + """Test section header without spacer.""" + styles = create_pdf_styles() + elements = create_section_header("Test Header", styles["h1"], add_spacer=False) + + assert len(elements) == 1 + assert isinstance(elements[0], Paragraph) + + def test_create_section_header_custom_spacer_height(self): + """Test section header with custom spacer height.""" + styles = create_pdf_styles() + elements = create_section_header("Test Header", styles["h2"], spacer_height=0.5) + + assert len(elements) == 2 + + +# ============================================================================= +# Chart Tests +# ============================================================================= + + +class TestChartCreation: + """Tests for chart creation functions.""" + + def test_create_vertical_bar_chart(self): + """Test vertical bar chart creation.""" + buffer = create_vertical_bar_chart( + labels=["A", "B", "C"], + values=[80, 60, 40], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() # Not empty + + def test_create_vertical_bar_chart_with_options(self): + """Test vertical bar chart with custom options.""" + buffer = create_vertical_bar_chart( + labels=["Section 1", "Section 2"], + values=[90, 70], + ylabel="Compliance", + title="Test Chart", + figsize=(8, 6), + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_horizontal_bar_chart(self): + """Test horizontal bar chart creation.""" + buffer = create_horizontal_bar_chart( + labels=["Category 1", "Category 2", "Category 3"], + values=[85, 65, 45], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_horizontal_bar_chart_with_options(self): + """Test horizontal bar chart with custom options.""" + buffer = create_horizontal_bar_chart( + labels=["A", "B"], + values=[100, 50], + xlabel="Percentage", + title="Custom Chart", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_radar_chart(self): + """Test radar chart creation.""" + buffer = create_radar_chart( + labels=["Dim 1", "Dim 2", "Dim 3", "Dim 4", "Dim 5"], + values=[80, 70, 60, 90, 75], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_radar_chart_with_options(self): + """Test radar chart with custom options.""" + buffer = create_radar_chart( + labels=["A", "B", "C"], + values=[50, 60, 70], + color="#FF0000", + fill_alpha=0.5, + title="Custom Radar", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_pie_chart(self): + """Test pie chart creation.""" + buffer = create_pie_chart( + labels=["Pass", "Fail"], + values=[80, 20], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_pie_chart_with_options(self): + """Test pie chart with custom options.""" + buffer = create_pie_chart( + labels=["Pass", "Fail", "Manual"], + values=[60, 30, 10], + colors=["#4CAF50", "#F44336", "#9E9E9E"], + title="Status Distribution", + autopct="%1.0f%%", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_stacked_bar_chart(self): + """Test stacked bar chart creation.""" + buffer = create_stacked_bar_chart( + labels=["Section 1", "Section 2", "Section 3"], + data_series={ + "Pass": [8, 6, 4], + "Fail": [2, 4, 6], + }, + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_stacked_bar_chart_with_options(self): + """Test stacked bar chart with custom options.""" + buffer = create_stacked_bar_chart( + labels=["A", "B"], + data_series={ + "Pass": [10, 5], + "Fail": [2, 3], + "Manual": [1, 2], + }, + colors={ + "Pass": "#4CAF50", + "Fail": "#F44336", + "Manual": "#9E9E9E", + }, + xlabel="Categories", + ylabel="Requirements", + title="Requirements by Status", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_stacked_bar_chart_without_legend(self): + """Test stacked bar chart without legend.""" + buffer = create_stacked_bar_chart( + labels=["X", "Y"], + data_series={"A": [1, 2]}, + show_legend=False, + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_vertical_bar_chart_without_labels(self): + """Test vertical bar chart without value labels.""" + buffer = create_vertical_bar_chart( + labels=["A", "B"], + values=[50, 75], + show_labels=False, + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_vertical_bar_chart_with_explicit_colors(self): + """Test vertical bar chart with explicit color list.""" + buffer = create_vertical_bar_chart( + labels=["Pass", "Fail"], + values=[80, 20], + colors=["#4CAF50", "#F44336"], + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_horizontal_bar_chart_auto_figsize(self): + """Test horizontal bar chart auto-calculates figure size for many items.""" + labels = [f"Item {i}" for i in range(20)] + values = [50 + i * 2 for i in range(20)] + buffer = create_horizontal_bar_chart( + labels=labels, + values=values, + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_horizontal_bar_chart_with_explicit_colors(self): + """Test horizontal bar chart with explicit colors.""" + buffer = create_horizontal_bar_chart( + labels=["A", "B", "C"], + values=[80, 60, 40], + colors=["#4CAF50", "#FFEB3B", "#F44336"], + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_radar_chart_with_custom_ticks(self): + """Test radar chart with custom y-axis ticks.""" + buffer = create_radar_chart( + labels=["A", "B", "C", "D"], + values=[25, 50, 75, 100], + y_ticks=[0, 25, 50, 75, 100], + ) + assert isinstance(buffer, io.BytesIO) + + +# ============================================================================= +# Data Class Tests +# ============================================================================= + + +class TestDataClasses: + """Tests for data classes.""" + + def test_requirement_data_creation(self): + """Test RequirementData creation.""" + req = RequirementData( + id="REQ-001", + description="Test requirement", + status="PASS", + passed_findings=10, + total_findings=10, + ) + assert req.id == "REQ-001" + assert req.status == "PASS" + assert req.passed_findings == 10 + + def test_requirement_data_with_failed_findings(self): + """Test RequirementData with failed findings.""" + req = RequirementData( + id="REQ-002", + description="Failed requirement", + status="FAIL", + passed_findings=3, + failed_findings=7, + total_findings=10, + ) + assert req.failed_findings == 7 + assert req.total_findings == 10 + + def test_requirement_data_defaults(self): + """Test RequirementData default values.""" + req = RequirementData( + id="REQ-003", + description="Minimal requirement", + status="MANUAL", + ) + assert req.passed_findings == 0 + assert req.failed_findings == 0 + assert req.total_findings == 0 + + def test_compliance_data_creation(self): + """Test ComplianceData creation.""" + data = ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="test_compliance", + framework="Test", + name="Test Compliance", + version="1.0", + description="Test description", + ) + assert data.tenant_id == "tenant-123" + assert data.framework == "Test" + assert data.requirements == [] + + def test_compliance_data_with_requirements(self): + """Test ComplianceData with requirements list.""" + reqs = [ + RequirementData(id="R1", description="Req 1", status="PASS"), + RequirementData(id="R2", description="Req 2", status="FAIL"), + ] + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + requirements=reqs, + ) + assert len(data.requirements) == 2 + assert data.requirements[0].id == "R1" + + def test_compliance_data_with_attributes(self): + """Test ComplianceData with attributes dictionary.""" + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + attributes_by_requirement_id={ + "R1": {"attributes": {"key": "value"}}, + }, + ) + assert "R1" in data.attributes_by_requirement_id + assert data.attributes_by_requirement_id["R1"]["attributes"]["key"] == "value" + + +# ============================================================================= +# PDF Styles Tests +# ============================================================================= + + +class TestPDFStyles: + """Tests for PDF styles.""" + + def test_create_pdf_styles_returns_dict(self): + """Test that create_pdf_styles returns a dictionary.""" + styles = create_pdf_styles() + assert isinstance(styles, dict) + + def test_create_pdf_styles_has_required_keys(self): + """Test that styles dict has all required keys.""" + styles = create_pdf_styles() + required_keys = ["title", "h1", "h2", "h3", "normal", "normal_center"] + for key in required_keys: + assert key in styles + + def test_create_pdf_styles_caches_result(self): + """Test that styles are cached.""" + styles1 = create_pdf_styles() + styles2 = create_pdf_styles() + assert styles1 is styles2 + + +# ============================================================================= +# Base Generator Tests +# ============================================================================= + + +class TestBaseComplianceReportGenerator: + """Tests for BaseComplianceReportGenerator.""" + + def test_cannot_instantiate_directly(self): + """Test that base class cannot be instantiated directly.""" + config = FrameworkConfig(name="test", display_name="Test") + with pytest.raises(TypeError): + BaseComplianceReportGenerator(config) + + def test_concrete_implementation(self): + """Test that a concrete implementation can be created.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test") + generator = ConcreteGenerator(config) + assert generator.config.name == "test" + assert generator.styles is not None + + def test_get_footer_text_english(self): + """Test footer text in English.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test", language="en") + generator = ConcreteGenerator(config) + left, right = generator.get_footer_text(1) + assert left == "Page 1" + assert right == "Powered by Prowler" + + def test_get_footer_text_spanish(self): + """Test footer text in Spanish.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test", language="es") + generator = ConcreteGenerator(config) + left, right = generator.get_footer_text(1) + assert left == "PΓ‘gina 1" + + +class TestBuildInfoRows: + """Tests for _build_info_rows helper method.""" + + def _create_generator(self, language="en"): + """Create a concrete generator for testing.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test", language=language) + return ConcreteGenerator(config) + + def test_build_info_rows_english(self): + """Test info rows are built with English labels.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test Framework", + name="Test Name", + version="1.0", + description="Test description", + ) + + rows = generator._build_info_rows(data, language="en") + + assert ("Framework:", "Test Framework") in rows + assert ("Name:", "Test Name") in rows + assert ("Version:", "1.0") in rows + assert ("Scan ID:", "scan-123") in rows + assert ("Description:", "Test description") in rows + + def test_build_info_rows_spanish(self): + """Test info rows are built with Spanish labels.""" + generator = self._create_generator(language="es") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test Framework", + name="Test Name", + version="1.0", + description="Test description", + ) + + rows = generator._build_info_rows(data, language="es") + + assert ("Framework:", "Test Framework") in rows + assert ("Nombre:", "Test Name") in rows + assert ("VersiΓ³n:", "1.0") in rows + assert ("Scan ID:", "scan-123") in rows + assert ("DescripciΓ³n:", "Test description") in rows + + def test_build_info_rows_with_provider(self): + """Test info rows include provider info when available.""" + from unittest.mock import Mock + + generator = self._create_generator(language="en") + + mock_provider = Mock() + mock_provider.provider = "aws" + mock_provider.uid = "123456789012" + mock_provider.alias = "my-account" + + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=mock_provider, + ) + + rows = generator._build_info_rows(data, language="en") + + assert ("Provider:", "AWS") in rows + assert ("Account ID:", "123456789012") in rows + assert ("Alias:", "my-account") in rows + + def test_build_info_rows_with_provider_spanish(self): + """Test provider info uses Spanish labels.""" + from unittest.mock import Mock + + generator = self._create_generator(language="es") + + mock_provider = Mock() + mock_provider.provider = "azure" + mock_provider.uid = "subscription-id" + mock_provider.alias = "mi-suscripcion" + + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=mock_provider, + ) + + rows = generator._build_info_rows(data, language="es") + + assert ("Proveedor:", "AZURE") in rows + assert ("Account ID:", "subscription-id") in rows + assert ("Alias:", "mi-suscripcion") in rows + + def test_build_info_rows_without_provider(self): + """Test info rows work without provider info.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=None, + ) + + rows = generator._build_info_rows(data, language="en") + + # Provider info should not be present + labels = [label for label, _ in rows] + assert "Provider:" not in labels + assert "Account ID:" not in labels + assert "Alias:" not in labels + + def test_build_info_rows_provider_with_missing_fields(self): + """Test provider info handles None values gracefully.""" + from unittest.mock import Mock + + generator = self._create_generator(language="en") + + mock_provider = Mock() + mock_provider.provider = "gcp" + mock_provider.uid = None + mock_provider.alias = None + + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=mock_provider, + ) + + rows = generator._build_info_rows(data, language="en") + + assert ("Provider:", "GCP") in rows + assert ("Account ID:", "N/A") in rows + assert ("Alias:", "N/A") in rows + + def test_build_info_rows_without_description(self): + """Test info rows exclude description when empty.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + ) + + rows = generator._build_info_rows(data, language="en") + + labels = [label for label, _ in rows] + assert "Description:" not in labels + + def test_build_info_rows_defaults_to_english(self): + """Test unknown language defaults to English labels.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="Desc", + ) + + rows = generator._build_info_rows(data, language="fr") # Unknown language + + # Should use English labels as fallback + assert ("Name:", "Test") in rows + assert ("Description:", "Desc") in rows + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestExampleReportGenerator: + """Integration tests using an example report generator.""" + + def setup_method(self): + """Set up test fixtures.""" + + class ExampleGenerator(BaseComplianceReportGenerator): + """Example concrete implementation for testing.""" + + def create_executive_summary(self, data): + return [ + Paragraph("Executive Summary", self.styles["h1"]), + Paragraph( + f"Total requirements: {len(data.requirements)}", + self.styles["normal"], + ), + ] + + def create_charts_section(self, data): + chart_buffer = create_vertical_bar_chart( + labels=["Pass", "Fail"], + values=[80, 20], + ) + return [Image(chart_buffer, width=6 * inch, height=4 * inch)] + + def create_requirements_index(self, data): + elements = [Paragraph("Requirements Index", self.styles["h1"])] + for req in data.requirements: + elements.append( + Paragraph( + f"- {req.id}: {req.description}", self.styles["normal"] + ) + ) + return elements + + self.generator_class = ExampleGenerator + + def test_example_generator_creation(self): + """Test creating example generator.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + assert generator is not None + + def test_example_generator_executive_summary(self): + """Test executive summary generation.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + requirements=[ + RequirementData(id="R1", description="Req 1", status="PASS"), + RequirementData(id="R2", description="Req 2", status="FAIL"), + ], + ) + + elements = generator.create_executive_summary(data) + assert len(elements) == 2 + + def test_example_generator_charts_section(self): + """Test charts section generation.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + ) + + elements = generator.create_charts_section(data) + assert len(elements) == 1 + + def test_example_generator_requirements_index(self): + """Test requirements index generation.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + requirements=[ + RequirementData(id="R1", description="Requirement 1", status="PASS"), + ], + ) + + elements = generator.create_requirements_index(data) + assert len(elements) == 2 # Header + 1 requirement + + +# ============================================================================= +# Edge Case Tests +# ============================================================================= + + +class TestChartEdgeCases: + """Tests for chart edge cases.""" + + def test_vertical_bar_chart_empty_data(self): + """Test vertical bar chart with empty data.""" + buffer = create_vertical_bar_chart(labels=[], values=[]) + assert isinstance(buffer, io.BytesIO) + + def test_vertical_bar_chart_single_item(self): + """Test vertical bar chart with single item.""" + buffer = create_vertical_bar_chart(labels=["Single"], values=[75.0]) + assert isinstance(buffer, io.BytesIO) + + def test_horizontal_bar_chart_empty_data(self): + """Test horizontal bar chart with empty data.""" + buffer = create_horizontal_bar_chart(labels=[], values=[]) + assert isinstance(buffer, io.BytesIO) + + def test_horizontal_bar_chart_single_item(self): + """Test horizontal bar chart with single item.""" + buffer = create_horizontal_bar_chart(labels=["Single"], values=[50.0]) + assert isinstance(buffer, io.BytesIO) + + def test_radar_chart_minimum_points(self): + """Test radar chart with minimum number of points (3).""" + buffer = create_radar_chart( + labels=["A", "B", "C"], + values=[30.0, 60.0, 90.0], + ) + assert isinstance(buffer, io.BytesIO) + + def test_pie_chart_single_slice(self): + """Test pie chart with single slice.""" + buffer = create_pie_chart(labels=["Only"], values=[100.0]) + assert isinstance(buffer, io.BytesIO) + + def test_pie_chart_many_slices(self): + """Test pie chart with many slices.""" + labels = [f"Item {i}" for i in range(10)] + values = [10.0] * 10 + buffer = create_pie_chart(labels=labels, values=values) + assert isinstance(buffer, io.BytesIO) + + def test_stacked_bar_chart_single_series(self): + """Test stacked bar chart with single series.""" + buffer = create_stacked_bar_chart( + labels=["A", "B"], + data_series={"Only": [10.0, 20.0]}, + ) + assert isinstance(buffer, io.BytesIO) + + def test_stacked_bar_chart_empty_data(self): + """Test stacked bar chart with empty data.""" + buffer = create_stacked_bar_chart(labels=[], data_series={}) + assert isinstance(buffer, io.BytesIO) + + +class TestComponentEdgeCases: + """Tests for component edge cases.""" + + def test_create_badge_empty_text(self): + """Test badge with empty text.""" + badge = create_badge("", COLOR_BLUE) + assert badge is not None + + def test_create_badge_long_text(self): + """Test badge with very long text.""" + long_text = "A" * 100 + badge = create_badge(long_text, COLOR_BLUE, width=5 * inch) + assert badge is not None + + def test_create_status_badge_unknown_status(self): + """Test status badge with unknown status.""" + badge = create_status_badge("UNKNOWN") + assert badge is not None + + def test_create_multi_badge_row_single_badge(self): + """Test multi-badge row with single badge.""" + badges = [("A", COLOR_BLUE)] + table = create_multi_badge_row(badges) + assert table is not None + + def test_create_multi_badge_row_many_badges(self): + """Test multi-badge row with many badges.""" + badges = [(chr(65 + i), COLOR_BLUE) for i in range(10)] # A-J + table = create_multi_badge_row(badges) + assert table is not None + + def test_create_info_table_empty(self): + """Test info table with empty rows.""" + table = create_info_table([]) + assert isinstance(table, Table) + + def test_create_info_table_long_values(self): + """Test info table with very long values wraps properly.""" + rows = [ + ("Key:", "A" * 200), # Very long value + ] + styles = create_pdf_styles() + table = create_info_table(rows, normal_style=styles["normal"]) + assert table is not None + + def test_create_data_table_empty(self): + """Test data table with empty data.""" + columns = [ + ColumnConfig("Name", 2 * inch, "name"), + ] + table = create_data_table([], columns) + assert table is not None + + def test_create_data_table_large_dataset(self): + """Test data table with large dataset uses LongTable.""" + # Create more than 50 rows to trigger LongTable + data = [{"name": f"Item {i}"} for i in range(60)] + columns = [ColumnConfig("Name", 2 * inch, "name")] + table = create_data_table(data, columns) + # Should be a LongTable for large datasets + assert isinstance(table, LongTable) + + def test_create_risk_component_zero_values(self): + """Test risk component with zero values.""" + component = create_risk_component(risk_level=0, weight=0, score=0) + assert component is not None + + def test_create_risk_component_max_values(self): + """Test risk component with maximum values.""" + component = create_risk_component(risk_level=5, weight=200, score=1000) + assert component is not None + + +class TestColorEdgeCases: + """Tests for color function edge cases.""" + + def test_get_color_for_compliance_boundary_80(self): + """Test compliance color at exactly 80%.""" + assert get_color_for_compliance(80) == COLOR_SAFE + + def test_get_color_for_compliance_boundary_60(self): + """Test compliance color at exactly 60%.""" + assert get_color_for_compliance(60) == COLOR_LOW_RISK + + def test_get_color_for_compliance_over_100(self): + """Test compliance color for values over 100.""" + assert get_color_for_compliance(150) == COLOR_SAFE + + def test_get_color_for_weight_boundary_100(self): + """Test weight color at exactly 100.""" + assert get_color_for_weight(100) == COLOR_LOW_RISK + + def test_get_color_for_weight_boundary_50(self): + """Test weight color at exactly 50.""" + assert get_color_for_weight(50) == COLOR_SAFE + + def test_get_status_color_case_insensitive(self): + """Test that status color is case insensitive.""" + assert get_status_color("PASS") == get_status_color("pass") + assert get_status_color("FAIL") == get_status_color("Fail") + assert get_status_color("MANUAL") == get_status_color("manual") + + +class TestFrameworkConfigEdgeCases: + """Tests for FrameworkConfig edge cases.""" + + def test_framework_config_empty_sections(self): + """Test FrameworkConfig with empty sections list.""" + config = FrameworkConfig( + name="test", + display_name="Test", + sections=[], + ) + assert config.sections == [] + + def test_framework_config_empty_attribute_fields(self): + """Test FrameworkConfig with empty attribute fields.""" + config = FrameworkConfig( + name="test", + display_name="Test", + attribute_fields=[], + ) + assert config.attribute_fields == [] + + def test_get_framework_config_case_variations(self): + """Test get_framework_config with different case variations.""" + # Test case insensitivity + assert get_framework_config("PROWLER_THREATSCORE_AWS") is not None + assert get_framework_config("ENS_RD2022_AWS") is not None + assert get_framework_config("NIS2_AWS") is not None + + def test_get_framework_config_partial_match(self): + """Test that partial matches work correctly.""" + # Should match based on substring + assert get_framework_config("my_custom_threatscore_compliance") is not None + assert get_framework_config("ens_something_else") is not None + assert get_framework_config("nis2_gcp") is not None diff --git a/api/src/backend/tasks/tests/test_reports_ens.py b/api/src/backend/tasks/tests/test_reports_ens.py new file mode 100644 index 0000000000..91eb6d6f3a --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_ens.py @@ -0,0 +1,1227 @@ +import io +from unittest.mock import Mock, patch + +import pytest +from reportlab.platypus import PageBreak, Paragraph, Table +from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData +from tasks.jobs.reports.ens import ENSReportGenerator + + +# Use string status values directly to avoid Django DB initialization +# These match api.models.StatusChoices values +class StatusChoices: + """Mock StatusChoices to avoid Django DB initialization.""" + + PASS = "PASS" + FAIL = "FAIL" + MANUAL = "MANUAL" + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def ens_generator(): + """Create an ENSReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["ens"] + return ENSReportGenerator(config) + + +@pytest.fixture +def mock_ens_requirement_attribute(): + """Create a mock ENS requirement attribute with all fields.""" + mock = Mock() + mock.Marco = "Operacional" + mock.Categoria = "GestiΓ³n de incidentes" + mock.DescripcionControl = "Control de gestiΓ³n de incidentes de seguridad" + mock.Tipo = "requisito" + mock.Nivel = "alto" + mock.Dimensiones = ["confidencialidad", "integridad"] + mock.ModoEjecucion = "automatico" + mock.IdGrupoControl = "op.ext.1" + return mock + + +@pytest.fixture +def mock_ens_requirement_attribute_medio(): + """Create a mock ENS requirement attribute with nivel medio.""" + mock = Mock() + mock.Marco = "Organizativo" + mock.Categoria = "Seguridad en los recursos humanos" + mock.DescripcionControl = "Control de seguridad del personal" + mock.Tipo = "refuerzo" + mock.Nivel = "medio" + mock.Dimensiones = "trazabilidad, autenticidad" # String format + mock.ModoEjecucion = "manual" + mock.IdGrupoControl = "org.rh.1" + return mock + + +@pytest.fixture +def mock_ens_requirement_attribute_bajo(): + """Create a mock ENS requirement attribute with nivel bajo.""" + mock = Mock() + mock.Marco = "Medidas de ProtecciΓ³n" + mock.Categoria = "ProtecciΓ³n de las instalaciones" + mock.DescripcionControl = "Control de acceso fΓ­sico" + mock.Tipo = "recomendacion" + mock.Nivel = "bajo" + mock.Dimensiones = ["disponibilidad"] + mock.ModoEjecucion = "automatico" + mock.IdGrupoControl = "mp.if.1" + return mock + + +@pytest.fixture +def mock_ens_requirement_attribute_opcional(): + """Create a mock ENS requirement attribute with nivel opcional.""" + mock = Mock() + mock.Marco = "Marco de OrganizaciΓ³n" + mock.Categoria = "PolΓ­tica de seguridad" + mock.DescripcionControl = "PolΓ­tica de seguridad de la informaciΓ³n" + mock.Tipo = "medida" + mock.Nivel = "opcional" + mock.Dimensiones = [] + mock.ModoEjecucion = "automatico" + mock.IdGrupoControl = "org.1" + return mock + + +@pytest.fixture +def basic_ens_compliance_data(): + """Create basic ComplianceData for ENS testing.""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="ens_rd2022_aws", + framework="ENS RD2022", + name="Esquema Nacional de Seguridad RD 311/2022", + version="2022", + description="Marco de seguridad para la administraciΓ³n electrΓ³nica espaΓ±ola", + ) + + +# ============================================================================= +# Generator Initialization Tests +# ============================================================================= + + +class TestENSGeneratorInitialization: + """Test suite for ENS generator initialization.""" + + def test_generator_creation(self, ens_generator): + """Test that ENS generator is created correctly.""" + assert ens_generator is not None + assert ens_generator.config.name == "ens" + assert ens_generator.config.language == "es" + + def test_generator_has_niveles(self, ens_generator): + """Test that ENS config has niveles enabled.""" + assert ens_generator.config.has_niveles is True + + def test_generator_has_dimensions(self, ens_generator): + """Test that ENS config has dimensions enabled.""" + assert ens_generator.config.has_dimensions is True + + def test_generator_no_risk_levels(self, ens_generator): + """Test that ENS config does not use risk levels.""" + assert ens_generator.config.has_risk_levels is False + + def test_generator_no_weight(self, ens_generator): + """Test that ENS config does not use weight.""" + assert ens_generator.config.has_weight is False + + +# ============================================================================= +# Cover Page Tests +# ============================================================================= + + +class TestENSCoverPage: + """Test suite for ENS cover page generation.""" + + @patch("tasks.jobs.reports.ens.Image") + def test_cover_page_has_logos( + self, mock_image, ens_generator, basic_ens_compliance_data + ): + """Test that cover page contains logos.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + assert len(elements) > 0 + # Should have called Image at least twice (prowler + ens logos) + assert mock_image.call_count >= 2 + + def test_cover_page_has_title(self, ens_generator, basic_ens_compliance_data): + """Test that cover page contains the ENS title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "ENS" in content or "Informe" in content + + def test_cover_page_has_info_table(self, ens_generator, basic_ens_compliance_data): + """Test that cover page contains info table with metadata.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 # At least info table + + def test_cover_page_has_warning_about_manual( + self, ens_generator, basic_ens_compliance_data + ): + """Test that cover page has warning about manual requirements.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + # Find paragraphs (including those inside tables) that mention manual + all_paragraphs = [] + for e in elements: + if isinstance(e, Paragraph): + all_paragraphs.append(e) + elif isinstance(e, Table): + # Check table cells for Paragraph objects + cell_values = getattr(e, "_cellvalues", []) + for row in cell_values: + for cell in row: + if isinstance(cell, Paragraph): + all_paragraphs.append(cell) + content = " ".join(str(p.text) for p in all_paragraphs) + assert "manual" in content.lower() or "AVISO" in content + + def test_cover_page_has_legend(self, ens_generator, basic_ens_compliance_data): + """Test that cover page contains the ENS values legend.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + # Legend should be a table with explanations + tables = [e for e in elements if isinstance(e, Table)] + # At least 3 tables: logos, info, warning, legend + assert len(tables) >= 3 + + +# ============================================================================= +# Executive Summary Tests +# ============================================================================= + + +class TestENSExecutiveSummary: + """Test suite for ENS executive summary generation.""" + + def test_executive_summary_has_title( + self, ens_generator, basic_ens_compliance_data + ): + """Test that executive summary has Spanish title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Resumen Ejecutivo" in content + + def test_executive_summary_calculates_compliance( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that executive summary calculates compliance percentage.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + # Should contain tables with metrics + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_executive_summary_excludes_manual_from_compliance( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from compliance calculation.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + # Should calculate 100% compliance (only 1 auto requirement that passed) + assert len(elements) > 0 + + def test_executive_summary_has_nivel_table( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that executive summary includes compliance by nivel table.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Alto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + # Should have nivel table + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Nivel" in content or "nivel" in content.lower() + + +# ============================================================================= +# Charts Section Tests +# ============================================================================= + + +class TestENSChartsSection: + """Test suite for ENS charts section generation.""" + + def test_charts_section_has_page_breaks( + self, ens_generator, basic_ens_compliance_data + ): + """Test that charts section has page breaks between charts.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + page_breaks = [e for e in elements if isinstance(e, PageBreak)] + assert len(page_breaks) >= 2 # At least 2 page breaks for different charts + + def test_charts_section_has_marco_category_chart( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that charts section contains Marco/CategorΓ­a chart.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Marco" in content or "CategorΓ­a" in content + + def test_charts_section_has_dimensions_radar( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that charts section contains dimensions radar chart.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Dimensiones" in content or "dimensiones" in content.lower() + + def test_charts_section_has_tipo_distribution( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that charts section contains tipo distribution.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Tipo" in content or "tipo" in content.lower() + + +# ============================================================================= +# Critical Failed Requirements Tests +# ============================================================================= + + +class TestENSCriticalFailedRequirements: + """Test suite for ENS critical failed requirements (nivel alto).""" + + def test_no_critical_failures_shows_success_message( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that no critical failures shows success message.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed alto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_critical_failed_section( + basic_ens_compliance_data + ) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "No hay" in content or "βœ…" in content + + def test_critical_failures_shows_table( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that critical failures shows requirements table.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed alto requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_critical_failed_section( + basic_ens_compliance_data + ) + + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_critical_failures_only_includes_alto( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + ): + """Test that only nivel alto failures are included.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed alto requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed medio requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + } + + elements = ens_generator._create_critical_failed_section( + basic_ens_compliance_data + ) + + # Should have table but only with alto requirement + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should mention 1 critical requirement + assert "1" in content + + +# ============================================================================= +# Requirements Index Tests +# ============================================================================= + + +class TestENSRequirementsIndex: + """Test suite for ENS requirements index generation.""" + + def test_requirements_index_has_title( + self, ens_generator, basic_ens_compliance_data + ): + """Test that requirements index has Spanish title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Índice" in content or "Requisitos" in content + + def test_requirements_index_organized_by_marco( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + ): + """Test that requirements index is organized by Marco.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Operacional requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Organizativo requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + } + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert ( + "Operacional" in content or "Organizativo" in content or "Marco" in content + ) + + def test_requirements_index_excludes_manual( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from index.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # REQ-001 should be there, REQ-002 should not + assert "REQ-001" in content + assert "REQ-002" not in content + + def test_requirements_index_shows_status_indicators( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that requirements index shows pass/fail indicators.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should have status indicators + assert "βœ“" in content or "βœ—" in content + + +# ============================================================================= +# Detailed Findings Tests +# ============================================================================= + + +class TestENSDetailedFindings: + """Test suite for ENS detailed findings generation.""" + + def test_detailed_findings_has_title( + self, ens_generator, basic_ens_compliance_data + ): + """Test that detailed findings section has title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Detalle" in content or "Requisitos" in content + + def test_detailed_findings_no_failures_message( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test message when no failed requirements exist.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "No hay" in content or "requisitos fallidos" in content.lower() + + def test_detailed_findings_shows_failed_requirements( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that failed requirements are shown in detail.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + # Should have tables showing requirement details + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_detailed_findings_shows_nivel_badges( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that detailed findings show nivel badges.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + # Should generate without errors + assert len(elements) > 0 + + def test_detailed_findings_shows_dimensiones_badges( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that detailed findings show dimension badges.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + # Should generate without errors with dimension badges + assert len(elements) > 0 + + +# ============================================================================= +# Dimension Handling Tests +# ============================================================================= + + +class TestENSDimensionHandling: + """Test suite for ENS security dimension handling.""" + + def test_dimensions_as_list( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test handling dimensions as a list.""" + # mock_ens_requirement_attribute has Dimensiones as list + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_dimensions_radar_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + def test_dimensions_as_string( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute_medio, + ): + """Test handling dimensions as comma-separated string.""" + # mock_ens_requirement_attribute_medio has Dimensiones as string + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_dimensions_radar_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + def test_dimensions_empty( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute_opcional, + ): + """Test handling empty dimensions.""" + # mock_ens_requirement_attribute_opcional has empty Dimensiones + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_ens_requirement_attribute_opcional] + } + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_dimensions_radar_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + +# ============================================================================= +# Footer Tests +# ============================================================================= + + +class TestENSFooter: + """Test suite for ENS footer generation.""" + + def test_footer_is_spanish(self, ens_generator): + """Test that footer text is in Spanish.""" + left, right = ens_generator.get_footer_text(1) + + assert "PΓ‘gina" in left + assert "Prowler" in right + + def test_footer_includes_page_number(self, ens_generator): + """Test that footer includes page number.""" + left, right = ens_generator.get_footer_text(5) + + assert "5" in left + + +# ============================================================================= +# Nivel Table Tests +# ============================================================================= + + +class TestENSNivelTable: + """Test suite for ENS nivel compliance table.""" + + def test_nivel_table_all_niveles( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + mock_ens_requirement_attribute_bajo, + mock_ens_requirement_attribute_opcional, + ): + """Test nivel table with all niveles represented.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Alto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Medio requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + RequirementData( + id="REQ-003", + description="Bajo requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-004", + description="Opcional requirement", + status=StatusChoices.PASS, + passed_findings=3, + failed_findings=0, + total_findings=3, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + "REQ-003": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_bajo]} + }, + "REQ-004": { + "attributes": { + "req_attributes": [mock_ens_requirement_attribute_opcional] + } + }, + } + + elements = ens_generator._create_nivel_table(basic_ens_compliance_data) + + # Should have at least one table + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_nivel_table_excludes_manual( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from nivel table.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_nivel_table(basic_ens_compliance_data) + + # Should generate without errors + assert len(elements) > 0 + + +# ============================================================================= +# Marco Category Chart Tests +# ============================================================================= + + +class TestENSMarcoCategoryChart: + """Test suite for ENS Marco/CategorΓ­a chart.""" + + def test_marco_category_chart_creation( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that Marco/CategorΓ­a chart is created successfully.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + chart_buffer = ens_generator._create_marco_category_chart( + basic_ens_compliance_data + ) + + assert isinstance(chart_buffer, io.BytesIO) + assert chart_buffer.getvalue() # Not empty + + def test_marco_category_chart_excludes_manual( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from chart.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_marco_category_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + +# ============================================================================= +# Tipo Section Tests +# ============================================================================= + + +class TestENSTipoSection: + """Test suite for ENS tipo distribution section.""" + + def test_tipo_section_creation( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that tipo section is created successfully.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Requisito type", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_tipo_section(basic_ens_compliance_data) + + assert len(elements) > 0 + # Should have a table with tipo distribution + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_tipo_section_all_types( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + mock_ens_requirement_attribute_bajo, + mock_ens_requirement_attribute_opcional, + ): + """Test tipo section with all requirement types.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Requisito type", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Refuerzo type", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + RequirementData( + id="REQ-003", + description="Recomendacion type", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-004", + description="Medida type", + status=StatusChoices.PASS, + passed_findings=3, + failed_findings=0, + total_findings=3, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + "REQ-003": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_bajo]} + }, + "REQ-004": { + "attributes": { + "req_attributes": [mock_ens_requirement_attribute_opcional] + } + }, + } + + elements = ens_generator._create_tipo_section(basic_ens_compliance_data) + + # Should generate without errors + assert len(elements) > 0 diff --git a/api/src/backend/tasks/tests/test_reports_nis2.py b/api/src/backend/tasks/tests/test_reports_nis2.py new file mode 100644 index 0000000000..07e88ec7ca --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_nis2.py @@ -0,0 +1,1093 @@ +import io +from unittest.mock import Mock, patch + +import pytest +from reportlab.platypus import PageBreak, Paragraph, Table +from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData +from tasks.jobs.reports.nis2 import NIS2ReportGenerator, _extract_section_number + + +# Use string status values directly to avoid Django DB initialization +# These match api.models.StatusChoices values +class StatusChoices: + """Mock StatusChoices to avoid Django DB initialization.""" + + PASS = "PASS" + FAIL = "FAIL" + MANUAL = "MANUAL" + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def nis2_generator(): + """Create a NIS2ReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["nis2"] + return NIS2ReportGenerator(config) + + +@pytest.fixture +def mock_nis2_requirement_attribute_section1(): + """Create a mock NIS2 requirement attribute for Section 1.""" + mock = Mock() + mock.Section = "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS" + mock.SubSection = "1.1 Policy establishment" + mock.Description = "Establish security policies for network and information systems" + return mock + + +@pytest.fixture +def mock_nis2_requirement_attribute_section2(): + """Create a mock NIS2 requirement attribute for Section 2.""" + mock = Mock() + mock.Section = "2 RISK MANAGEMENT" + mock.SubSection = "2.1 Risk assessment" + mock.Description = "Conduct risk assessments for critical infrastructure" + return mock + + +@pytest.fixture +def mock_nis2_requirement_attribute_section11(): + """Create a mock NIS2 requirement attribute for Section 11.""" + mock = Mock() + mock.Section = "11 ACCESS CONTROL" + mock.SubSection = "11.2 User access management" + mock.Description = "Manage user access to systems and data" + return mock + + +@pytest.fixture +def mock_nis2_requirement_attribute_no_subsection(): + """Create a mock NIS2 requirement attribute without subsection.""" + mock = Mock() + mock.Section = "3 INCIDENT HANDLING" + mock.SubSection = "" + mock.Description = "Handle security incidents effectively" + return mock + + +@pytest.fixture +def basic_nis2_compliance_data(): + """Create basic ComplianceData for NIS2 testing.""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="nis2_aws", + framework="NIS2", + name="NIS2 Directive (EU) 2022/2555", + version="2022", + description="EU directive on security of network and information systems", + ) + + +# ============================================================================= +# Section Number Extraction Tests +# ============================================================================= + + +class TestSectionNumberExtraction: + """Test suite for section number extraction utility.""" + + def test_extract_simple_section_number(self): + """Test extracting single digit section number.""" + result = _extract_section_number("1 POLICY ON SECURITY") + assert result == "1" + + def test_extract_double_digit_section_number(self): + """Test extracting double digit section number.""" + result = _extract_section_number("11 ACCESS CONTROL") + assert result == "11" + + def test_extract_section_number_with_spaces(self): + """Test extracting section number with leading/trailing spaces.""" + result = _extract_section_number(" 2 RISK MANAGEMENT ") + assert result == "2" + + def test_extract_section_number_empty_string(self): + """Test extracting from empty string returns 'Other'.""" + result = _extract_section_number("") + assert result == "Other" + + def test_extract_section_number_none_like(self): + """Test extracting from empty/None-like returns 'Other'.""" + # Note: The function expects str, so we test empty string behavior + result = _extract_section_number("") + assert result == "Other" + + def test_extract_section_number_no_number(self): + """Test extracting from string without number returns 'Other'.""" + result = _extract_section_number("POLICY ON SECURITY") + assert result == "Other" + + def test_extract_section_number_letter_first(self): + """Test extracting from string starting with letter returns 'Other'.""" + result = _extract_section_number("A. Some Section") + assert result == "Other" + + +# ============================================================================= +# Generator Initialization Tests +# ============================================================================= + + +class TestNIS2GeneratorInitialization: + """Test suite for NIS2 generator initialization.""" + + def test_generator_creation(self, nis2_generator): + """Test that NIS2 generator is created correctly.""" + assert nis2_generator is not None + assert nis2_generator.config.name == "nis2" + assert nis2_generator.config.language == "en" + + def test_generator_no_niveles(self, nis2_generator): + """Test that NIS2 config does not use niveles.""" + assert nis2_generator.config.has_niveles is False + + def test_generator_no_dimensions(self, nis2_generator): + """Test that NIS2 config does not use dimensions.""" + assert nis2_generator.config.has_dimensions is False + + def test_generator_no_risk_levels(self, nis2_generator): + """Test that NIS2 config does not use risk levels.""" + assert nis2_generator.config.has_risk_levels is False + + def test_generator_no_weight(self, nis2_generator): + """Test that NIS2 config does not use weight.""" + assert nis2_generator.config.has_weight is False + + +# ============================================================================= +# Cover Page Tests +# ============================================================================= + + +class TestNIS2CoverPage: + """Test suite for NIS2 cover page generation.""" + + @patch("tasks.jobs.reports.nis2.Image") + def test_cover_page_has_logos( + self, mock_image, nis2_generator, basic_nis2_compliance_data + ): + """Test that cover page contains logos.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_cover_page(basic_nis2_compliance_data) + + assert len(elements) > 0 + # Should have called Image at least twice (prowler + nis2 logos) + assert mock_image.call_count >= 2 + + def test_cover_page_has_title(self, nis2_generator, basic_nis2_compliance_data): + """Test that cover page contains the NIS2 title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_cover_page(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "NIS2" in content or "Directive" in content + + def test_cover_page_has_metadata_table( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that cover page contains metadata table.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_cover_page(basic_nis2_compliance_data) + + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + +# ============================================================================= +# Executive Summary Tests +# ============================================================================= + + +class TestNIS2ExecutiveSummary: + """Test suite for NIS2 executive summary generation.""" + + def test_executive_summary_has_english_title( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that executive summary has English title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Executive Summary" in content + + def test_executive_summary_calculates_compliance( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that executive summary calculates compliance percentage.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + # Should contain tables with metrics + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_executive_summary_shows_all_statuses( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that executive summary shows passed, failed, and manual counts.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Manual", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + # Should have a summary table with all statuses + assert len(elements) > 0 + + def test_executive_summary_excludes_manual_from_percentage( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that manual requirements are excluded from compliance percentage.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + # Should calculate 100% (only 1 evaluated requirement that passed) + assert len(elements) > 0 + + +# ============================================================================= +# Charts Section Tests +# ============================================================================= + + +class TestNIS2ChartsSection: + """Test suite for NIS2 charts section generation.""" + + def test_charts_section_has_section_chart_title( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that charts section has section compliance title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Section" in content or "Compliance" in content + + def test_charts_section_has_page_break( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that charts section has page breaks.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + page_breaks = [e for e in elements if isinstance(e, PageBreak)] + assert len(page_breaks) >= 1 + + def test_charts_section_has_subsection_breakdown( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that charts section includes subsection breakdown table.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "SubSection" in content or "Breakdown" in content + + +# ============================================================================= +# Section Chart Tests +# ============================================================================= + + +class TestNIS2SectionChart: + """Test suite for NIS2 section compliance chart.""" + + def test_section_chart_creation( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that section chart is created successfully.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data) + + assert isinstance(chart_buffer, io.BytesIO) + assert chart_buffer.getvalue() # Not empty + + def test_section_chart_excludes_manual( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that manual requirements are excluded from section chart.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + # Should not raise any errors + chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data) + assert isinstance(chart_buffer, io.BytesIO) + + def test_section_chart_multiple_sections( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + mock_nis2_requirement_attribute_section2, + mock_nis2_requirement_attribute_section11, + ): + """Test section chart with multiple sections.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Section 1 requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Section 2 requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Section 11 requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section2] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section11] + } + }, + } + + chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data) + assert isinstance(chart_buffer, io.BytesIO) + + +# ============================================================================= +# SubSection Table Tests +# ============================================================================= + + +class TestNIS2SubSectionTable: + """Test suite for NIS2 subsection breakdown table.""" + + def test_subsection_table_creation( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that subsection table is created successfully.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + table = nis2_generator._create_subsection_table(basic_nis2_compliance_data) + + assert isinstance(table, Table) + + def test_subsection_table_counts_statuses( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that subsection table counts passed, failed, and manual.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Manual", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + table = nis2_generator._create_subsection_table(basic_nis2_compliance_data) + assert isinstance(table, Table) + + def test_subsection_table_no_subsection( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_no_subsection, + ): + """Test subsection table when requirements have no subsection.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="No subsection requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_no_subsection] + } + }, + } + + table = nis2_generator._create_subsection_table(basic_nis2_compliance_data) + assert isinstance(table, Table) + + +# ============================================================================= +# Requirements Index Tests +# ============================================================================= + + +class TestNIS2RequirementsIndex: + """Test suite for NIS2 requirements index generation.""" + + def test_requirements_index_has_title( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that requirements index has English title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Requirements Index" in content + + def test_requirements_index_organized_by_section( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + mock_nis2_requirement_attribute_section2, + ): + """Test that requirements index is organized by section.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Section 1 requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Section 2 requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section2] + } + }, + } + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should have section headers + assert "Policy" in content or "Risk" in content or "1." in content + + def test_requirements_index_shows_status_indicators( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that requirements index shows pass/fail/manual indicators.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should have status indicators + assert "βœ“" in content or "βœ—" in content or "βŠ™" in content + + def test_requirements_index_truncates_long_descriptions( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that long descriptions are truncated.""" + mock_attr = Mock() + mock_attr.Section = "1 POLICY" + mock_attr.SubSection = "1.1 Long subsection name" + mock_attr.Description = "A" * 100 # Very long description + + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="A" * 100, + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + # Should not raise errors + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + assert len(elements) > 0 + + +# ============================================================================= +# Section Key Sorting Tests +# ============================================================================= + + +class TestNIS2SectionKeySorting: + """Test suite for NIS2 section key sorting.""" + + def test_sort_simple_sections(self, nis2_generator): + """Test sorting simple section numbers.""" + result = nis2_generator._sort_section_key("1") + assert result == (1,) + + result = nis2_generator._sort_section_key("2") + assert result == (2,) + + def test_sort_subsections(self, nis2_generator): + """Test sorting subsection numbers.""" + result = nis2_generator._sort_section_key("1.1") + assert result == (1, 1) + + result = nis2_generator._sort_section_key("1.2") + assert result == (1, 2) + + def test_sort_double_digit_sections(self, nis2_generator): + """Test sorting double digit section numbers.""" + result = nis2_generator._sort_section_key("11") + assert result == (11,) + + result = nis2_generator._sort_section_key("11.2") + assert result == (11, 2) + + def test_sort_order_is_correct(self, nis2_generator): + """Test that sort order is numerically correct.""" + keys = ["11", "1", "2", "1.2", "1.1", "11.2", "2.1"] + sorted_keys = sorted(keys, key=nis2_generator._sort_section_key) + + assert sorted_keys == ["1", "1.1", "1.2", "2", "2.1", "11", "11.2"] + + def test_sort_invalid_key(self, nis2_generator): + """Test sorting invalid section key.""" + result = nis2_generator._sort_section_key("Other") + # Should contain infinity for non-numeric parts + assert result[0] == float("inf") + + +# ============================================================================= +# Empty Data Tests +# ============================================================================= + + +class TestNIS2EmptyData: + """Test suite for NIS2 with empty or minimal data.""" + + def test_executive_summary_empty_requirements( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test executive summary with no requirements.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + assert len(elements) > 0 + + def test_charts_section_empty_requirements( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test charts section with no requirements.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + assert len(elements) > 0 + + def test_requirements_index_empty(self, nis2_generator, basic_nis2_compliance_data): + """Test requirements index with no requirements.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + # Should at least have the title + assert len(elements) >= 1 + + +# ============================================================================= +# All Pass / All Fail Tests +# ============================================================================= + + +class TestNIS2EdgeCases: + """Test suite for NIS2 edge cases.""" + + def test_all_requirements_pass( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test with all requirements passing.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id=f"REQ-{i:03d}", + description=f"Passing requirement {i}", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + for i in range(1, 6) + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + f"REQ-{i:03d}": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + } + for i in range(1, 6) + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + assert len(elements) > 0 + + def test_all_requirements_fail( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test with all requirements failing.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id=f"REQ-{i:03d}", + description=f"Failing requirement {i}", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + for i in range(1, 6) + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + f"REQ-{i:03d}": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + } + for i in range(1, 6) + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + assert len(elements) > 0 + + def test_all_requirements_manual( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test with all requirements being manual.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id=f"REQ-{i:03d}", + description=f"Manual requirement {i}", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + for i in range(1, 6) + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + f"REQ-{i:03d}": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + } + for i in range(1, 6) + } + + # Should handle gracefully - compliance should be 100% when no evaluated + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + assert len(elements) > 0 + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestNIS2Integration: + """Integration tests for NIS2 report generation.""" + + def test_full_report_generation_flow( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + mock_nis2_requirement_attribute_section2, + ): + """Test the complete report generation flow.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Section 1 passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Section 2 failed", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section2] + } + }, + } + + # Generate all sections + exec_summary = nis2_generator.create_executive_summary( + basic_nis2_compliance_data + ) + charts = nis2_generator.create_charts_section(basic_nis2_compliance_data) + index = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + # All sections should generate without errors + assert len(exec_summary) > 0 + assert len(charts) > 0 + assert len(index) > 0 diff --git a/api/src/backend/tasks/tests/test_reports_threatscore.py b/api/src/backend/tasks/tests/test_reports_threatscore.py new file mode 100644 index 0000000000..c79c0b16e9 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_threatscore.py @@ -0,0 +1,1093 @@ +import io +from unittest.mock import Mock + +import pytest +from reportlab.platypus import Image, PageBreak, Paragraph, Table +from tasks.jobs.reports import ( + FRAMEWORK_REGISTRY, + ComplianceData, + RequirementData, + ThreatScoreReportGenerator, +) + +from api.models import StatusChoices + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def threatscore_generator(): + """Create a ThreatScoreReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["prowler_threatscore"] + return ThreatScoreReportGenerator(config) + + +@pytest.fixture +def mock_requirement_attribute(): + """Create a mock requirement attribute with numeric values.""" + mock = Mock() + mock.LevelOfRisk = 4 + mock.Weight = 100 + mock.Section = "1. IAM" + mock.SubSection = "1.1 Access Control" + mock.Title = "Test Requirement" + mock.AttributeDescription = "Test Description" + return mock + + +@pytest.fixture +def mock_requirement_attribute_string_values(): + """Create a mock requirement attribute with string values (edge case).""" + mock = Mock() + mock.LevelOfRisk = "5" # String instead of int + mock.Weight = "150" # String instead of int + mock.Section = "2. Attack Surface" + mock.SubSection = "2.1 Exposure" + mock.Title = "String Values Requirement" + mock.AttributeDescription = "Test with string numeric values" + return mock + + +@pytest.fixture +def mock_requirement_attribute_invalid_values(): + """Create a mock requirement attribute with invalid values (edge case).""" + mock = Mock() + mock.LevelOfRisk = "High" # Invalid string + mock.Weight = "Critical" # Invalid string + mock.Section = "3. Logging" + mock.SubSection = "3.1 Audit" + mock.Title = "Invalid Values Requirement" + mock.AttributeDescription = "Test with invalid string values" + return mock + + +@pytest.fixture +def mock_requirement_attribute_empty_values(): + """Create a mock requirement attribute with empty values.""" + mock = Mock() + mock.LevelOfRisk = "" + mock.Weight = "" + mock.Section = "4. Encryption" + mock.SubSection = "4.1 Data at Rest" + mock.Title = "Empty Values Requirement" + mock.AttributeDescription = "Test with empty values" + return mock + + +@pytest.fixture +def mock_requirement_attribute_none_values(): + """Create a mock requirement attribute with None values.""" + mock = Mock() + mock.LevelOfRisk = None + mock.Weight = None + mock.Section = "1. IAM" + mock.SubSection = "1.2 Policies" + mock.Title = "None Values Requirement" + mock.AttributeDescription = "Test with None values" + return mock + + +@pytest.fixture +def basic_compliance_data(): + """Create basic ComplianceData for testing.""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="prowler_threatscore_aws", + framework="Prowler ThreatScore", + name="ThreatScore AWS", + version="1.0", + description="Security assessment framework", + ) + + +# ============================================================================= +# ThreatScore Calculation Tests +# ============================================================================= + + +class TestThreatScoreCalculation: + """Test suite for ThreatScore calculation logic.""" + + def test_calculate_threatscore_no_findings_returns_100( + self, threatscore_generator, basic_compliance_data + ): + """Test that 100% is returned when there are no findings.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_compliance_data.attributes_by_requirement_id = {} + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + assert result == 100.0 + + def test_calculate_threatscore_all_passed( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test ThreatScore calculation when all findings pass.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_requirement_attribute]}, + } + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + assert result == 100.0 + + def test_calculate_threatscore_all_failed( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test ThreatScore calculation when all findings fail.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_requirement_attribute]}, + } + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + assert result == 0.0 + + def test_calculate_threatscore_mixed_findings( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test ThreatScore calculation with mixed pass/fail findings.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.FAIL, + passed_findings=7, + failed_findings=3, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_requirement_attribute]}, + } + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # rate_i = 7/10 = 0.7 + # rfac_i = 1 + 0.25 * 4 = 2.0 + # numerator = 0.7 * 10 * 100 * 2.0 = 1400 + # denominator = 10 * 100 * 2.0 = 2000 + # score = (1400 / 2000) * 100 = 70.0 + assert result == 70.0 + + def test_calculate_threatscore_multiple_requirements( + self, threatscore_generator, basic_compliance_data + ): + """Test ThreatScore calculation with multiple requirements.""" + mock_attr_1 = Mock() + mock_attr_1.LevelOfRisk = 5 + mock_attr_1.Weight = 100 + + mock_attr_2 = Mock() + mock_attr_2.LevelOfRisk = 3 + mock_attr_2.Weight = 50 + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="High risk requirement", + status=StatusChoices.FAIL, + passed_findings=8, + failed_findings=2, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Low risk requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # REQ-001: rate=0.8, rfac=2.25, num=0.8*10*100*2.25=1800, den=10*100*2.25=2250 + # REQ-002: rate=1.0, rfac=1.75, num=1.0*5*50*1.75=437.5, den=5*50*1.75=437.5 + # total_num = 1800 + 437.5 = 2237.5 + # total_den = 2250 + 437.5 = 2687.5 + # score = (2237.5 / 2687.5) * 100 β‰ˆ 83.26% + assert 83.0 < result < 84.0 + + def test_calculate_threatscore_zero_weight( + self, threatscore_generator, basic_compliance_data + ): + """Test ThreatScore calculation with zero weight.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 + mock_attr.Weight = 0 + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Zero weight requirement", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # With weight=0, denominator will be 0, should return 0.0 + assert result == 0.0 + + +# ============================================================================= +# Type Conversion Tests (Critical for bug fix validation) +# ============================================================================= + + +class TestTypeConversionSafety: + """Test suite for type conversion safety in ThreatScore calculations.""" + + def test_calculate_threatscore_with_string_risk_level( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_string_values, + ): + """Test that string LevelOfRisk is correctly converted to int.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="String values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_string_values] + } + }, + } + + # Should not raise TypeError: '<=' not supported between 'str' and 'int' + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # LevelOfRisk="5" -> 5, Weight="150" -> 150 + # rate_i = 0.5, rfac_i = 1 + 0.25*5 = 2.25 + # numerator = 0.5 * 10 * 150 * 2.25 = 1687.5 + # denominator = 10 * 150 * 2.25 = 3375 + # score = 50.0 + assert result == 50.0 + + def test_calculate_threatscore_with_invalid_string_values( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_invalid_values, + ): + """Test that invalid string values default to 0.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Invalid values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_invalid_values] + } + }, + } + + # Should not raise ValueError, should default to 0 + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # With weight=0 (from invalid string), denominator is 0 + assert result == 0.0 + + def test_calculate_threatscore_with_empty_values( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_empty_values, + ): + """Test that empty string values default to 0.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Empty values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_empty_values] + } + }, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # Empty strings should default to 0 + assert result == 0.0 + + def test_calculate_threatscore_with_none_values( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_none_values, + ): + """Test that None values default to 0.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="None values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_none_values] + } + }, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # None values should default to 0 + assert result == 0.0 + + def test_critical_failed_requirements_with_string_risk_level( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_string_values, + ): + """Test that critical requirements filter works with string LevelOfRisk.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="High risk with string", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_string_values] + } + }, + } + + # Should not raise TypeError + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + # LevelOfRisk="5" should be converted to 5, which is >= 4 + assert len(result) == 1 + assert result[0]["id"] == "REQ-001" + assert result[0]["risk_level"] == 5 + assert result[0]["weight"] == 150 + + def test_critical_failed_requirements_with_invalid_risk_level( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_invalid_values, + ): + """Test that invalid LevelOfRisk is excluded from critical requirements.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Invalid risk level", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_invalid_values] + } + }, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + # Invalid string defaults to 0, which is < 4 + assert len(result) == 0 + + +# ============================================================================= +# Critical Failed Requirements Tests +# ============================================================================= + + +class TestCriticalFailedRequirements: + """Test suite for critical failed requirements identification.""" + + def test_get_critical_failed_no_failures( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that no critical requirements are returned when all pass.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passing requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 0 + + def test_get_critical_failed_below_threshold( + self, threatscore_generator, basic_compliance_data + ): + """Test that low risk failures are not included.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 2 # Below threshold of 4 + mock_attr.Weight = 100 + mock_attr.Title = "Low Risk" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Low risk failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 0 + + def test_get_critical_failed_at_threshold( + self, threatscore_generator, basic_compliance_data + ): + """Test that requirements at exactly the threshold are included.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 # Exactly at threshold + mock_attr.Weight = 100 + mock_attr.Title = "At Threshold" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="At threshold failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 1 + assert result[0]["risk_level"] == 4 + + def test_get_critical_failed_sorted_by_risk_and_weight( + self, threatscore_generator, basic_compliance_data + ): + """Test that critical requirements are sorted by risk level then weight.""" + mock_attr_1 = Mock() + mock_attr_1.LevelOfRisk = 4 + mock_attr_1.Weight = 150 + mock_attr_1.Title = "Mid risk, high weight" + mock_attr_1.Section = "1. IAM" + + mock_attr_2 = Mock() + mock_attr_2.LevelOfRisk = 5 + mock_attr_2.Weight = 50 + mock_attr_2.Title = "High risk, low weight" + mock_attr_2.Section = "2. Attack Surface" + + mock_attr_3 = Mock() + mock_attr_3.LevelOfRisk = 5 + mock_attr_3.Weight = 100 + mock_attr_3.Title = "High risk, mid weight" + mock_attr_3.Section = "3. Logging" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="First", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-002", + description="Second", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-003", + description="Third", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + "REQ-003": {"attributes": {"req_attributes": [mock_attr_3]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 3 + # Sorted by (risk_level, weight) descending + # First: risk=5, weight=100 (REQ-003) + # Second: risk=5, weight=50 (REQ-002) + # Third: risk=4, weight=150 (REQ-001) + assert result[0]["id"] == "REQ-003" + assert result[1]["id"] == "REQ-002" + assert result[2]["id"] == "REQ-001" + + def test_get_critical_failed_manual_status_excluded( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that MANUAL status requirements are excluded.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 0 + + +# ============================================================================= +# Section Score Chart Tests +# ============================================================================= + + +class TestSectionScoreChart: + """Test suite for section score chart generation.""" + + def test_create_section_chart_empty_data( + self, threatscore_generator, basic_compliance_data + ): + """Test chart creation with no requirements.""" + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = {} + + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + assert result.getvalue() # Should have content + + def test_create_section_chart_single_section( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test chart creation with a single section.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="IAM requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + + def test_create_section_chart_multiple_sections( + self, threatscore_generator, basic_compliance_data + ): + """Test chart creation with multiple sections.""" + mock_attr_1 = Mock() + mock_attr_1.LevelOfRisk = 4 + mock_attr_1.Weight = 100 + mock_attr_1.Section = "1. IAM" + + mock_attr_2 = Mock() + mock_attr_2.LevelOfRisk = 3 + mock_attr_2.Weight = 50 + mock_attr_2.Section = "2. Attack Surface" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="IAM requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Attack Surface requirement", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ), + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + } + + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + + def test_create_section_chart_no_findings_section_gets_100( + self, threatscore_generator, basic_compliance_data + ): + """Test that sections without findings get 100% score.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 + mock_attr.Weight = 100 + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="No findings requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + # Chart should be created without errors + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + + +# ============================================================================= +# Executive Summary Tests +# ============================================================================= + + +class TestExecutiveSummary: + """Test suite for executive summary generation.""" + + def test_executive_summary_contains_chart( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that executive summary contains a chart.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_executive_summary(basic_compliance_data) + + assert len(elements) > 0 + assert any(isinstance(e, Image) for e in elements) + + def test_executive_summary_contains_score_table( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that executive summary contains a score table.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_executive_summary(basic_compliance_data) + + assert any(isinstance(e, Table) for e in elements) + + +# ============================================================================= +# Charts Section Tests +# ============================================================================= + + +class TestChartsSection: + """Test suite for charts section generation.""" + + def test_charts_section_no_critical_failures( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test charts section when no critical failures exist.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passing requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + assert len(elements) > 0 + # Should contain success message + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "No critical failed requirements" in content or "Great job" in content + + def test_charts_section_with_critical_failures( + self, threatscore_generator, basic_compliance_data + ): + """Test charts section when critical failures exist.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 5 + mock_attr.Weight = 100 + mock_attr.Title = "Critical Failure" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Critical failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + assert len(elements) > 0 + # Should contain a table with critical requirements + assert any(isinstance(e, Table) for e in elements) + + def test_charts_section_starts_with_page_break( + self, threatscore_generator, basic_compliance_data + ): + """Test that charts section starts with a page break.""" + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = {} + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + assert len(elements) > 0 + assert isinstance(elements[0], PageBreak) + + def test_charts_section_respects_min_risk_level( + self, threatscore_generator, basic_compliance_data + ): + """Test that charts section respects the min_risk_level setting.""" + threatscore_generator._min_risk_level = 5 # Higher threshold + + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 # Below the new threshold + mock_attr.Weight = 100 + mock_attr.Title = "Medium Risk" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Medium risk failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + # Should not contain a table since risk=4 < min=5 + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) == 0 + + +# ============================================================================= +# Requirements Index Tests +# ============================================================================= + + +class TestRequirementsIndex: + """Test suite for requirements index generation.""" + + def test_requirements_index_empty( + self, threatscore_generator, basic_compliance_data + ): + """Test requirements index with no requirements.""" + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = {} + + elements = threatscore_generator.create_requirements_index( + basic_compliance_data + ) + + assert len(elements) >= 1 # At least the header + assert isinstance(elements[0], Paragraph) + + def test_requirements_index_single_requirement( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test requirements index with a single requirement.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_requirements_index( + basic_compliance_data + ) + + assert len(elements) >= 2 # Header + at least section header + + def test_requirements_index_organized_by_section( + self, threatscore_generator, basic_compliance_data + ): + """Test that requirements index is organized by section.""" + mock_attr_1 = Mock() + mock_attr_1.Section = "1. IAM" + mock_attr_1.SubSection = "1.1 Access" + mock_attr_1.Title = "IAM Requirement" + + mock_attr_2 = Mock() + mock_attr_2.Section = "2. Attack Surface" + mock_attr_2.SubSection = "2.1 Exposure" + mock_attr_2.Title = "Attack Surface Requirement" + + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + } + + elements = threatscore_generator.create_requirements_index( + basic_compliance_data + ) + + # Check that section headers are present + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "IAM" in content or "1." in content + + +# ============================================================================= +# Critical Requirements Table Tests +# ============================================================================= + + +class TestCriticalRequirementsTable: + """Test suite for critical requirements table generation.""" + + def test_create_table_single_requirement(self, threatscore_generator): + """Test table creation with a single requirement.""" + critical = [ + { + "id": "REQ-001", + "risk_level": 5, + "weight": 100, + "title": "Test Requirement", + "section": "1. IAM", + } + ] + + table = threatscore_generator._create_critical_requirements_table(critical) + + assert isinstance(table, Table) + + def test_create_table_truncates_long_titles(self, threatscore_generator): + """Test that long titles are truncated.""" + critical = [ + { + "id": "REQ-001", + "risk_level": 5, + "weight": 100, + "title": "A" * 100, # Very long title + "section": "1. IAM", + } + ] + + table = threatscore_generator._create_critical_requirements_table(critical) + + # Table should be created without errors + assert isinstance(table, Table) + + def test_create_table_multiple_requirements(self, threatscore_generator): + """Test table creation with multiple requirements.""" + critical = [ + { + "id": "REQ-001", + "risk_level": 5, + "weight": 150, + "title": "First", + "section": "1. IAM", + }, + { + "id": "REQ-002", + "risk_level": 4, + "weight": 100, + "title": "Second", + "section": "2. Attack Surface", + }, + ] + + table = threatscore_generator._create_critical_requirements_table(critical) + + assert isinstance(table, Table) diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 8902b17b54..5f244e0103 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1380,6 +1380,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache: set[tuple[str, str, str, str]] = set() mute_rules_cache = {} scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} with ( patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), @@ -1398,6 +1400,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache, mute_rules_cache, scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, ) created_finding = Finding.objects.get(uid=finding.uid) @@ -1491,6 +1495,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache: set[tuple[str, str, str, str]] = set() mute_rules_cache = {finding.uid: "Muted via rule"} scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} with ( patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), @@ -1509,6 +1515,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache, mute_rules_cache, scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, ) existing_resource.refresh_from_db() @@ -1617,6 +1625,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache: set[tuple[str, str, str, str]] = set() mute_rules_cache = {} scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} with ( patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), @@ -1636,6 +1646,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache, mute_rules_cache, scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, ) # Verify the long UID finding was NOT created @@ -1713,6 +1725,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache: set[tuple[str, str, str, str]] = set() mute_rules_cache = {} scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} with ( patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), @@ -1731,6 +1745,8 @@ class TestProcessFindingMicroBatch: scan_resource_cache, mute_rules_cache, scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, ) # finding1: PASS, severity=low, categories=["gen-ai", "security"] diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 376315d90d..3a58118c62 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -1,10 +1,13 @@ import uuid +from contextlib import contextmanager +from datetime import datetime, timezone from unittest.mock import MagicMock, patch import openai import pytest from botocore.exceptions import ClientError from django_celery_beat.models import IntervalSchedule, PeriodicTask +from django_celery_results.models import TaskResult from tasks.jobs.lighthouse_providers import ( _create_bedrock_client, _extract_bedrock_credentials, @@ -15,6 +18,8 @@ from tasks.tasks import ( check_integrations_task, check_lighthouse_provider_connection_task, generate_outputs_task, + perform_attack_paths_scan_task, + perform_scheduled_scan_task, refresh_lighthouse_provider_models_task, s3_integration_task, security_hub_integration_task, @@ -26,6 +31,7 @@ from api.models import ( LighthouseProviderModels, Scan, StateChoices, + Task, ) @@ -737,8 +743,12 @@ class TestScanCompleteTasks: @patch("tasks.tasks.generate_outputs_task.si") @patch("tasks.tasks.generate_compliance_reports_task.si") @patch("tasks.tasks.check_integrations_task.si") + @patch("tasks.tasks.perform_attack_paths_scan_task.apply_async") + @patch("tasks.tasks.can_provider_run_attack_paths_scan", return_value=False) def test_scan_complete_tasks( self, + mock_can_run_attack_paths, + mock_attack_paths_task, mock_check_integrations_task, mock_compliance_reports_task, mock_outputs_task, @@ -793,6 +803,67 @@ class TestScanCompleteTasks: scan_id="scan-id", ) + # Attack Paths task should be skipped when provider cannot run it + mock_attack_paths_task.assert_not_called() + + +class TestAttackPathsTasks: + @staticmethod + @contextmanager + def _override_task_request(task, **attrs): + request = task.request + sentinel = object() + previous = {key: getattr(request, key, sentinel) for key in attrs} + for key, value in attrs.items(): + setattr(request, key, value) + + try: + yield + finally: + for key, prev in previous.items(): + if prev is sentinel: + if hasattr(request, key): + delattr(request, key) + else: + setattr(request, key, prev) + + def test_perform_attack_paths_scan_task_calls_runner(self): + with ( + patch("tasks.tasks.attack_paths_scan") as mock_attack_paths_scan, + self._override_task_request( + perform_attack_paths_scan_task, id="celery-task-id" + ), + ): + mock_attack_paths_scan.return_value = {"status": "ok"} + + result = perform_attack_paths_scan_task.run( + tenant_id="tenant-id", scan_id="scan-id" + ) + + mock_attack_paths_scan.assert_called_once_with( + tenant_id="tenant-id", scan_id="scan-id", task_id="celery-task-id" + ) + assert result == {"status": "ok"} + + def test_perform_attack_paths_scan_task_propagates_exception(self): + with ( + patch( + "tasks.tasks.attack_paths_scan", + side_effect=RuntimeError("Exception to propagate"), + ) as mock_attack_paths_scan, + self._override_task_request( + perform_attack_paths_scan_task, id="celery-task-error" + ), + ): + with pytest.raises(RuntimeError, match="Exception to propagate"): + perform_attack_paths_scan_task.run( + tenant_id="tenant-id", scan_id="scan-id" + ) + + mock_attack_paths_scan.assert_called_once_with( + tenant_id="tenant-id", scan_id="scan-id", task_id="celery-task-error" + ) + @pytest.mark.django_db class TestCheckIntegrationsTask: @@ -2068,3 +2139,215 @@ class TestCleanupOrphanScheduledScans: assert not Scan.objects.filter(id=orphan_scan.id).exists() assert Scan.objects.filter(id=scheduled_scan.id).exists() assert Scan.objects.filter(id=available_scan_other_task.id).exists() + + +@pytest.mark.django_db +class TestPerformScheduledScanTask: + """Unit tests for perform_scheduled_scan_task.""" + + @staticmethod + @contextmanager + def _override_task_request(task, **attrs): + request = task.request + sentinel = object() + previous = {key: getattr(request, key, sentinel) for key in attrs} + for key, value in attrs.items(): + setattr(request, key, value) + + try: + yield + finally: + for key, prev in previous.items(): + if prev is sentinel: + if hasattr(request, key): + delattr(request, key) + else: + setattr(request, key, prev) + + def _create_periodic_task(self, provider_id, tenant_id, interval_hours=24): + interval, _ = IntervalSchedule.objects.get_or_create( + every=interval_hours, period="hours" + ) + return PeriodicTask.objects.create( + name=f"scan-perform-scheduled-{provider_id}", + task="scan-perform-scheduled", + interval=interval, + kwargs=f'{{"tenant_id": "{tenant_id}", "provider_id": "{provider_id}"}}', + enabled=True, + ) + + def _create_task_result(self, tenant_id, task_id): + task_result = TaskResult.objects.create( + task_id=task_id, + task_name="scan-perform-scheduled", + status="STARTED", + date_created=datetime.now(timezone.utc), + ) + Task.objects.create( + id=task_id, task_runner_task=task_result, tenant_id=tenant_id + ) + return task_result + + def test_skip_when_scheduled_scan_executing( + self, tenants_fixture, providers_fixture + ): + """Skip a scheduled run when another scheduled scan is already executing.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + task_id = str(uuid.uuid4()) + self._create_task_result(tenant.id, task_id) + + executing_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.EXECUTING, + scheduler_task_id=periodic_task.id, + ) + + with ( + patch("tasks.tasks.perform_prowler_scan") as mock_scan, + patch("tasks.tasks._perform_scan_complete_tasks") as mock_complete_tasks, + self._override_task_request(perform_scheduled_scan_task, id=task_id), + ): + result = perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=str(provider.id) + ) + + mock_scan.assert_not_called() + mock_complete_tasks.assert_not_called() + assert result["id"] == str(executing_scan.id) + assert result["state"] == StateChoices.EXECUTING + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + ).count() + == 0 + ) + + def test_creates_next_scheduled_scan_after_completion( + self, tenants_fixture, providers_fixture + ): + """Create a next scheduled scan after a successful run completes.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + self._create_periodic_task(provider.id, tenant.id) + task_id = str(uuid.uuid4()) + self._create_task_result(tenant.id, task_id) + + def _complete_scan(tenant_id, scan_id, provider_id): + other_scheduled = Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + ).exclude(id=scan_id) + assert not other_scheduled.exists() + scan_instance = Scan.objects.get(id=scan_id) + scan_instance.state = StateChoices.COMPLETED + scan_instance.save() + return {"status": "ok"} + + with ( + patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan), + patch("tasks.tasks._perform_scan_complete_tasks"), + self._override_task_request(perform_scheduled_scan_task, id=task_id), + ): + perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=str(provider.id) + ) + + scheduled_scans = Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + ) + assert scheduled_scans.count() == 1 + assert scheduled_scans.first().scheduled_at > datetime.now(timezone.utc) + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + ).count() + == 1 + ) + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.COMPLETED, + ).count() + == 1 + ) + + def test_dedupes_multiple_scheduled_scans_before_run( + self, tenants_fixture, providers_fixture + ): + """Ensure duplicated scheduled scans are removed before executing.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + task_id = str(uuid.uuid4()) + self._create_task_result(tenant.id, task_id) + + scheduled_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduled_at=datetime.now(timezone.utc), + scheduler_task_id=periodic_task.id, + ) + duplicate_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduled_at=scheduled_scan.scheduled_at, + scheduler_task_id=periodic_task.id, + ) + + def _complete_scan(tenant_id, scan_id, provider_id): + other_scheduled = Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + ).exclude(id=scan_id) + assert not other_scheduled.exists() + scan_instance = Scan.objects.get(id=scan_id) + scan_instance.state = StateChoices.COMPLETED + scan_instance.save() + return {"status": "ok"} + + with ( + patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan), + patch("tasks.tasks._perform_scan_complete_tasks"), + self._override_task_request(perform_scheduled_scan_task, id=task_id), + ): + perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=str(provider.id) + ) + + assert not Scan.objects.filter(id=duplicate_scan.id).exists() + assert Scan.objects.filter(id=scheduled_scan.id).exists() + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + ).count() + == 1 + ) diff --git a/api/src/backend/tasks/utils.py b/api/src/backend/tasks/utils.py index 21e30c9e29..eded5bfb9a 100644 --- a/api/src/backend/tasks/utils.py +++ b/api/src/backend/tasks/utils.py @@ -5,6 +5,10 @@ from enum import Enum from django_celery_beat.models import PeriodicTask from django_celery_results.models import TaskResult +from api.models import Scan, StateChoices + +SCHEDULED_SCAN_NAME = "Daily scheduled scan" + class CustomEncoder(json.JSONEncoder): def default(self, o): @@ -71,3 +75,58 @@ def batched(iterable, batch_size): batch = [] yield batch, True + + +def _get_or_create_scheduled_scan( + tenant_id: str, + provider_id: str, + scheduler_task_id: int, + scheduled_at: datetime, + update_state: bool = False, +) -> Scan: + """ + Get or create a scheduled scan, cleaning up duplicates if found. + + Args: + tenant_id: The tenant ID. + provider_id: The provider ID. + scheduler_task_id: The PeriodicTask ID. + scheduled_at: The scheduled datetime for the scan. + update_state: If True, also reset state to SCHEDULED when updating. + + Returns: + The scan instance to use. + """ + scheduled_scans = list( + Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + scheduler_task_id=scheduler_task_id, + ).order_by("scheduled_at", "inserted_at") + ) + + if scheduled_scans: + scan_instance = scheduled_scans[0] + if len(scheduled_scans) > 1: + Scan.objects.filter(id__in=[s.id for s in scheduled_scans[1:]]).delete() + needs_update = scan_instance.scheduled_at != scheduled_at + if update_state and scan_instance.state != StateChoices.SCHEDULED: + scan_instance.state = StateChoices.SCHEDULED + scan_instance.name = SCHEDULED_SCAN_NAME + needs_update = True + if needs_update: + scan_instance.scheduled_at = scheduled_at + scan_instance.save() + return scan_instance + + return Scan.objects.create( + tenant_id=tenant_id, + name=SCHEDULED_SCAN_NAME, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduled_at=scheduled_at, + scheduler_task_id=scheduler_task_id, + ) diff --git a/prowler/providers/cloudflare/services/zones/__init__.py b/contrib/aws/simulate_policy/__init__.py similarity index 100% rename from prowler/providers/cloudflare/services/zones/__init__.py rename to contrib/aws/simulate_policy/__init__.py diff --git a/contrib/aws/simulate_policy/simulate_policy_client.py b/contrib/aws/simulate_policy/simulate_policy_client.py new file mode 100644 index 0000000000..eebc9d5174 --- /dev/null +++ b/contrib/aws/simulate_policy/simulate_policy_client.py @@ -0,0 +1,20 @@ +# prowler/contrib/aws/simulate_policy_client.py +from typing import Optional + +from prowler.contrib.aws.simulate_policy.simulate_policy_service import IamSimulator +from prowler.providers.common.provider import Provider + +_iam_simulator_client: Optional[IamSimulator] = None + + +def get_iam_simulator_client() -> IamSimulator: + global _iam_simulator_client + if _iam_simulator_client is None: + provider = Provider.get_global_provider() + if provider is None: + # Fail fast with a clear message if somehow called too early + raise RuntimeError( + "Global Provider is not initialized yet for IAM simulator." + ) + _iam_simulator_client = IamSimulator(provider) + return _iam_simulator_client diff --git a/contrib/aws/simulate_policy/simulate_policy_service.py b/contrib/aws/simulate_policy/simulate_policy_service.py new file mode 100644 index 0000000000..b111515bb1 --- /dev/null +++ b/contrib/aws/simulate_policy/simulate_policy_service.py @@ -0,0 +1,200 @@ +# prowler/contrib/aws/simulate_policy_service.py + +import json +import logging +from typing import Dict, List, Optional, Tuple + +from botocore.exceptions import ClientError + +from prowler.providers.common.provider import Provider + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +# ====================================================================== +# PURPOSE +# ---------------------------------------------------------------------- +# This module provides a precise way to test IAM actions programmatically. +# It replicates the behaviour of the AWS CLI command: +# aws iam simulate-principal-policy --policy-source-arn arn:aws:iam:::role/ --action-names +# +# Use this when you need to validate whether a specific IAM role allows or denies +# certain actions against given resources. +# +# ====================================================================== +# CLI ANALOGUE +# ---------------------------------------------------------------------- +# Example equivalent CLI command: +# aws iam simulate-principal-policy \ +# --policy-source-arn arn:aws:iam::278419598935:role/your-role \ +# --action-names datazone:AcceptPredictions +# +# ====================================================================== +# DOCUMENTATION +# ---------------------------------------------------------------------- +# AWS IAM Policy Simulator: +# https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_testing-policies.html +# +# IAM Condition Keys: +# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html +# +# Related AWS SDK discussion: +# https://github.com/aws/aws-sdk/issues/102 +# +# ====================================================================== +# LIMITATIONS +# ---------------------------------------------------------------------- +# - The IAM Policy Simulator does NOT evaluate Service Control Policies (SCPs) +# that include conditions. This is a limitation of the API. +# - In environments where SCPs contain conditions, use +# `is_action_allowed_simulate_custom_policy` instead. +# - In environments without SCP conditions, `is_action_allowed_simulate_principal_policy` +# works as expected. +# +# ====================================================================== +# USAGE +# ---------------------------------------------------------------------- +# In your custom check: +# +# from prowler.contrib.aws.simulate_policy.simulate_policy_client import get_iam_simulator_client +# +# iam_sim = get_iam_simulator_client() +# policy_data = iam_sim.get_role_policy_data(role_name=role_name) +# iam_sim.is_action_allowed_simulate_custom_policy( +# policy_data=policy_data, +# action_names=[action], +# resource_arns=["*"] +# ) +# +# +# ====================================================================== + + +class IamSimulator: + """ + Helper for IAM Policy Simulator: + - simulate_principal_policy + - simulate_custom_policy + - collect role inline/managed policies + """ + + def __init__(self, provider: Provider) -> None: + + boto3_session = provider.session.current_session + + # IAM is a global service. Region is optional; we can use the provider's global region + # to stay consistent across partitions. + try: + region_name = provider.get_global_region() + except AttributeError: + # Fallback if provider lacks the helper (older trees) + region_name = boto3_session.region_name or "us-east-1" + + self.iam = boto3_session.client("iam", region_name=region_name) + + def is_action_allowed_simulate_principal_policy( + self, + principal_arn: str, + action_names: List[str], + resource_arns: Optional[List[str]] = None, + ) -> Tuple[bool, Dict]: + if resource_arns is None: + resource_arns = ["*"] + try: + resp = self.iam.simulate_principal_policy( + PolicySourceArn=principal_arn, + ActionNames=action_names, + ResourceArns=resource_arns, + ) + allowed = any( + r.get("EvalDecision") == "allowed" + for r in resp.get("EvaluationResults", []) + ) + return allowed, resp + except ClientError as e: + logger.error("simulate_principal_policy failed: %s", e, exc_info=True) + return False, {"error": str(e)} + + def get_role_policy_data(self, role_name: str) -> Dict[str, List]: + inline_names: List[str] = [] + inline_docs: List[Dict] = [] + managed_names: List[str] = [] + managed_docs: List[Dict] = [] + + # Inline policies + inline_resp = self.iam.list_role_policies(RoleName=role_name) + inline_names = inline_resp.get("PolicyNames", []) + for pname in inline_names: + pol_resp = self.iam.get_role_policy(RoleName=role_name, PolicyName=pname) + inline_docs.append(pol_resp["PolicyDocument"]) # dict + + # Managed policies + managed_resp = self.iam.list_attached_role_policies(RoleName=role_name) + for attached in managed_resp.get("AttachedPolicies", []): + managed_names.append(attached["PolicyName"]) + pol_meta = self.iam.get_policy(PolicyArn=attached["PolicyArn"])["Policy"] + pol_ver = self.iam.get_policy_version( + PolicyArn=attached["PolicyArn"], VersionId=pol_meta["DefaultVersionId"] + ) + managed_docs.append(pol_ver["PolicyVersion"]["Document"]) # dict + + return { + "inline_policy_names": inline_names, + "inline_policy_data": inline_docs, + "managed_policy_names": managed_names, + "managed_policy_data": managed_docs, + } + + def is_action_allowed_simulate_custom_policy( + self, + policy_data: Dict[str, List], + action_names: List[str], + resource_arns: Optional[List[str]] = None, + ) -> Tuple[bool, Dict]: + names = policy_data.get("inline_policy_names", []) + policy_data.get( + "managed_policy_names", [] + ) + docs = policy_data.get("inline_policy_data", []) + policy_data.get( + "managed_policy_data", [] + ) + + results: Dict[str, List] = {"policies": []} + any_allowed = False + if resource_arns is None: + resource_arns = ["*"] + + for idx, doc in enumerate(docs): + name = names[idx] if idx < len(names) else f"policy_{idx}" + try: + sim_resp = self.iam.simulate_custom_policy( + PolicyInputList=[json.dumps(doc)], + ActionNames=action_names, + ResourceArns=resource_arns, + ) + except ClientError as e: + logger.error( + "simulate_custom_policy failed for %s: %s", name, e, exc_info=True + ) + results["policies"].append({"policy_name": name, "error": str(e)}) + continue + + per_action = [] + for ev in sim_resp.get("EvaluationResults", []): + decision = ev.get( + "EvalDecision" + ) # allowed | explicitDeny | implicitDeny + per_action.append( + { + "action": ev.get("EvalActionName"), + "decision": decision, + "matching_statements": ev.get("MatchedStatements", []), + "missing_context_values": ev.get("MissingContextValues", []), + } + ) + if decision == "allowed": + any_allowed = True + + results["policies"].append({"policy_name": name, "evaluations": per_action}) + + return any_allowed, results diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 746948cc3a..b5de1af83f 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,6 +1,7 @@ services: api-dev: hostname: "prowler-api" + image: prowler-api-dev build: context: ./api dockerfile: Dockerfile @@ -24,6 +25,8 @@ services: condition: service_healthy valkey: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "/home/prowler/docker-entrypoint.sh" - "dev" @@ -85,7 +88,41 @@ services: timeout: 5s retries: 3 + neo4j: + image: graphstack/dozerdb:5.26.3.0 + hostname: "neo4j" + volumes: + - ./_data/neo4j:/data + environment: + # We can't add our .env file because some of our current variables are not compatible with Neo4j env vars + # Auth + - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD} + # Memory limits + - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000} + - NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G} + - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G} + - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G} + # APOC + - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true} + - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true} + - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} + - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}" + - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}" + - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}" + # Networking + - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}" + # 7474 is the UI port + ports: + - 7474:7474 + - ${NEO4J_PORT:-7687}:7687 + healthcheck: + test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"] + interval: 10s + timeout: 10s + retries: 10 + worker-dev: + image: prowler-api-dev build: context: ./api dockerfile: Dockerfile @@ -96,17 +133,23 @@ services: - path: .env required: false volumes: - - "outputs:/tmp/prowler_api_output" + - ./api/src/backend:/home/prowler/backend + - ./api/pyproject.toml:/home/prowler/pyproject.toml + - ./api/docker-entrypoint.sh:/home/prowler/docker-entrypoint.sh + - outputs:/tmp/prowler_api_output depends_on: valkey: condition: service_healthy postgres: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "/home/prowler/docker-entrypoint.sh" - "worker" worker-beat: + image: prowler-api-dev build: context: ./api dockerfile: Dockerfile @@ -121,6 +164,8 @@ services: condition: service_healthy postgres: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "../docker-entrypoint.sh" - "beat" diff --git a/docker-compose.yml b/docker-compose.yml index 3c9b2f67ff..992a753ac0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: condition: service_healthy valkey: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "/home/prowler/docker-entrypoint.sh" - "prod" @@ -72,6 +74,37 @@ services: timeout: 5s retries: 3 + neo4j: + image: graphstack/dozerdb:5.26.3.0 + hostname: "neo4j" + volumes: + - ./_data/neo4j:/data + environment: + # We can't add our .env file because some of our current variables are not compatible with Neo4j env vars + # Auth + - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD} + # Memory limits + - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000} + - NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G} + - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G} + - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G} + # APOC + - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true} + - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true} + - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} + - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}" + - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}" + - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}" + # Networking + - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}" + ports: + - ${NEO4J_PORT:-7687}:7687 + healthcheck: + test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"] + interval: 10s + timeout: 10s + retries: 10 + worker: image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} env_file: diff --git a/docs/contact.mdx b/docs/contact.mdx deleted file mode 100644 index 3f898b9049..0000000000 --- a/docs/contact.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: 'Contact Us' ---- - -For technical support or any type of inquiries, you are very welcome to: - -- Reach out to community members on the [**Prowler Slack channel**](https://goto.prowler.com/slack) - -- Open an Issue or a Pull Request in our [**GitHub repository**](https://github.com/prowler-cloud/prowler). - -We will appreciate all types of feedback and contribution, Prowler would not be the same without our vibrant community! πŸ˜ƒ diff --git a/docs/docs.json b/docs/docs.json index 1aa7c96fa6..8b2edb1438 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -331,14 +331,28 @@ }, { "tab": "Security", - "pages": [ - "security" + "groups": [ + { + "group": "Security & Compliance", + "pages": [ + "security/index", + "security/software-security" + ] + }, + { + "group": "Prowler Cloud", + "pages": [ + "security/encryption", + "security/data-regions", + "security/networking" + ] + } ] }, { - "tab": "Contact Us", + "tab": "Support", "pages": [ - "contact" + "support" ] }, { @@ -459,6 +473,10 @@ { "source": "/projects/prowler-open-source/en/latest/tutorials/:slug*", "destination": "/user-guide/tutorials/:slug*" + }, + { + "source": "/contact", + "destination": "/support" } ] } diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index cd3e18b3ea..2a1d56d605 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -115,8 +115,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.16.0" -PROWLER_API_VERSION="5.16.0" +PROWLER_UI_VERSION="5.17.0" +PROWLER_API_VERSION="5.17.0" ``` diff --git a/docs/images/providers/grant-admin-consent.png b/docs/images/providers/grant-admin-consent.png index 0b242308f9..b080cffb3e 100644 Binary files a/docs/images/providers/grant-admin-consent.png and b/docs/images/providers/grant-admin-consent.png differ diff --git a/docs/images/providers/granted-admin-consent.png b/docs/images/providers/granted-admin-consent.png new file mode 100644 index 0000000000..ceafbbd675 Binary files /dev/null and b/docs/images/providers/granted-admin-consent.png differ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 928a527a88..eee557b4fc 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -31,11 +31,13 @@ The supported providers right now are: | [Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) | Official | Clusters | UI, API, CLI | | [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI | | [Github](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI | -| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | -| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | -| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | -| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | -| **NHN** | Unofficial | Tenants | CLI | +| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | +| [Alibaba Cloud](/user-guide/providers/alibabacloud/getting-started-alibabacloud) | Official | Accounts | UI, API, CLI | +| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI | +| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | +| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | +| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | +| **NHN** | Unofficial | Tenants | CLI | For more information about the checks and compliance of each provider visit [Prowler Hub](https://hub.prowler.com). diff --git a/docs/security.mdx b/docs/security.mdx deleted file mode 100644 index fbf90e63ef..0000000000 --- a/docs/security.mdx +++ /dev/null @@ -1,163 +0,0 @@ ---- -title: 'Security' ---- - -## Compliance and Trust -We publish our live SOC 2 Type 2 Compliance data at [https://trust.prowler.com](https://trust.prowler.com) - -As an **AWS Partner**, we have passed the [AWS Foundation Technical Review (FTR)](https://aws.amazon.com/partners/foundational-technical-review/). - - -## Encryption (Prowler Cloud) - -We use encryption everywhere possible. The data and communications used by **Prowler Cloud** are **encrypted at-rest** and **in-transit**. - -## Data Retention Policy (Prowler Cloud) - -Prowler Cloud is GDPR compliant in regards to personal data and the ["right to be forgotten"](https://gdpr.eu/right-to-be-forgotten/). When a user deletes their account their user information will be deleted from Prowler Cloud online and backup systems within 10 calendar days. - -## Software Security - -We follow a **security-by-design approach** throughout our software development lifecycle. All changes go through automated checks at every stage, from local development to production deployment. - -We enforce [pre-commit](https://github.com/prowler-cloud/prowler/blob/master/.pre-commit-config.yaml) validations to catch issues early, and [our CI/CD pipelines](https://github.com/prowler-cloud/prowler/tree/master/.github) include multiple security gates to ensure code quality, secure configurations, and compliance with internal standards. - -Our container registries are continuously scanned for vulnerabilities, with findings automatically reported to our security team for assessment and remediation. This process evolves alongside our stack as we adopt new languages, frameworks, and technologies, ensuring our security practices remain comprehensive, proactive, and adaptable. - -### Static Application Security Testing (SAST) - -We employ multiple SAST tools across our codebase to identify security vulnerabilities, code quality issues, and potential bugs during development: - -#### CodeQL Analysis -- **Scope**: UI (JavaScript/TypeScript), API (Python), and SDK (Python) -- **Frequency**: On every push and pull request, plus daily scheduled scans -- **Integration**: Results uploaded to GitHub Security tab via SARIF format -- **Purpose**: Identifies security vulnerabilities, coding errors, and potential exploits in source code - -#### Python Security Scanners -- **Bandit**: Detects common security issues in Python code (SQL injection, hardcoded passwords, etc.) - - Configured to ignore test files and report only high-severity issues - - Runs on both SDK and API codebases -- **Pylint**: Static code analysis with security-focused checks - - Integrated into pre-commit hooks and CI/CD pipelines - -#### Code Quality & Dead Code Detection -- **Vulture**: Identifies unused code that could indicate incomplete implementations or security gaps -- **Flake8**: Style guide enforcement with security-relevant checks -- **Shellcheck**: Security and correctness checks for shell scripts - -### Software Composition Analysis (SCA) - -We continuously monitor our dependencies for known vulnerabilities and ensure timely updates: - -#### Dependency Vulnerability Scanning -- **Safety**: Scans Python dependencies against known vulnerability databases - - Runs on every commit via pre-commit hooks - - Integrated into CI/CD for SDK and API - - Configured with selective ignores for tracked exceptions -- **Trivy**: Multi-purpose scanner for containers and dependencies - - Scans all container images (UI, API, SDK, MCP Server) - - Checks for vulnerabilities in OS packages and application dependencies - - Reports findings to GitHub Security tab - -#### Automated Dependency Updates -- **Dependabot**: Automated pull requests for dependency updates - - **Python (pip)**: Monthly updates for SDK - - **GitHub Actions**: Monthly updates for workflow dependencies - - **Docker**: Monthly updates for base images - - Temporarily paused for API and UI to maintain stability during active development - - **Security-first approach**: Even when paused, Dependabot automatically creates pull requests for security vulnerabilities, ensuring critical security patches are never delayed - -### Container Security - -All container images are scanned before deployment: - -- **Trivy Vulnerability Scanning**: - - Scans images for vulnerabilities and misconfigurations - - Generates SARIF reports uploaded to GitHub Security tab - - Creates PR comments with scan summaries - - Configurable to fail builds on critical findings - - Reports include CVE counts and remediation guidance -- **Hadolint**: Dockerfile linting to enforce best practices - - Validates Dockerfile syntax and structure - - Ensures secure image building practices - -### Secrets Detection - -We protect against accidental exposure of sensitive credentials: - -- **TruffleHog**: Scans entire codebase and Git history for secrets - - Runs on every push and pull request - - Pre-commit hook prevents committing secrets - - Detects high-entropy strings, API keys, tokens, and credentials - - Configured to report verified and unknown findings - -### Security Monitoring - -- **GitHub Security Tab**: Centralized view of all security findings from CodeQL, Trivy, and other SARIF-compatible tools -- **Artifact Retention**: Security scan reports retained for post-deployment analysis -- **PR Comments**: Automated security feedback on pull requests for rapid remediation - -## Reporting Vulnerabilities - -At Prowler, we consider the security of our open source software and systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present. - -If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our users, our clients and our systems. - -When reporting vulnerabilities, please consider (1) attack scenario / exploitability, and (2) the security impact of the bug. The following issues are considered out of scope: - -- Social engineering support or attacks requiring social engineering. -- Clickjacking on pages with no sensitive actions. -- Cross-Site Request Forgery (CSRF) on unauthenticated forms or forms with no sensitive actions. -- Attacks requiring Man-In-The-Middle (MITM) or physical access to a user's device. -- Previously known vulnerable libraries without a working Proof of Concept (PoC). -- Comma Separated Values (CSV) injection without demonstrating a vulnerability. -- Missing best practices in SSL/TLS configuration. -- Any activity that could lead to the disruption of service (DoS). -- Rate limiting or brute force issues on non-authentication endpoints. -- Missing best practices in Content Security Policy (CSP). -- Missing HttpOnly or Secure flags on cookies. -- Configuration of or missing security headers. -- Missing email best practices, such as invalid, incomplete, or missing SPF/DKIM/DMARC records. -- Vulnerabilities only affecting users of outdated or unpatched browsers (less than two stable versions behind). -- Software version disclosure, banner identification issues, or descriptive error messages. -- Tabnabbing. -- Issues that require unlikely user interaction. -- Improper logout functionality and improper session timeout. -- CORS misconfiguration without an exploitation scenario. -- Broken link hijacking. -- Automated scanning results (e.g., sqlmap, Burp active scanner) that have not been manually verified. -- Content spoofing and text injection issues without a clear attack vector. -- Email spoofing without exploiting security flaws. -- Dead links or broken links. -- User enumeration. - -Testing guidelines: - -- Do not run automated scanners on other customer projects. Running automated scanners can run up costs for our users. Aggressively configured scanners might inadvertently disrupt services, exploit vulnerabilities, lead to system instability or breaches and violate Terms of Service from our upstream providers. Our own security systems won't be able to distinguish hostile reconnaissance from whitehat research. If you wish to run an automated scanner, notify us at support@prowler.com and only run it on your own Prowler app project. Do NOT attack Prowler in usage of other customers. -- Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data. - -Reporting guidelines: - -- File a report through our Support Desk at https://support.prowler.com -- If it is about a lack of a security functionality, please file a feature request instead at https://github.com/prowler-cloud/prowler/issues -- Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. -- If you have further questions and want direct interaction with the Prowler team, please contact us at via our Community Slack at goto.prowler.com/slack. - -Disclosure guidelines: - -- In order to protect our users and customers, do not reveal the problem to others until we have researched, addressed and informed our affected customers. -- If you want to publicly share your research about Prowler at a conference, in a blog or any other public forum, you should share a draft with us for review and approval at least 30 days prior to the publication date. Please note that the following should not be included: - - Data regarding any Prowler user or customer projects. - - Prowler customers' data. - - Information about Prowler employees, contractors or partners. - -What we promise: - -- We will respond to your report within 5 business days with our evaluation of the report and an expected resolution date. -- If you have followed the instructions above, we will not take any legal action against you in regard to the report. -- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission. -- We will keep you informed of the progress towards resolving the problem. -- In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise). - -We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved. diff --git a/docs/security/data-regions.mdx b/docs/security/data-regions.mdx new file mode 100644 index 0000000000..0602caf7b9 --- /dev/null +++ b/docs/security/data-regions.mdx @@ -0,0 +1,25 @@ +--- +title: 'Data Regions & Availability' +--- + +Prowler Cloud runs on AWS with high availability built in. + +## Regions + +| Region | URL | Location | +|--------|-----|----------| +| **EU** | [cloud.prowler.com](https://cloud.prowler.com) | Ireland (`eu-west-1`) | + +## Business Continuity + +| Control | Details | +|---------|---------| +| **High Availability** | Multi-AZ databases and load-balanced stateless application layer on AWS | +| **Disaster Recovery** | Encrypted backups, tested regularly | +| **[RPO](https://en.wikipedia.org/wiki/Recovery_point_objective)** | 24 hours | +| **[RTO](https://en.wikipedia.org/wiki/Recovery_time_objective)** | 2 hours | +| **Status** | [status.prowler.com](https://status.prowler.com) β€” uptime history and incidents | + +## Contact + +For questions about data regions and availability, visit the [Support page](/support). diff --git a/docs/security/encryption.mdx b/docs/security/encryption.mdx new file mode 100644 index 0000000000..3c05643069 --- /dev/null +++ b/docs/security/encryption.mdx @@ -0,0 +1,25 @@ +--- +title: 'Encryption' +--- + +Prowler Cloud uses encryption everywhere possible. All data and communications are encrypted at rest and in transit. + +## Encryption at Rest + +All data stored in Prowler Cloud is encrypted at rest using AES-256 encryption, including: + +- **Database contents:** All scan results, findings, and configuration data. +- **File storage:** Reports, exports, and uploaded files. +- **Backups:** All backup data is encrypted. + +## Encryption in Transit + +All communications with Prowler Cloud are encrypted in transit using TLS 1.2 or higher, including: + +- **API requests:** All REST API communications. +- **Web application traffic:** Browser-to-server connections. +- **Internal service communication:** Service-to-service traffic within the platform. + +## Contact + +For questions regarding encryption, visit the [Support page](/support). diff --git a/docs/security/index.mdx b/docs/security/index.mdx new file mode 100644 index 0000000000..a9034c4cea --- /dev/null +++ b/docs/security/index.mdx @@ -0,0 +1,76 @@ +--- +title: 'Security & Compliance' +--- + +**Prowler secures itself with Prowler.** As an open-source cloud security platform trusted by thousands of organizations, Prowler applies the same rigorous security standards internally that customers achieve externally. + +All security tooling, configurations, and CI/CD pipelines are publicly available in the [Prowler GitHub repository](https://github.com/prowler-cloud/prowler). Transparency is fundamental to open-source security. + +## Software Security + +All Prowler code goes through the same security pipeline, whether running on Prowler Cloud or self-managed infrastructure: DAST, SAST, SCA, container scanning, and secrets detection on every build. + + + Security tools and practices applied to all Prowler code. + + +## Prowler Cloud vs Self-Managed + +| | Prowler Cloud | Self-Managed | +|--|---------------|--------------| +| **Deployment** | Fully managed SaaS | Own infrastructure | +| **Region** | EU (Ireland) | Any region or provider | +| **Compliance** | SOC 2 Type II, AWS FTR | Organization responsibility | +| **Data Control** | Prowler managed | Full control | +| **Encryption** | AES-256 at rest, TLS 1.2+ in transit | Configurable | +| **Backups** | Automated | Organization responsibility | +| **Updates** | Automatic | Manual | + + +Self-Managed includes Prowler App and Prowler CLI. They can run anywhere β€” any cloud provider, any region, on-premises, or air-gapped environments. Full control over data residency and infrastructure decisions. See the [Prowler App Installation Guide](/getting-started/installation/prowler-app) to get started. + + +--- + +## Prowler Cloud + +This section covers security and compliance for **Prowler Cloud**, the managed infrastructure. + +### Trust & Compliance + +Prowler Cloud holds compliance certifications and undergoes regular audits. + +| Certification | Status | +|---------------|--------| +| **SOC 2 Type II** | [View on Trust Portal](https://trust.prowler.com) | +| **AWS Foundational Technical Review (FTR)** | Passed β€” [Details](https://aws.amazon.com/partners/foundational-technical-review/) | + +Compliance data and reports: [trust.prowler.com](https://trust.prowler.com) + +### Security + + + + Data encrypted at rest (AES-256) and in transit (TLS 1.2+). + + + EU-hosted infrastructure with high availability and disaster recovery. + + + Static egress IPs for firewall allowlisting. + + + +### Privacy + +Prowler Cloud is GDPR compliant in regard to the ["right to be forgotten"](https://gdpr.eu/right-to-be-forgotten/). When an account is deleted, user information is removed from online and backup systems within 10 calendar days. + +--- + +## Report a Vulnerability + +Found a security issue? Report it through the [responsible disclosure](https://prowler.com/.well-known/security.txt) process. + +## Contact + +For security inquiries or general support, visit the [Support page](/support). diff --git a/docs/security/networking.mdx b/docs/security/networking.mdx new file mode 100644 index 0000000000..767e6ac4bf --- /dev/null +++ b/docs/security/networking.mdx @@ -0,0 +1,21 @@ +--- +title: 'Networking' +--- + +## Egress IP Addresses + +Prowler Cloud makes outbound API calls to scan cloud provider accounts and connect to integrations. Allowlist these IPs in firewalls or security groups to restrict access to Prowler Cloud only. + +| Region | IP Address | +|--------|------------| +| EU (Ireland) | `52.48.254.174` | + +Resolve the egress IP via DNS: + +```bash +dig egress.prowler.com +short +``` + +## Contact + +For questions about networking, visit the [Support page](/support). diff --git a/docs/security/software-security.mdx b/docs/security/software-security.mdx new file mode 100644 index 0000000000..4c690e988b --- /dev/null +++ b/docs/security/software-security.mdx @@ -0,0 +1,97 @@ +--- +title: 'Software Security' +--- + +Prowler follows a **security-by-design approach** throughout the software development lifecycle. All changes go through automated checks at every stage, from local development to production deployment. + +[Pre-commit](https://github.com/prowler-cloud/prowler/blob/master/.pre-commit-config.yaml) validations catch issues early, and [CI/CD pipelines](https://github.com/prowler-cloud/prowler/tree/master/.github) include multiple security gates ensuring code quality, secure configurations, and compliance with internal standards. + +Container registries are continuously scanned for vulnerabilities, with findings automatically reported to the security team for assessment and remediation. This process evolves alongside the stack as new languages, frameworks, and technologies are adopted, ensuring security practices remain comprehensive, proactive, and adaptable. + +## Static Application Security Testing (SAST) + +Multiple SAST tools are employed across the codebase to identify security vulnerabilities, code quality issues, and potential bugs during development. + +### CodeQL Analysis + +- **Scope:** UI (JavaScript/TypeScript), API (Python), and SDK (Python) +- **Frequency:** On every push and pull request, plus daily scheduled scans +- **Integration:** Results uploaded to GitHub Security tab via SARIF format +- **Purpose:** Identifies security vulnerabilities, coding errors, and potential exploits in source code + +### Python Security Scanners + +- **Bandit:** Detects common security issues in Python code (SQL injection, hardcoded passwords, etc.) + - Configured to ignore test files and report only high-severity issues + - Runs on both SDK and API codebases +- **Pylint:** Static code analysis with security-focused checks + - Integrated into pre-commit hooks and CI/CD pipelines + +### Code Quality & Dead Code Detection + +- **Vulture:** Identifies unused code that could indicate incomplete implementations or security gaps +- **Flake8:** Style guide enforcement with security-relevant checks +- **Shellcheck:** Security and correctness checks for shell scripts + +## Software Composition Analysis (SCA) + +Dependencies are continuously monitored for known vulnerabilities with timely updates ensured. + +### Dependency Vulnerability Scanning + +- **Safety:** Scans Python dependencies against known vulnerability databases + - Runs on every commit via pre-commit hooks + - Integrated into CI/CD for SDK and API + - Configured with selective ignores for tracked exceptions +- **Trivy:** Multi-purpose scanner for containers and dependencies + - Scans all container images (UI, API, SDK, MCP Server) + - Checks for vulnerabilities in OS packages and application dependencies + - Reports findings to GitHub Security tab + +### Automated Dependency Updates + +- **Dependabot:** Automated pull requests for dependency updates + - **Python (pip):** Monthly updates for SDK + - **GitHub Actions:** Monthly updates for workflow dependencies + - **Docker:** Monthly updates for base images + - Temporarily paused for API and UI to maintain stability during active development + - **Security-first approach:** Even when paused, Dependabot automatically creates pull requests for security vulnerabilities, ensuring critical security patches are never delayed + +## Container Security + +All container images are scanned before deployment. + +### Trivy Vulnerability Scanning + +- Scans images for vulnerabilities and misconfigurations +- Generates SARIF reports uploaded to GitHub Security tab +- Creates PR comments with scan summaries +- Configurable to fail builds on critical findings +- Reports include CVE counts and remediation guidance + +### Hadolint + +- Validates Dockerfile syntax and structure +- Ensures secure image building practices + +## Secrets Detection + +Prowler protects against accidental exposure of sensitive credentials. + +### TruffleHog + +- Scans entire codebase and Git history for secrets +- Runs on every push and pull request +- Pre-commit hook prevents committing secrets +- Detects high-entropy strings, API keys, tokens, and credentials +- Configured to report verified and unknown findings + +## Security Monitoring + +- **GitHub Security Tab:** Centralized view of all security findings from CodeQL, Trivy, and other SARIF-compatible tools +- **Artifact Retention:** Security scan reports retained for post-deployment analysis +- **PR Comments:** Automated security feedback on pull requests for rapid remediation + +## Contact + +For questions regarding software security, visit the [Support page](/support). diff --git a/docs/support.mdx b/docs/support.mdx new file mode 100644 index 0000000000..6999d5efbf --- /dev/null +++ b/docs/support.mdx @@ -0,0 +1,62 @@ +--- +title: 'Support' +description: 'Get help with Prowler' +--- + +## Lighthouse AI + +Lighthouse AI is a Cloud Security Analyst chatbot powered by [Prowler MCP](/getting-started/products/prowler-mcp), your 24/7 virtual cloud security analyst. It can: + +- **Query your security data**: Findings, compliance status, resources, and remediation guidance +- **Search Prowler Hub**: Over 1,000 security checks and 70+ compliance frameworks +- **Access documentation**: Search and retrieve Prowler docs contextually + +Available in Prowler Cloud and Prowler App. + +[Learn more about Lighthouse AI](/getting-started/products/prowler-lighthouse-ai) + +## Support Desk + +> Available to **Prowler Cloud** customers. + +For Prowler Cloud customers, submit support requests through our support desk. We'll route your request to the right team and respond via email. + + + Contact our support team + + +## GitHub Discussions + +Prowler is Open Source. If you have a question, it's likely someone else has it too. We'd love to answer in the open on GitHub whenever possible. + + + + Get help from the community + + + Found something wrong? Let us know + + + Share your ideas for improvements + + + +## Community Slack + +Join our Slack workspace to connect with the Prowler community, ask questions, and get help from other users and the Prowler team. + + + Connect with the community + + +## Office Hours + +Join our open calls to discuss what you're building, ask questions, and connect with the Prowler team and community. + +Office Hours sessions are announced on [LinkedIn](https://www.linkedin.com/company/prowler-security/). Recordings of previous sessions are available on [YouTube](https://www.youtube.com/playlist?list=PLIwvjRXuMGkE-BDYXmUR2TXYQ7agxtuB1). + +## Security + +To report a vulnerability or for security-related inquiries, contact [security@prowler.com](mailto:security@prowler.com). + +See also: [Responsible Disclosure](https://prowler.com/.well-known/security.txt) diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 73f9ff3999..e5039bff21 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -66,6 +66,11 @@ The following list includes all the AWS checks with configurable variables that | `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer | | `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings | | `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean | +| `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings | +| `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings | +| `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings | +| `s3_bucket_cross_account_access` | `trusted_account_ids` | List of Strings | +| `ssm_documents_set_as_public` | `trusted_account_ids` | List of Strings | | `vpc_endpoint_connections_trust_boundaries` | `trusted_account_ids` | List of Strings | | `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings | @@ -202,7 +207,10 @@ aws: ] # AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries) - # AWS SSM Configuration (aws.ssm_documents_set_as_public) + # AWS SSM Configuration (ssm_documents_set_as_public) + # AWS S3 Configuration (s3_bucket_cross_account_access) + # AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access) + # AWS DynamoDB Configuration (dynamodb_table_cross_account_access) # Single account environment: No action required. The AWS account number will be automatically added by the checks. # Multi account environment: Any additional trusted account number should be added as a space separated list, e.g. # trusted_account_ids : ["123456789012", "098765432109", "678901234567"] diff --git a/docs/user-guide/providers/azure/authentication.mdx b/docs/user-guide/providers/azure/authentication.mdx index 57746e2d60..852e79d115 100644 --- a/docs/user-guide/providers/azure/authentication.mdx +++ b/docs/user-guide/providers/azure/authentication.mdx @@ -27,9 +27,9 @@ These permissions allow Prowler to retrieve metadata from the assumed identity a Assign the following Microsoft Graph permissions: +- `AuditLog.Read.All` - `Directory.Read.All` - `Policy.Read.All` -- `UserAuthenticationMethod.Read.All` (optional, for multifactor authentication (MFA) checks) Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permissions. Note that Entra checks related to DirectoryRoles and GetUsers will not run with this permission. @@ -48,21 +48,22 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss 3. Search and select: + - `AuditLog.Read.All` - `Directory.Read.All` - `Policy.Read.All` - - `UserAuthenticationMethod.Read.All` ![Permission Screenshots](/images/providers/domain-permission.png) 4. Click "Add permissions", then grant admin consent ![Grant Admin Consent](/images/providers/grant-admin-consent.png) + ![Granted Admin Consent](/images/providers/granted-admin-consent.png) 1. To grant permissions to a Service Principal, execute the following command in a terminal: ```console - az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role 38d9df27-64da-44fd-b7c5-a6fbac20248f=Role + az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role b0afded3-3588-46d8-8b3d-9842eff778da=Role ``` @@ -82,17 +83,17 @@ By default, Prowler scans all accessible subscriptions. If you need to audit spe 1. To grant Prowler access to scan a specific Azure subscription, follow these steps in Azure Portal: Navigate to the subscription you want to audit with Prowler. - 1. In the left menu, select "Access control (IAM)". + 2. In the left menu, select "Access control (IAM)". - 2. Click "+ Add" and select "Add role assignment". + 3. Click "+ Add" and select "Add role assignment". - 3. In the search bar, enter `Reader`, select it and click "Next". + 4. In the search bar, enter `Reader`, select it and click "Next". - 4. In the "Members" tab, click "+ Select members", then add the accounts to assign this role. + 5. In the "Members" tab, click "+ Select members", then add the accounts to assign this role. - 5. Click "Review + assign" to finalize and apply the role assignment. + 6. Click "Review + assign" to finalize and apply the role assignment. - ![Adding the Reader Role to a Subscription](/images/providers/add-reader-role.gif) + ![Adding the Reader Role to a Subscription](/images/providers/add-reader-role.png) 1. Open a terminal and execute the following command to assign the `Reader` role to the identity that is going to be assumed by Prowler: @@ -375,7 +376,7 @@ The ProwlerRole is a custom role required for specific security checks. First, c #### Step 4: (Optional) Assign Microsoft Graph Permissions -For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and optionally `UserAuthenticationMethod.Read.All`. +For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and `AuditLog.Read.All`. Assigning Microsoft Graph API permissions to a Managed Identity requires Azure CLI or PowerShell - it cannot be done through the Azure Portal's standard role assignment interface. diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx index e32258eda3..d3c916750e 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -2,6 +2,10 @@ title: 'Getting Started with Cloudflare' --- +import { VersionBadge } from "/snippets/version-badge.mdx"; + + + Prowler for Cloudflare allows you to scan your Cloudflare zones for security misconfigurations, including SSL/TLS settings, DNSSEC, HSTS, and more. ## Prerequisites @@ -83,6 +87,30 @@ You can also use zone IDs instead of domain names: prowler cloudflare -f 023e105f4ecef8ad9ca31a8372d0c353 ``` +## Filtering Accounts + +By default, Prowler scans all accounts accessible with your credentials. If your API Token or API Key has access to multiple Cloudflare accounts, you can restrict the scan to specific accounts using the `--account-id` argument: + +```bash +prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 +``` + +You can specify multiple account IDs: + +```bash +prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 9a7806061c88ada191ed06f989cc3dac +``` + + +If any of the provided account IDs are not found among the accounts accessible with your credentials, Prowler will raise an error and stop execution. + + +You can combine account and zone filtering to narrow the scan scope further: + +```bash +prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 -f example.com +``` + ## Configuration Prowler uses a configuration file to customize provider behavior. The Cloudflare configuration includes: diff --git a/mcp_server/AGENTS.md b/mcp_server/AGENTS.md index bbefa2db57..c8f77bd4b1 100644 --- a/mcp_server/AGENTS.md +++ b/mcp_server/AGENTS.md @@ -1,6 +1,20 @@ # Prowler MCP Server - AI Agent Ruleset -> **Skills Reference**: For detailed patterns, use the [`prowler-mcp`](../skills/prowler-mcp/SKILL.md) skill. +> **Skills Reference**: See [`prowler-mcp`](../skills/prowler-mcp/SKILL.md) + +### 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` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating a git commit | `prowler-commit` | +| Review changelog format and conventions | `prowler-changelog` | +| Update CHANGELOG.md in any component | `prowler-changelog` | +| Working on MCP server tools | `prowler-mcp` | ## Project Overview diff --git a/poetry.lock b/poetry.lock index a04d0f80a9..f60fce585e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "about-time" @@ -741,7 +741,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -912,19 +912,18 @@ files = [ [[package]] name = "azure-core" -version = "1.35.0" +version = "1.38.0" description = "Microsoft Azure Core Library for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1"}, - {file = "azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c"}, + {file = "azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335"}, + {file = "azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993"}, ] [package.dependencies] requests = ">=2.21.0" -six = ">=1.11.0" typing-extensions = ">=4.6.0" [package.extras] @@ -1512,46 +1511,46 @@ files = [ [[package]] name = "boto3" -version = "1.39.15" +version = "1.40.61" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "boto3-1.39.15-py3-none-any.whl", hash = "sha256:38fc54576b925af0075636752de9974e172c8a2cf7133400e3e09b150d20fb6a"}, - {file = "boto3-1.39.15.tar.gz", hash = "sha256:b4483625f0d8c35045254dee46cd3c851bbc0450814f20b9b25bee1b5c0d8409"}, + {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, + {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, ] [package.dependencies] -botocore = ">=1.39.15,<1.40.0" +botocore = ">=1.40.61,<1.41.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.13.0,<0.14.0" +s3transfer = ">=0.14.0,<0.15.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.39.15" +version = "1.40.61" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "botocore-1.39.15-py3-none-any.whl", hash = "sha256:eb9cfe918ebfbfb8654e1b153b29f0c129d586d2c0d7fb4032731d49baf04cff"}, - {file = "botocore-1.39.15.tar.gz", hash = "sha256:2aa29a717f14f8c7ca058c2e297aaed0aa10ecea24b91514eee802814d1b7600"}, + {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"}, + {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"}, ] [package.dependencies] jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" urllib3 = [ - {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, + {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, ] [package.extras] -crt = ["awscrt (==0.23.8)"] +crt = ["awscrt (==0.27.6)"] [[package]] name = "cachetools" @@ -2378,20 +2377,29 @@ testing = ["hatch", "pre-commit", "pytest", "tox"] [[package]] name = "filelock" -version = "3.12.4" +version = "3.19.1" description = "A platform independent file lock." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] +markers = "python_version < \"3.10\"" files = [ - {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, - {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, + {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, + {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, ] -[package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] -typing = ["typing-extensions (>=4.7.1) ; python_version < \"3.11\""] +[[package]] +name = "filelock" +version = "3.20.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, +] [[package]] name = "flake8" @@ -2706,7 +2714,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -2746,7 +2754,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -2783,7 +2791,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -2964,6 +2972,18 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "joblib" +version = "1.5.3" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, + {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, +] + [[package]] name = "joserfc" version = "1.2.2" @@ -3927,6 +3947,32 @@ extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.1 test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] test-extras = ["pytest-mpl", "pytest-randomly"] +[[package]] +name = "nltk" +version = "3.9.2" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"}, + {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + [[package]] name = "nodeenv" version = "1.9.1" @@ -4489,36 +4535,6 @@ files = [ {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, ] -[[package]] -name = "psutil" -version = "6.0.0" -description = "Cross-platform lib for process and system monitoring in Python." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -groups = ["dev"] -files = [ - {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, - {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, - {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, - {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, - {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, - {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, - {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, - {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, - {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, - {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, - {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, - {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, - {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, - {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, - {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, - {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, - {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, -] - -[package.extras] -test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""] - [[package]] name = "py-iam-expand" version = "0.1.0" @@ -4568,14 +4584,14 @@ dev = ["black (==22.6.0)", "flake8", "mypy", "pytest"] [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.2" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, + {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, + {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, ] [[package]] @@ -4612,7 +4628,7 @@ description = "C parser in Python" optional = false python-versions = ">=3.8" groups = ["main", "dev"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -5613,6 +5629,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, @@ -5621,6 +5638,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, @@ -5629,6 +5647,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, @@ -5637,6 +5656,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, @@ -5645,6 +5665,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, @@ -5652,14 +5673,14 @@ files = [ [[package]] name = "s3transfer" -version = "0.13.1" +version = "0.14.0" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"}, - {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"}, + {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, + {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, ] [package.dependencies] @@ -5670,34 +5691,35 @@ crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "safety" -version = "3.2.9" -description = "Checks installed dependencies for known vulnerabilities and licenses." +version = "3.7.0" +description = "Scan dependencies for known vulnerabilities and licenses." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "safety-3.2.9-py3-none-any.whl", hash = "sha256:5e199c057550dc6146c081084274279dfb98c17735193b028db09a55ea508f1a"}, - {file = "safety-3.2.9.tar.gz", hash = "sha256:494bea752366161ac9e0742033d2a82e4dc51d7c788be42e0ecf5f3ef36b8071"}, + {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"}, + {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"}, ] [package.dependencies] -Authlib = ">=1.2.0" -Click = ">=8.0.2" -dparse = ">=0.6.4b0" -filelock = ">=3.12.2,<3.13.0" +authlib = ">=1.2.0" +click = ">=8.0.2" +dparse = ">=0.6.4" +filelock = ">=3.16.1,<4.0" +httpx = "*" jinja2 = ">=3.1.0" marshmallow = ">=3.15.0" +nltk = ">=3.9" packaging = ">=21.0" -psutil = ">=6.0.0,<6.1.0" -pydantic = ">=1.10.12" +pydantic = ">=2.6.0" requests = "*" -rich = "*" -"ruamel.yaml" = ">=0.17.21" -safety-schemas = ">=0.0.4" -setuptools = ">=65.5.1" -typer = "*" +ruamel-yaml = ">=0.17.21" +safety-schemas = "0.0.16" +tenacity = ">=8.1.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} +tomlkit = "*" +typer = ">=0.16.0" typing-extensions = ">=4.7.1" -urllib3 = ">=1.26.5" [package.extras] github = ["pygithub (>=1.43.3)"] @@ -5706,20 +5728,20 @@ spdx = ["spdx-tools (>=0.8.2)"] [[package]] name = "safety-schemas" -version = "0.0.5" +version = "0.0.16" description = "Schemas for Safety tools" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "safety_schemas-0.0.5-py3-none-any.whl", hash = "sha256:6ac9eb71e60f0d4e944597c01dd48d6d8cd3d467c94da4aba3702a05a3a6ab4f"}, - {file = "safety_schemas-0.0.5.tar.gz", hash = "sha256:0de5fc9a53d4423644a8ce9a17a2e474714aa27e57f3506146e95a41710ff104"}, + {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"}, + {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"}, ] [package.dependencies] -dparse = ">=0.6.4b0" +dparse = ">=0.6.4" packaging = ">=21.0" -pydantic = "*" +pydantic = ">=2.6.0" ruamel-yaml = ">=0.17.21" typing-extensions = ">=4.7.1" @@ -5804,18 +5826,18 @@ files = [ [[package]] name = "slack-sdk" -version = "3.34.0" +version = "3.39.0" description = "The Slack API Platform SDK for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa"}, - {file = "slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d"}, + {file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"}, + {file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"}, ] [package.extras] -optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<15)"] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"] [[package]] name = "sniffio" @@ -5823,7 +5845,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -5889,6 +5911,22 @@ files = [ [package.extras] widechars = ["wcwidth"] +[[package]] +name = "tenacity" +version = "9.1.2" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tldextract" version = "5.3.0" @@ -5966,6 +6004,28 @@ files = [ {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, ] +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + [[package]] name = "typer" version = "0.16.0" @@ -6553,4 +6613,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.9.1,<3.13" -content-hash = "841d93c4db73d37dbc83d2b252bb022917974dc44fc4cca6eb60c41288ad49d9" +content-hash = "adfc2da2c6e3e803f7a151b9697dbc3f461366a03e4504eb97498cbc72b2e48c" diff --git a/prowler/AGENTS.md b/prowler/AGENTS.md index 3b43e1c5d0..4667ba8a28 100644 --- a/prowler/AGENTS.md +++ b/prowler/AGENTS.md @@ -13,13 +13,17 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Action | Skill | |--------|-------| +| Add changelog entry for a PR or feature | `prowler-changelog` | | Adding new providers | `prowler-provider` | | Adding services to existing providers | `prowler-provider` | +| Create PR that requires changelog entry | `prowler-changelog` | | Creating new checks | `prowler-sdk-check` | | Creating/updating compliance frameworks | `prowler-compliance` | | Mapping checks to compliance controls | `prowler-compliance` | | Mocking AWS with moto in tests | `prowler-test-sdk` | +| Review changelog format and conventions | `prowler-changelog` | | Reviewing compliance framework PRs | `prowler-compliance-review` | +| Update CHANGELOG.md in any component | `prowler-changelog` | | Updating existing checks and metadata | `prowler-sdk-check` | | Writing Prowler SDK tests | `prowler-test-sdk` | | Writing Python tests with pytest | `pytest` | @@ -28,7 +32,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: ## Project Overview -The Prowler SDK is the core Python engine powering cloud security assessments across AWS, Azure, GCP, Kubernetes, GitHub, M365, and more. It includes 1000+ security checks and 30+ compliance frameworks. +The Prowler SDK is the core Python engine powering cloud security assessments across AWS, Azure, GCP, Kubernetes, GitHub, M365, and more. It includes 1100+ security checks and 85+ compliance frameworks. --- diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 49112dbd4e..b583a67c30 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,11 +2,41 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.17.0] (Prowler UNRELEASED) +## [5.18.0] (Prowler UNRELEASED) + +### πŸš€ Added + +- `defender_zap_for_teams_enabled` check for M365 provider [(#9838)](https://github.com/prowler-cloud/prowler/pull/9838) +- `compute_instance_suspended_without_persistent_disks` check for GCP provider [(#9747)](https://github.com/prowler-cloud/prowler/pull/9747) +- `codebuild_project_webhook_filters_use_anchored_patterns` check for AWS provider to detect CodeBreach vulnerability [(#9840)](https://github.com/prowler-cloud/prowler/pull/9840) +- `exchange_shared_mailbox_sign_in_disabled` check for M365 provider [(#9828)](https://github.com/prowler-cloud/prowler/pull/9828) +- CloudTrail Timeline abstraction for querying resource modification history [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101) +- Cloudflare `--account-id` filter argument [(#9894)](https://github.com/prowler-cloud/prowler/pull/9894) +- `rds_instance_extended_support` check for AWS provider [(#9865)](https://github.com/prowler-cloud/prowler/pull/9865) + + +### Changed + +- Update Azure App Service service metadata to new format [(#9613)](https://github.com/prowler-cloud/prowler/pull/9613) +- Update Azure Application Insights service metadata to new format [(#9614)](https://github.com/prowler-cloud/prowler/pull/9614) +- Update Azure Container Registry service metadata to new format [(#9615)](https://github.com/prowler-cloud/prowler/pull/9615) +- Update Azure Cosmos DB service metadata to new format [(#9616)](https://github.com/prowler-cloud/prowler/pull/9616) +- Update Azure Databricks service metadata to new format [(#9617)](https://github.com/prowler-cloud/prowler/pull/9617) +- Parallelize Azure Key Vault vaults and vaults contents retrieval to improve performance [(#9876)](https://github.com/prowler-cloud/prowler/pull/9876) +- Update Azure IAM service metadata to new format [(#9620)](https://github.com/prowler-cloud/prowler/pull/9620) +- Update Azure Policy service metadata to new format [(#9625)](https://github.com/prowler-cloud/prowler/pull/9625) +- Update Azure MySQL service metadata to new format [(#9623)](https://github.com/prowler-cloud/prowler/pull/9623) +- Update Azure Defender service metadata to new format [(#9618)](https://github.com/prowler-cloud/prowler/pull/9618) +- Make AWS cross-account checks configurable through `trusted_account_ids` config parameter [(#9692)](https://github.com/prowler-cloud/prowler/pull/9692) + +--- + +## [5.17.0] (Prowler v5.17.0) ### Added + - AI Skills pack for AI coding assistants (Claude Code, OpenCode, Codex) following agentskills.io standard [(#9728)](https://github.com/prowler-cloud/prowler/pull/9728) -- Add Prowler ThreatScore for the Alibaba Cloud provider [(#9511)](https://github.com/prowler-cloud/prowler/pull/9511) +- Prowler ThreatScore for the Alibaba Cloud provider [(#9511)](https://github.com/prowler-cloud/prowler/pull/9511) - `compute_instance_group_multiple_zones` check for GCP provider [(#9566)](https://github.com/prowler-cloud/prowler/pull/9566) - `compute_instance_group_autohealing_enabled` check for GCP provider [(#9690)](https://github.com/prowler-cloud/prowler/pull/9690) - Support AWS European Sovereign Cloud [(#9649)](https://github.com/prowler-cloud/prowler/pull/9649) @@ -16,13 +46,20 @@ All notable changes to the **Prowler SDK** are documented in this file. - `compute_configuration_changes` check for GCP provider to detect Compute Engine configuration changes in Cloud Audit Logs [(#9698)](https://github.com/prowler-cloud/prowler/pull/9698) - `compute_instance_group_load_balancer_attached` check for GCP provider [(#9695)](https://github.com/prowler-cloud/prowler/pull/9695) - `Cloudflare` provider with critical security checks [(#9423)](https://github.com/prowler-cloud/prowler/pull/9423) +- CloudFlare `TLS/SSL`, `records` and `email` checks for `zone` service [(#9424)](https://github.com/prowler-cloud/prowler/pull/9424) - `compute_instance_single_network_interface` check for GCP provider [(#9702)](https://github.com/prowler-cloud/prowler/pull/9702) - `compute_image_not_publicly_shared` check for GCP provider [(#9718)](https://github.com/prowler-cloud/prowler/pull/9718) +- `compute_snapshot_not_outdated` check for GCP provider [(#9774)](https://github.com/prowler-cloud/prowler/pull/9774) +- `compute_project_os_login_2fa_enabled` check for GCP provider [(#9839)](https://github.com/prowler-cloud/prowler/pull/9839) +- `compute_instance_on_host_maintenance_migrate` check for GCP provider [(#9834)](https://github.com/prowler-cloud/prowler/pull/9834) - CIS 1.12 compliance framework for Kubernetes [(#9778)](https://github.com/prowler-cloud/prowler/pull/9778) - CIS 6.0 for M365 provider [(#9779)](https://github.com/prowler-cloud/prowler/pull/9779) - CIS 5.0 compliance framework for the Azure provider [(#9777)](https://github.com/prowler-cloud/prowler/pull/9777) +- `Cloudflare` Bot protection, WAF, Privacy, Anti-Scraping and Zone configuration checks [(#9425)](https://github.com/prowler-cloud/prowler/pull/9425) +- `Cloudflare` `waf` and `dns record` checks [(#9426)](https://github.com/prowler-cloud/prowler/pull/9426) ### Changed + - Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432) - Update AWS Route 53 service metadata to new format [(#9406)](https://github.com/prowler-cloud/prowler/pull/9406) - Update AWS SQS service metadata to new format [(#9429)](https://github.com/prowler-cloud/prowler/pull/9429) @@ -48,21 +85,28 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS RDS service metadata to new format [(#9551)](https://github.com/prowler-cloud/prowler/pull/9551) - Update AWS Bedrock service metadata to new format [(#8827)](https://github.com/prowler-cloud/prowler/pull/8827) - Update AWS IAM service metadata to new format [(#9550)](https://github.com/prowler-cloud/prowler/pull/9550) +- Enhance `user_registration_details` perfomance and user `mfa` evaluation [(#9236)](https://github.com/prowler-cloud/prowler/pull/9236) - Update AWS Cognito service metadata to new format [(#8853)](https://github.com/prowler-cloud/prowler/pull/8853) - ---- - -## [5.16.2] (Prowler v5.16.2) (UNRELEASED) +- Update AWS EC2 service metadata to new format [(#9549)](https://github.com/prowler-cloud/prowler/pull/9549) +- Update Azure AI Search service metadata to new format [(#9087)](https://github.com/prowler-cloud/prowler/pull/9087) +- Update Azure AKS service metadata to new format [(#9611)](https://github.com/prowler-cloud/prowler/pull/9611) +- Update Azure API Management service metadata to new format [(#9612)](https://github.com/prowler-cloud/prowler/pull/9612) ### Fixed -- Fix OCI authentication error handling and validation [(#9738)](https://github.com/prowler-cloud/prowler/pull/9738) -- Fixup AWS EC2 SG library [(#9216)](https://github.com/prowler-cloud/prowler/pull/9216) + +- OCI authentication error handling and validation [(#9738)](https://github.com/prowler-cloud/prowler/pull/9738) +- AWS EC2 SG library [(#9216)](https://github.com/prowler-cloud/prowler/pull/9216) + +### Security +- `safety` to `3.7.0` and `filelock` to `3.20.3` due to [Safety vulnerability 82754 (CVE-2025-68146)](https://data.safetycli.com/v/82754/97c/) [(#9816)](https://github.com/prowler-cloud/prowler/pull/9816) +- `pyasn1` to v0.6.2 to address [CVE-2026-23490](https://nvd.nist.gov/vuln/detail/CVE-2026-23490) [(#9817)](https://github.com/prowler-cloud/prowler/pull/9817) --- ## [5.16.1] (Prowler v5.16.1) ### Fixed + - ZeroDivision error from Prowler ThreatScore [(#9653)](https://github.com/prowler-cloud/prowler/pull/9653) --- @@ -70,10 +114,12 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.16.0] (Prowler v5.16.0) ### Added + - `privilege-escalation` and `ec2-imdsv1` categories for AWS checks [(#9537)](https://github.com/prowler-cloud/prowler/pull/9537) - Supported IaC formats and scanner documentation for the IaC provider [(#9553)](https://github.com/prowler-cloud/prowler/pull/9553) ### Changed + - Update AWS Glue service metadata to new format [(#9258)](https://github.com/prowler-cloud/prowler/pull/9258) - Update AWS Kafka service metadata to new format [(#9261)](https://github.com/prowler-cloud/prowler/pull/9261) - Update AWS KMS service metadata to new format [(#9263)](https://github.com/prowler-cloud/prowler/pull/9263) @@ -86,6 +132,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS WAF v2 service metadata to new format [(#9481)](https://github.com/prowler-cloud/prowler/pull/9481) ### Fixed + - Fix typo `trustboundaries` category to `trust-boundaries` [(#9536)](https://github.com/prowler-cloud/prowler/pull/9536) - Fix incorrect `bedrock-agent` regional availability, now using official AWS docs instead of copying from `bedrock` - Store MongoDB Atlas provider regions as lowercase [(#9554)](https://github.com/prowler-cloud/prowler/pull/9554) @@ -96,6 +143,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.15.1] (Prowler v5.15.1) ### Fixed + - Fix false negative in AWS `apigateway_restapi_logging_enabled` check by refining stage logging evaluation to ensure logging level is not set to "OFF" [(#9304)](https://github.com/prowler-cloud/prowler/pull/9304) --- @@ -103,6 +151,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.15.0] (Prowler v5.15.0) ### Added + - `cloudstorage_uses_vpc_service_controls` check for GCP provider [(#9256)](https://github.com/prowler-cloud/prowler/pull/9256) - Alibaba Cloud provider with CIS 2.0 benchmark [(#9329)](https://github.com/prowler-cloud/prowler/pull/9329) - `repository_immutable_releases_enabled` check for GitHub provider [(#9162)](https://github.com/prowler-cloud/prowler/pull/9162) @@ -116,6 +165,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - RBI Cyber Security Framework compliance for Azure provider [(#8822)](https://github.com/prowler-cloud/prowler/pull/8822) ### Changed + - Update AWS Macie service metadata to new format [(#9265)](https://github.com/prowler-cloud/prowler/pull/9265) - Update AWS Lightsail service metadata to new format [(#9264)](https://github.com/prowler-cloud/prowler/pull/9264) - Update AWS GuardDuty service metadata to new format [(#9259)](https://github.com/prowler-cloud/prowler/pull/9259) @@ -125,6 +175,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS Lightsail service metadata to new format [(#9264)](https://github.com/prowler-cloud/prowler/pull/9264) ### Fixed + - Fix duplicate requirement IDs in ISO 27001:2013 AWS compliance framework by adding unique letter suffixes - Removed incorrect threat-detection category from checks metadata [(#9489)](https://github.com/prowler-cloud/prowler/pull/9489) - GCP `cloudstorage_uses_vpc_service_controls` check to handle VPC Service Controls blocked API access [(#9478)](https://github.com/prowler-cloud/prowler/pull/9478) @@ -134,6 +185,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.14.2] (Prowler v5.14.2) ### Fixed + - Custom check folder metadata validation [(#9335)](https://github.com/prowler-cloud/prowler/pull/9335) - Pin `alibabacloud-gateway-oss-util` to version 0.0.3 to address missing dependency [(#9487)](https://github.com/prowler-cloud/prowler/pull/9487) @@ -142,6 +194,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.14.1] (Prowler v5.14.1) ### Fixed + - `sharepoint_external_sharing_managed` check to handle external sharing disabled at organization level [(#9298)](https://github.com/prowler-cloud/prowler/pull/9298) - Support multiple Exchange mailbox policies in M365 `exchange_mailbox_policy_additional_storage_restricted` check [(#9241)](https://github.com/prowler-cloud/prowler/pull/9241) @@ -150,6 +203,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.14.0] (Prowler v5.14.0) ### Added + - GitHub provider check `organization_default_repository_permission_strict` [(#8785)](https://github.com/prowler-cloud/prowler/pull/8785) - Add OCI mapping to scan and check classes [(#8927)](https://github.com/prowler-cloud/prowler/pull/8927) - `codepipeline_project_repo_private` check for AWS provider [(#5915)](https://github.com/prowler-cloud/prowler/pull/5915) @@ -175,6 +229,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add branch name to IaC provider region [(#9296)](https://github.com/prowler-cloud/prowler/pull/9295) ### Changed + - Update AWS Direct Connect service metadata to new format [(#8855)](https://github.com/prowler-cloud/prowler/pull/8855) - Update AWS DRS service metadata to new format [(#8870)](https://github.com/prowler-cloud/prowler/pull/8870) - Update AWS DynamoDB service metadata to new format [(#8871)](https://github.com/prowler-cloud/prowler/pull/8871) @@ -208,9 +263,10 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS ECS service metadata to new format [(#8888)](https://github.com/prowler-cloud/prowler/pull/8888) - Update AWS Kinesis service metadata to new format [(#9262)](https://github.com/prowler-cloud/prowler/pull/9262) - Update AWS DocumentDB service metadata to new format [(#8862)](https://github.com/prowler-cloud/prowler/pull/8862) - +- Adapt IaC provider to be used in the Prowler App [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751) ### Fixed + - Check `check_name` has no `resource_name` error for GCP provider [(#9169)](https://github.com/prowler-cloud/prowler/pull/9169) - Depth Truncation and parsing error in PowerShell queries [(#9181)](https://github.com/prowler-cloud/prowler/pull/9181) - False negative in `iam_role_cross_service_confused_deputy_prevention` check [(#9213)](https://github.com/prowler-cloud/prowler/pull/9213) @@ -228,6 +284,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.13.1] (Prowler v5.13.1) ### Fixed + - Add `resource_name` for checks under `logging` for the GCP provider [(#9023)](https://github.com/prowler-cloud/prowler/pull/9023) - Fix `ec2_instance_with_outdated_ami` check to handle None AMIs [(#9046)](https://github.com/prowler-cloud/prowler/pull/9046) - Handle timestamp when transforming compliance findings in CCC [(#9042)](https://github.com/prowler-cloud/prowler/pull/9042) @@ -236,14 +293,10 @@ All notable changes to the **Prowler SDK** are documented in this file. --- -### Changed -- Adapt IaC provider to be used in the Prowler App [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751) - ---- - ## [5.13.0] (Prowler v5.13.0) ### Added + - Support for AdditionalURLs in outputs [(#8651)](https://github.com/prowler-cloud/prowler/pull/8651) - Support for markdown metadata fields in Dashboard [(#8667)](https://github.com/prowler-cloud/prowler/pull/8667) - `ec2_instance_with_outdated_ami` check for AWS provider [(#6910)](https://github.com/prowler-cloud/prowler/pull/6910) @@ -286,6 +339,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Fixed + - Fix SNS topics showing empty AWS_ResourceID in Quick Inventory output [(#8762)](https://github.com/prowler-cloud/prowler/issues/8762) - Fix HTML Markdown output for long strings [(#8803)](https://github.com/prowler-cloud/prowler/pull/8803) - Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) @@ -302,6 +356,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.12.1] (Prowler v5.12.1) ### Fixed + - Replaced old check id with new ones for compliance files [(#8682)](https://github.com/prowler-cloud/prowler/pull/8682) - `firehose_stream_encrypted_at_rest` check false positives and new api call in kafka service [(#8599)](https://github.com/prowler-cloud/prowler/pull/8599) - Replace defender rules policies key to use old name [(#8702)](https://github.com/prowler-cloud/prowler/pull/8702) @@ -311,6 +366,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.12.0] (Prowler v5.12.0) ### Added + - Add more fields for the Jira ticket and handle custom fields errors [(#8601)](https://github.com/prowler-cloud/prowler/pull/8601) - Support labels on Jira tickets [(#8603)](https://github.com/prowler-cloud/prowler/pull/8603) - Add finding url and tenant info inside Jira tickets [(#8607)](https://github.com/prowler-cloud/prowler/pull/8607) @@ -334,9 +390,11 @@ All notable changes to the **Prowler SDK** are documented in this file. - `projects_network_access_list_exposed_to_internet` - Ensure project network access list is not exposed to internet ### Changed + - Rename ftp and mongo checks to follow pattern `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_*` [(#8293)](https://github.com/prowler-cloud/prowler/pull/8293) ### Fixed + - Renamed `AdditionalUrls` to `AdditionalURLs` field in CheckMetadata [(#8639)](https://github.com/prowler-cloud/prowler/pull/8639) - TypeError from Python 3.9 in Security Hub module by updating type annotations [(#8619)](https://github.com/prowler-cloud/prowler/pull/8619) - KeyError when SecurityGroups field is missing in MemoryDB check [(#8666)](https://github.com/prowler-cloud/prowler/pull/8666) @@ -347,6 +405,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.11.0] (Prowler v5.11.0) ### Added + - Certificate authentication for M365 provider [(#8404)](https://github.com/prowler-cloud/prowler/pull/8404) - `vm_sufficient_daily_backup_retention_period` check for Azure provider [(#8200)](https://github.com/prowler-cloud/prowler/pull/8200) - `vm_jit_access_enabled` check for Azure provider [(#8202)](https://github.com/prowler-cloud/prowler/pull/8202) @@ -361,10 +420,12 @@ All notable changes to the **Prowler SDK** are documented in this file. - GCP `--skip-api-check` command line flag [(#8575)](https://github.com/prowler-cloud/prowler/pull/8575) ### Changed + - Refine kisa isms-p compliance mapping [(#8479)](https://github.com/prowler-cloud/prowler/pull/8479) - Improve AWS Security Hub region check using multiple threads [(#8365)](https://github.com/prowler-cloud/prowler/pull/8365) ### Fixed + - Resource metadata error in `s3_bucket_shadow_resource_vulnerability` check [(#8572)](https://github.com/prowler-cloud/prowler/pull/8572) - GitHub App authentication through API fails with auth_method validation error [(#8587)](https://github.com/prowler-cloud/prowler/pull/8587) - AWS resource-arn filtering [(#8533)](https://github.com/prowler-cloud/prowler/pull/8533) @@ -378,6 +439,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.10.2] (Prowler v5.10.2) ### Fixed + - Order requirements by ID in Prowler ThreatScore AWS compliance framework [(#8495)](https://github.com/prowler-cloud/prowler/pull/8495) - Add explicit resource name to GCP and Azure Defender checks [(#8352)](https://github.com/prowler-cloud/prowler/pull/8352) - Validation errors in Azure and M365 providers [(#8353)](https://github.com/prowler-cloud/prowler/pull/8353) @@ -392,6 +454,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.10.1] (Prowler v5.10.1) ### Fixed + - Remove invalid requirements from CIS 1.0 for GitHub provider [(#8472)](https://github.com/prowler-cloud/prowler/pull/8472) --- @@ -399,6 +462,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.10.0] (Prowler v5.10.0) ### Added + - `bedrock_api_key_no_administrative_privileges` check for AWS provider [(#8321)](https://github.com/prowler-cloud/prowler/pull/8321) - `bedrock_api_key_no_long_term_credentials` check for AWS provider [(#8396)](https://github.com/prowler-cloud/prowler/pull/8396) - Support App Key Content in GitHub provider [(#8271)](https://github.com/prowler-cloud/prowler/pull/8271) @@ -411,11 +475,13 @@ All notable changes to the **Prowler SDK** are documented in this file. - Use `trivy` as engine for IaC provider [(#8466)](https://github.com/prowler-cloud/prowler/pull/8466) ### Changed + - Handle some AWS errors as warnings instead of errors [(#8347)](https://github.com/prowler-cloud/prowler/pull/8347) - Revert import of `checkov` python library [(#8385)](https://github.com/prowler-cloud/prowler/pull/8385) - Updated policy mapping in ISMS-P compliance file for improved alignment [(#8367)](https://github.com/prowler-cloud/prowler/pull/8367) ### Fixed + - False positives in SQS encryption check for ephemeral queues [(#8330)](https://github.com/prowler-cloud/prowler/pull/8330) - Add protocol validation check in security group checks to ensure proper protocol matching [(#8374)](https://github.com/prowler-cloud/prowler/pull/8374) - Add missing audit evidence for controls 1.1.4 and 2.5.5 for ISMS-P compliance. [(#8386)](https://github.com/prowler-cloud/prowler/pull/8386) @@ -439,6 +505,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.9.2] (Prowler v5.9.2) ### Fixed + - Use the correct resource name in `defender_domain_dkim_enabled` check [(#8334)](https://github.com/prowler-cloud/prowler/pull/8334) --- @@ -446,6 +513,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.9.0] (Prowler v5.9.0) ### Added + - `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider [(#8123)](https://github.com/prowler-cloud/prowler/pull/8123) - `storage_smb_protocol_version_is_latest` check for Azure provider [(#8128)](https://github.com/prowler-cloud/prowler/pull/8128) - `vm_backup_enabled` check for Azure provider [(#8182)](https://github.com/prowler-cloud/prowler/pull/8182) @@ -458,9 +526,11 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add `test_connection` method to GitHub provider [(#8248)](https://github.com/prowler-cloud/prowler/pull/8248) ### Changed + - Refactor the Azure Defender get security contact configuration method to use the API REST endpoint instead of the SDK [(#8241)](https://github.com/prowler-cloud/prowler/pull/8241) ### Fixed + - Title & description wording for `iam_user_accesskey_unused` check for AWS provider [(#8233)](https://github.com/prowler-cloud/prowler/pull/8233) - Add GitHub provider to lateral panel in documentation and change -h environment variable output [(#8246)](https://github.com/prowler-cloud/prowler/pull/8246) - Show `m365_identity_type` and `m365_identity_id` in cloud reports [(#8247)](https://github.com/prowler-cloud/prowler/pull/8247) @@ -480,6 +550,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.8.1] (Prowler v5.8.1) ### Fixed + - Detect wildcarded ARNs in sts:AssumeRole policy resources [(#8164)](https://github.com/prowler-cloud/prowler/pull/8164) - List all streams and `firehose_stream_encrypted_at_rest` logic [(#8213)](https://github.com/prowler-cloud/prowler/pull/8213) - Allow empty values for http_endpoint in templates [(#8184)](https://github.com/prowler-cloud/prowler/pull/8184) @@ -532,6 +603,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - New check `codebuild_project_not_publicly_accessible` for AWS provider [(#8127)](https://github.com/prowler-cloud/prowler/pull/8127) ### Fixed + - Consolidate Azure Storage file service properties to the account level, improving the accuracy of the `storage_ensure_file_shares_soft_delete_is_enabled` check [(#8087)](https://github.com/prowler-cloud/prowler/pull/8087) - Migrate Azure VM service and managed disk logic to Pydantic models for better serialization and type safety, and update all related tests to use the new models and fix UUID handling [(#https://github.com/prowler-cloud/prowler/pull/8151)](https://github.com/prowler-cloud/prowler/pull/https://github.com/prowler-cloud/prowler/pull/8151) - `organizations_scp_check_deny_regions` check to pass when SCP policies have no statements [(#8091)](https://github.com/prowler-cloud/prowler/pull/8091) @@ -542,9 +614,11 @@ All notable changes to the **Prowler SDK** are documented in this file. - Handle empty name in Azure Defender and GCP checks [(#8120)](https://github.com/prowler-cloud/prowler/pull/8120) ### Changed + - Reworked `S3.test_connection` to match the AwsProvider logic [(#8088)](https://github.com/prowler-cloud/prowler/pull/8088) ### Removed + - OCSF version number references to point always to the latest [(#8064)](https://github.com/prowler-cloud/prowler/pull/8064) --- @@ -552,6 +626,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.7.5] (Prowler v5.7.5) ### Fixed + - Use unified timestamp for all requirements [(#8059)](https://github.com/prowler-cloud/prowler/pull/8059) - Add EKS to service without subservices [(#7959)](https://github.com/prowler-cloud/prowler/pull/7959) - `apiserver_strong_ciphers_only` check for K8S provider [(#7952)](https://github.com/prowler-cloud/prowler/pull/7952) @@ -570,6 +645,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.7.3] (Prowler v5.7.3) ### Fixed + - Automatically encrypt password in Microsoft365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) - Remove last encrypted password appearances [(#7825)](https://github.com/prowler-cloud/prowler/pull/7825) @@ -578,6 +654,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.7.2] (Prowler v5.7.2) ### Fixed + - `m365_powershell test_credentials` to use sanitized credentials [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) - `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779) - `m365_powershell` to close the PowerShell sessions in msgraph services [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816) @@ -590,6 +667,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.7.0] (Prowler v5.7.0) ### Added + - Update the compliance list supported for each provider from docs [(#7694)](https://github.com/prowler-cloud/prowler/pull/7694) - Allow setting cluster name in in-cluster mode in Kubernetes [(#7695)](https://github.com/prowler-cloud/prowler/pull/7695) - Prowler ThreatScore for M365 provider [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692) @@ -608,6 +686,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - CIS 5.0 compliance framework for AWS [(7766)](https://github.com/prowler-cloud/prowler/pull/7766) ### Fixed + - Update CIS 4.0 for M365 provider [(#7699)](https://github.com/prowler-cloud/prowler/pull/7699) - Update and upgrade CIS for all the providers [(#7738)](https://github.com/prowler-cloud/prowler/pull/7738) - Cover policies with conditions with SNS endpoint in `sns_topics_not_publicly_accessible` [(#7750)](https://github.com/prowler-cloud/prowler/pull/7750) @@ -618,6 +697,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.6.0] (Prowler v5.6.0) ### Added + - SOC2 compliance framework to Azure [(#7489)](https://github.com/prowler-cloud/prowler/pull/7489) - Check for unused Service Accounts in GCP [(#7419)](https://github.com/prowler-cloud/prowler/pull/7419) - Powershell to Microsoft365 [(#7331)](https://github.com/prowler-cloud/prowler/pull/7331) @@ -667,6 +747,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681) ### Fixed + - Package name location in pyproject.toml while replicating for prowler-cloud [(#7531)](https://github.com/prowler-cloud/prowler/pull/7531) - Remove cache in PyPI release action [(#7532)](https://github.com/prowler-cloud/prowler/pull/7532) - The correct values for logger.info inside iam service [(#7526)](https://github.com/prowler-cloud/prowler/pull/7526) @@ -687,6 +768,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.5.1] (Prowler v5.5.1) ### Fixed + - Default name to contacts in Azure Defender [(#7483)](https://github.com/prowler-cloud/prowler/pull/7483) - Handle projects without ID in GCP [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496) - Restore packages location in PyProject [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510) diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 96a7932baf..156c17e26d 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -121,7 +121,9 @@ { "Id": "1.2.2", "Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people.Users with permissions to the group mailbox can send as or send on behalf of the mailbox email address if the administrator has given that user permissions to do that. This is particularly useful for help and support mailboxes because users can send emails from \"Contoso Support\" or \"Building A Reception Desk.\"Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation.The recommended state is `Sign in blocked` for `Shared mailboxes`.", - "Checks": [], + "Checks": [ + "exchange_shared_mailbox_sign_in_disabled" + ], "Attributes": [ { "Section": "1 Microsoft 365 admin center", @@ -691,7 +693,9 @@ { "Id": "2.4.4", "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.", - "Checks": [], + "Checks": [ + "defender_zap_for_teams_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index ea97cf10e6..93fe2e2226 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -121,7 +121,9 @@ { "Id": "1.2.2", "Description": "Shared mailboxes are used when multiple people need access to the same mailbox, such as a company information or support email address, reception desk, or other function that might be shared by multiple people. Shared mailboxes are created with a corresponding user account using a system generated password that is unknown at the time of creation. The recommended state is Sign in blocked for Shared mailboxes.", - "Checks": [], + "Checks": [ + "exchange_shared_mailbox_sign_in_disabled" + ], "Attributes": [ { "Section": "1 Microsoft 365 admin center", @@ -753,7 +755,9 @@ { "Id": "2.4.4", "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes malware and high confidence phishing. When ZAP for Teams protection blocks a message, the message is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP occurs up to 48 hours after delivery.", - "Checks": [], + "Checks": [ + "defender_zap_for_teams_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index 5e5d39c241..81ae9dea2a 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -112,12 +112,13 @@ } ], "Checks": [ - "entra_identity_protection_sign_in_risk_enabled", - "entra_identity_protection_user_risk_enabled", + "defender_antiphishing_policy_configured", "defender_antispam_outbound_policy_configured", "defender_malware_policy_notifications_internal_users_malware_enabled", - "defender_antiphishing_policy_configured", - "entra_admin_users_phishing_resistant_mfa_enabled" + "defender_zap_for_teams_enabled", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled" ] }, { @@ -344,15 +345,15 @@ } ], "Checks": [ - "defender_antispam_outbound_policy_configured", - "defender_malware_policy_notifications_internal_users_malware_enabled", - "defender_malware_policy_common_attachments_filter_enabled", - "defender_malware_policy_comprehensive_attachments_filter_applied", "defender_antispam_connection_filter_policy_empty_ip_allowlist", "defender_antispam_connection_filter_policy_safe_list_off", "defender_antispam_outbound_policy_configured", "defender_antispam_outbound_policy_forwarding_disabled", - "defender_antispam_policy_inbound_no_allowed_domains" + "defender_antispam_policy_inbound_no_allowed_domains", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_zap_for_teams_enabled" ] }, { @@ -368,11 +369,11 @@ } ], "Checks": [ + "defender_antispam_outbound_policy_configured", "defender_malware_policy_common_attachments_filter_enabled", "defender_malware_policy_comprehensive_attachments_filter_applied", "defender_malware_policy_notifications_internal_users_malware_enabled", - "defender_antispam_outbound_policy_configured", - "defender_malware_policy_notifications_internal_users_malware_enabled" + "defender_zap_for_teams_enabled" ] }, { @@ -691,6 +692,7 @@ "defender_malware_policy_common_attachments_filter_enabled", "defender_malware_policy_comprehensive_attachments_filter_applied", "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_zap_for_teams_enabled", "teams_external_domains_restricted", "teams_external_users_cannot_start_conversations" ] diff --git a/prowler/config/cloudflare_mutelist_example.yaml b/prowler/config/cloudflare_mutelist_example.yaml index dc7bb4c91e..ad98637cd2 100644 --- a/prowler/config/cloudflare_mutelist_example.yaml +++ b/prowler/config/cloudflare_mutelist_example.yaml @@ -10,7 +10,7 @@ Mutelist: Accounts: "example-account-id": Checks: - "zones_dnssec_enabled": + "zone_dnssec_enabled": Regions: - "*" Resources: diff --git a/prowler/config/config.py b/prowler/config/config.py index a8ce986457..2b9a5547bf 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -38,7 +38,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.17.0" +prowler_version = "5.18.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/config/config.yaml b/prowler/config/config.yaml index ab5cce8ba9..4682afc7af 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -63,7 +63,10 @@ aws: fargate_windows_latest_version: "1.0.0" # AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries) - # AWS SSM Configuration (aws.ssm_documents_set_as_public) + # AWS SSM Configuration (ssm_documents_set_as_public) + # AWS S3 Configuration (s3_bucket_cross_account_access) + # AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access) + # AWS DynamoDB Configuration (dynamodb_table_cross_account_access) # Single account environment: No action required. The AWS account number will be automatically added by the checks. # Multi account environment: Any additional trusted account number should be added as a space separated list, e.g. # trusted_account_ids : ["123456789012", "098765432109", "678901234567"] @@ -510,6 +513,9 @@ gcp: # gcp.compute_instance_group_multiple_zones # Minimum number of zones a MIG should span for high availability mig_min_zones: 2 + # gcp.compute_snapshot_not_outdated + # Maximum age in days for disk snapshots before they are considered outdated + max_snapshot_age_days: 90 # GCP Service Account and user-managed keys unused configuration # gcp.iam_service_account_unused #Β gcp.iam_sa_user_managed_key_unused diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 44e0078754..6a8c050cbb 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -779,7 +779,10 @@ class CheckReportCloudflare(Check_Report): @property def zone_name(self) -> str: - """Zone name.""" + """Zone name - for DNS records use zone_name attribute, for zones use name.""" + zone_name = getattr(self._zone, "zone_name", None) + if zone_name: + return zone_name return getattr(self._zone, "name", "") @property @@ -792,7 +795,10 @@ class CheckReportCloudflare(Check_Report): @property def region(self) -> str: - """Cloudflare is a global service.""" + """Return zone_name as region for zone-scoped resources, otherwise global.""" + zone_name = getattr(self._zone, "zone_name", None) + if zone_name: + return zone_name return "global" diff --git a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/__init__.py b/prowler/lib/timeline/__init__.py similarity index 100% rename from prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/__init__.py rename to prowler/lib/timeline/__init__.py diff --git a/prowler/lib/timeline/models.py b/prowler/lib/timeline/models.py new file mode 100644 index 0000000000..cfd98284ba --- /dev/null +++ b/prowler/lib/timeline/models.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic.v1 import BaseModel + + +class TimelineEvent(BaseModel): + """A timeline event representing a resource modification. + + Provider-agnostic model that can be used by any timeline implementation + (AWS CloudTrail, Azure Activity Logs, GCP Audit Logs, etc.). + """ + + event_id: str + event_time: datetime + event_name: str + event_source: str + actor: str + actor_uid: Optional[str] = None + actor_type: Optional[str] = None + source_ip_address: Optional[str] = None + user_agent: Optional[str] = None + request_data: Optional[Dict[str, Any]] = None + response_data: Optional[Dict[str, Any]] = None + error_code: Optional[str] = None + error_message: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None diff --git a/prowler/lib/timeline/timeline.py b/prowler/lib/timeline/timeline.py new file mode 100644 index 0000000000..926a90f8ca --- /dev/null +++ b/prowler/lib/timeline/timeline.py @@ -0,0 +1,36 @@ +"""Abstract base class for timeline services.""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + + +class TimelineService(ABC): + """Abstract base class for provider-specific timeline implementations. + + Subclasses should implement the get_resource_timeline method to query + their provider's audit/activity log service (e.g., AWS CloudTrail, + Azure Activity Logs, GCP Audit Logs). + """ + + @abstractmethod + def get_resource_timeline( + self, + region: Optional[str] = None, + resource_id: Optional[str] = None, + resource_uid: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Get timeline events for a resource. + + Args: + region: Region/location where the resource exists. Implementations + may provide a sensible default for global/regionless resources. + resource_id: Provider-specific resource ID (e.g., bucket name, instance ID) + resource_uid: Provider-specific unique identifier (e.g., AWS ARN, Azure Resource ID) + + Returns: + List of timeline event dictionaries + + Raises: + ValueError: If neither resource_id nor resource_uid is provided + """ + raise NotImplementedError diff --git a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/__init__.py b/prowler/providers/aws/lib/cloudtrail_timeline/__init__.py similarity index 100% rename from prowler/providers/cloudflare/services/zones/zones_hsts_enabled/__init__.py rename to prowler/providers/aws/lib/cloudtrail_timeline/__init__.py diff --git a/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py new file mode 100644 index 0000000000..45dff270d0 --- /dev/null +++ b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py @@ -0,0 +1,218 @@ +"""CloudTrail timeline service for AWS. + +Queries AWS CloudTrail to retrieve timeline events for resources, +showing who performed actions and when. +""" + +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from botocore.exceptions import ClientError + +from prowler.lib.logger import logger +from prowler.lib.timeline.models import TimelineEvent +from prowler.lib.timeline.timeline import TimelineService + + +class CloudTrailTimeline(TimelineService): + """AWS CloudTrail implementation of TimelineService. + + Args: + session: boto3.Session for AWS API calls + lookback_days: Number of days to look back (default 90, max 90 for Event History) + max_results: Maximum number of events to return + write_events_only: If True, filter out read-only events (Describe*, Get*, List*, etc.) + """ + + MAX_LOOKBACK_DAYS = 90 + + DEFAULT_MAX_RESULTS = 50 # Default page size for CloudTrail queries + + # Prefixes for read-only API operations that don't modify resources + READ_ONLY_PREFIXES = ( + "Describe", + "Get", + "List", + "Head", + "Check", + "Lookup", + "Search", + "Scan", + "Query", + "BatchGet", + "Select", + ) + + def __init__( + self, + session, + lookback_days: int = 90, + max_results: Optional[int] = None, + write_events_only: bool = True, + ): + self._session = session + self._lookback_days = min(lookback_days, self.MAX_LOOKBACK_DAYS) + self._max_results = max_results or self.DEFAULT_MAX_RESULTS + self._write_events_only = write_events_only + self._clients: Dict[str, Any] = {} + + DEFAULT_REGION = "us-east-1" # Default for global resources in commercial partition + + def get_resource_timeline( + self, + region: Optional[str] = None, + resource_id: Optional[str] = None, + resource_uid: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Get CloudTrail timeline events for a resource. + + Args: + region: AWS region to query. Defaults to us-east-1 for global resources + (IAM, S3, Route53, etc.) in the commercial partition. Caller + should provide the correct region for regional resources. + resource_id: AWS resource ID (e.g., sg-1234567890abcdef0) + resource_uid: AWS resource ARN (unique identifier) + + Returns: + List of timeline event dictionaries + + Raises: + ValueError: If neither resource_id nor resource_uid is provided + ClientError: If AWS API call fails + """ + resource_identifier = resource_uid or resource_id + if not resource_identifier: + raise ValueError("Either resource_id or resource_uid must be provided") + + region = region or self.DEFAULT_REGION + + try: + raw_events = self._lookup_events(resource_identifier, region) + + events = [] + for raw_event in raw_events: + # Filter read-only events if write_events_only is enabled + if self._write_events_only: + event_name = raw_event.get("EventName", "") + if self._is_read_only_event(event_name): + continue + + parsed = self._parse_event(raw_event) + if parsed: + events.append(parsed) + + return events + + except ClientError as e: + logger.error( + f"CloudTrail timeline error for {resource_identifier} in {region}: " + f"{e.response['Error']['Code']} - {e.response['Error']['Message']}" + ) + raise + except Exception as e: + lineno = e.__traceback__.tb_lineno if e.__traceback__ else "?" + logger.error( + f"CloudTrail timeline unexpected error: " + f"{e.__class__.__name__}[{lineno}]: {e}" + ) + return [] + + def _is_read_only_event(self, event_name: str) -> bool: + """Check if an event is a read-only operation.""" + return event_name.startswith(self.READ_ONLY_PREFIXES) + + def _get_client(self, region: str): + """Get or create a CloudTrail client for the specified region.""" + if region not in self._clients: + self._clients[region] = self._session.client( + "cloudtrail", region_name=region + ) + return self._clients[region] + + def _lookup_events( + self, resource_identifier: str, region: str + ) -> List[Dict[str, Any]]: + """Query CloudTrail for events related to a specific resource. + + Uses MaxResults to limit the number of events returned, preparing + for API-level pagination. Currently returns up to max_results events + from the first page only. + """ + client = self._get_client(region) + start_time = datetime.now(timezone.utc) - timedelta(days=self._lookback_days) + + # Use direct API call with MaxResults instead of paginator + # This limits CloudTrail to return only max_results events + response = client.lookup_events( + LookupAttributes=[ + {"AttributeKey": "ResourceName", "AttributeValue": resource_identifier} + ], + StartTime=start_time, + MaxResults=self._max_results, + ) + + return response.get("Events", []) + + def _parse_event(self, raw_event: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Parse a raw CloudTrail event into a TimelineEvent dictionary.""" + try: + cloud_trail_event = raw_event.get("CloudTrailEvent", "{}") + if isinstance(cloud_trail_event, str): + details = json.loads(cloud_trail_event) + else: + details = cloud_trail_event + + user_identity = details.get("userIdentity", {}) + + event = TimelineEvent( + event_id=raw_event.get("EventId"), + event_time=raw_event["EventTime"], + event_name=raw_event.get("EventName", "Unknown"), + event_source=raw_event.get("EventSource", "Unknown"), + actor=self._extract_actor(user_identity), + actor_uid=user_identity.get("arn"), + actor_type=user_identity.get("type"), + source_ip_address=details.get("sourceIPAddress"), + user_agent=details.get("userAgent"), + request_data=details.get("requestParameters"), + response_data=details.get("responseElements"), + error_code=details.get("errorCode"), + error_message=details.get("errorMessage"), + ) + + return event.dict() + + except Exception as e: + logger.warning( + f"CloudTrail timeline: failed to parse event: " + f"{e.__class__.__name__}: {e}" + ) + return None + + @staticmethod + def _extract_actor(user_identity: Dict[str, Any]) -> str: + """Extract a human-readable actor name from CloudTrail userIdentity.""" + # Try ARN first - most reliable + if arn := user_identity.get("arn"): + if "/" in arn: + parts = arn.split("/") + # For assumed-role, return the role name (second-to-last part) + if "assumed-role" in arn and len(parts) >= 2: + return parts[-2] + return parts[-1] + return arn.split(":")[-1] + + # Fall back to userName + if username := user_identity.get("userName"): + return username + + # Fall back to principalId + if principal_id := user_identity.get("principalId"): + return principal_id + + # For service-invoked actions + if invoking_service := user_identity.get("invokedBy"): + return invoking_service + + return "Unknown" diff --git a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/__init__.py b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/__init__.py similarity index 100% rename from prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/__init__.py rename to prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/__init__.py diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.metadata.json b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.metadata.json new file mode 100644 index 0000000000..f83824d81b --- /dev/null +++ b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "codebuild_project_webhook_filters_use_anchored_patterns", + "CheckTitle": "CodeBuild project webhook filters use anchored regex patterns", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "codebuild", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsCodeBuildProject", + "ResourceGroup": "devops", + "Description": "AWS CodeBuild webhook filters using `ACTOR_ACCOUNT_ID`, `HEAD_REF`, or `BASE_REF` have regex patterns anchored with `^` (start) and `$` (end) to enforce exact matching and prevent substring bypass attacks.", + "Risk": "Unanchored patterns expose CI/CD pipelines to **CodeBreach** attacks. Attackers can bypass `ACTOR_ACCOUNT_ID` filters by creating GitHub accounts with IDs containing trusted values as substrings. **Confidentiality**: Credentials leaked via build logs. **Integrity**: Malicious code injected into builds. **Availability**: Resource exhaustion through unauthorized builds.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "aws codebuild update-webhook --project-name --filter-groups '[[{\"type\":\"ACTOR_ACCOUNT_ID\",\"pattern\":\"^123456$|^234567$\"}]]'", + "NativeIaC": "AWSTemplateFormatVersion: '2010-09-09'\nResources:\n CodeBuildWebhook:\n Type: AWS::CodeBuild::Project\n Properties:\n Triggers:\n Webhook: true\n FilterGroups:\n - - Type: ACTOR_ACCOUNT_ID\n Pattern: '^123456$|^234567$' # Anchored pattern", + "Other": "1. Open AWS Console and navigate to CodeBuild. 2. Select the project with webhook filters. 3. Click Edit and go to Primary source webhook events. 4. For each filter using ACTOR_ACCOUNT_ID, HEAD_REF, or BASE_REF, update patterns to include ^ at start and $ at end (e.g., change '123456|234567' to '^123456$|^234567$'). 5. Save changes.", + "Terraform": "resource \"aws_codebuild_webhook\" \"example\" {\n project_name = aws_codebuild_project.example.name\n filter_group {\n filter {\n type = \"ACTOR_ACCOUNT_ID\"\n pattern = \"^123456$|^234567$\" # Anchored pattern\n }\n }\n}" + }, + "Recommendation": { + "Text": "Anchor all webhook filter patterns with `^` (start) and `$` (end) to enforce exact matching. For multiple values use: `^value1$|^value2$`. This prevents attackers from bypassing filters using substring matches.", + "Url": "https://hub.prowler.com/check/codebuild_project_webhook_filters_use_anchored_patterns" + } + }, + "Categories": [ + "software-supply-chain", + "ci-cd" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check targets the CodeBreach vulnerability disclosed by Wiz Research. The vulnerability allows attackers to bypass ACTOR_ACCOUNT_ID filters by creating GitHub accounts with IDs that contain trusted IDs as substrings.", + "AdditionalURLs": [ + "https://www.wiz.io/blog/wiz-research-codebreach-vulnerability-aws-codebuild", + "https://docs.aws.amazon.com/codebuild/latest/userguide/github-webhook.html" + ] +} diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.py b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.py new file mode 100644 index 0000000000..231e392687 --- /dev/null +++ b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns.py @@ -0,0 +1,58 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.codebuild.codebuild_client import codebuild_client + +HIGH_RISK_FILTER_TYPES = {"ACTOR_ACCOUNT_ID", "HEAD_REF", "BASE_REF"} + + +def is_pattern_anchored(pattern: str) -> bool: + """Check if each alternative in a pipe-separated pattern is anchored with ^ and $.""" + if not pattern: + return True + + for alt in pattern.split("|"): + alt = alt.strip() + if alt and not (alt.startswith("^") and alt.endswith("$")): + return False + return True + + +class codebuild_project_webhook_filters_use_anchored_patterns(Check): + def execute(self) -> List[Check_Report_AWS]: + findings = [] + + for project in codebuild_client.projects.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=project) + report.status = "PASS" + report.status_extended = ( + f"CodeBuild project {project.name} has no webhook configured or all " + "webhook filter patterns are properly anchored." + ) + + if not project.webhook or not project.webhook.filter_groups: + findings.append(report) + continue + + unanchored_filters = [] + for filter_group in project.webhook.filter_groups: + for webhook_filter in filter_group.filters: + if webhook_filter.type in HIGH_RISK_FILTER_TYPES: + if not is_pattern_anchored(webhook_filter.pattern): + unanchored_filters.append( + f"{webhook_filter.type}: '{webhook_filter.pattern}'" + ) + + if unanchored_filters: + report.status = "FAIL" + filters_str = ", ".join(unanchored_filters[:3]) + if len(unanchored_filters) > 3: + filters_str += f" and {len(unanchored_filters) - 3} more" + report.status_extended = ( + f"CodeBuild project {project.name} has webhook filters with " + f"unanchored patterns that could allow bypass attacks: {filters_str}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/codebuild/codebuild_service.py b/prowler/providers/aws/services/codebuild/codebuild_service.py index 475f578e94..361002aa65 100644 --- a/prowler/providers/aws/services/codebuild/codebuild_service.py +++ b/prowler/providers/aws/services/codebuild/codebuild_service.py @@ -122,6 +122,29 @@ class Codebuild(AWSService): project.tags = project_info.get("tags", []) project.service_role_arn = project_info.get("serviceRole", "") project.project_visibility = project_info.get("projectVisibility", "") + + # Extract webhook configuration + webhook_data = project_info.get("webhook") + if webhook_data: + filter_groups = [] + for fg in webhook_data.get("filterGroups", []): + filters = [] + for f in fg: + filters.append( + WebhookFilter( + type=f.get("type", ""), + pattern=f.get("pattern", ""), + exclude_matched_pattern=f.get( + "excludeMatchedPattern", False + ), + ) + ) + filter_groups.append(WebhookFilterGroup(filters=filters)) + + project.webhook = Webhook( + filter_groups=filter_groups, + branch_filter=webhook_data.get("branchFilter"), + ) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -209,6 +232,27 @@ class CloudWatchLogs(BaseModel): stream_name: str +class WebhookFilter(BaseModel): + """Represents a single filter in a webhook filter group.""" + + type: str # ACTOR_ACCOUNT_ID, HEAD_REF, BASE_REF, EVENT, etc. + pattern: str + exclude_matched_pattern: bool = False + + +class WebhookFilterGroup(BaseModel): + """Represents a group of filters (AND logic within group).""" + + filters: List[WebhookFilter] = [] + + +class Webhook(BaseModel): + """Represents the webhook configuration for a CodeBuild project.""" + + filter_groups: List[WebhookFilterGroup] = [] + branch_filter: Optional[str] = None + + class Project(BaseModel): name: str arn: str @@ -224,6 +268,7 @@ class Project(BaseModel): cloudwatch_logs: Optional[CloudWatchLogs] tags: Optional[list] project_visibility: Optional[str] = None + webhook: Optional[Webhook] = None class ExportConfig(BaseModel): diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json index a7bcb9456e..13a434f4c8 100644 --- a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json +++ b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.metadata.json @@ -38,5 +38,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding." } diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py index e3c1e817ca..208c2d35dd 100644 --- a/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py +++ b/prowler/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access.py @@ -6,6 +6,9 @@ from prowler.providers.aws.services.iam.lib.policy import is_policy_public class dynamodb_table_cross_account_access(Check): def execute(self): findings = [] + trusted_account_ids = dynamodb_client.audit_config.get( + "trusted_account_ids", [] + ) for table in dynamodb_client.tables.values(): if table.policy is None: continue @@ -20,6 +23,7 @@ class dynamodb_table_cross_account_access(Check): table.policy, dynamodb_client.audited_account, is_cross_account_allowed=False, + trusted_account_ids=trusted_account_ids, ): report.status = "FAIL" report.status_extended = f"DynamoDB table {table.name} has a resource-based policy allowing cross account access." diff --git a/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json b/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json index d01f57ab62..d54d112516 100644 --- a/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_ami_public", - "CheckTitle": "Ensure there are no EC2 AMIs set as Public.", + "CheckTitle": "EC2 AMI owned by the account is not public", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "ami", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "Other", "ResourceGroup": "compute", - "Description": "Ensure there are no EC2 AMIs set as Public.", - "Risk": "When your AMIs are publicly accessible, they are available in the Community AMIs where everyone with an AWS account can use them to launch EC2 instances. Your AMIs could contain snapshots of your applications (including their data), therefore exposing your snapshots in this manner is not advised.", + "Description": "**EC2 AMIs owned by the account** are evaluated for **public visibility** via their launch permissions. Images shared with all accounts (`Group=all`) are treated as publicly accessible.", + "Risk": "Public AMIs expose image contents to any AWS account, undermining **confidentiality** and **integrity**:\n- Leakage of embedded secrets, configs, or data from referenced snapshots\n- Adversaries can fingerprint your stack, aiding targeted exploits or repackaging for supply chain abuse", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cancel-sharing-an-AMI.html", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-explicit.html", + "https://docs.aws.amazon.com/cli/latest/reference/ec2/modify-image-attribute.html", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-intro.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 modify-image-attribute --region --image-id --launch-permission {\"Remove\":[{\"Group\":\"all\"}]}", + "CLI": "aws ec2 modify-image-attribute --image-id --launch-permission \"Remove=[{Group=all}]\"", "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/public-policies/public_8", + "Other": "1. Open the Amazon EC2 console and go to AMIs\n2. Select the AMI with Visibility = Public\n3. Click Actions > Edit AMI permissions\n4. Under AMI availability, select Private\n5. Click Save changes", "Terraform": "" }, "Recommendation": { - "Text": "We recommend your EC2 AMIs are not publicly accessible, or generally available in the Community AMIs.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cancel-sharing-an-AMI.html" + "Text": "Keep AMIs **private** and enforce **least privilege** launch permissions. Share only with specific accounts and review access routinely. Enable **block public access for AMIs**, sanitize images to remove secrets, encrypt backing snapshots, and apply lifecycle governance to retire outdated images.", + "Url": "https://hub.prowler.com/check/ec2_ami_public" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json index 1f78c2ffb1..8a9ad91e20 100644 --- a/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_client_vpn_endpoint_connection_logging_enabled/ec2_client_vpn_endpoint_connection_logging_enabled.metadata.json @@ -1,30 +1,41 @@ { "Provider": "aws", "CheckID": "ec2_client_vpn_endpoint_connection_logging_enabled", - "CheckTitle": "EC2 Client VPN endpoints should have client connection logging enabled.", - "CheckType": [], + "CheckTitle": "EC2 Client VPN endpoint has client connection logging enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)" + ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsEc2ClientVpnEndpoint", "ResourceGroup": "network", - "Description": "This control checks whether an AWS Client VPN endpoint has client connection logging enabled. The control fails if the endpoint doesn't have client connection logging enabled.", - "Risk": "Client VPN endpoints allow remote clients to securely connect to resources in a Virtual Private Cloud (VPC) in AWS. Connection logs allow you to track user activity on the VPN endpoint and provides visibility.", - "RelatedUrl": "https://docs.aws.amazon.com/vpn/latest/clientvpn-admin/what-is.html", + "Description": "**AWS Client VPN endpoints** are evaluated for **client connection logging** that records client connect/disconnect events to CloudWatch Logs. The evaluation detects endpoints where this logging is disabled.", + "Risk": "Without **Client VPN connection logs**, remote access lacks an **audit trail**, reducing detection and accountability.\n- Stolen credentials can be used unnoticed\n- Lateral movement and data exfiltration persist\nImpacts **confidentiality** and **integrity**; delayed investigation can degrade **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpn/latest/clientvpn-admin/what-is.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-51", + "https://docs.aws.amazon.com/config/latest/developerguide/ec2-client-vpn-connection-log-enabled.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-51", - "Terraform": "" + "CLI": "aws ec2 modify-client-vpn-endpoint --client-vpn-endpoint-id --connection-log-options Enabled=true,CloudWatchLogGroup=", + "NativeIaC": "```yaml\n# CloudFormation: enable connection logging on a Client VPN endpoint\nResources:\n :\n Type: AWS::EC2::ClientVpnEndpoint\n Properties:\n ClientCidrBlock: 10.0.0.0/22\n ServerCertificateArn: arn:aws:acm:::certificate/\n AuthenticationOptions:\n - Type: certificate-authentication\n MutualAuthentication:\n ClientRootCertificateChainArn: arn:aws:acm:::certificate/\n ConnectionLogOptions: # CRITICAL: enables client connection logging\n Enabled: true # CRITICAL: turns on logging\n CloudWatchLogGroup: # CRITICAL: destination log group\n```", + "Other": "1. Open the Amazon VPC console and go to Client VPN Endpoints\n2. Select the endpoint and choose Actions > Modify client VPN endpoint\n3. Under Connection logging, check Enable\n4. For CloudWatch log group, select an existing log group\n5. Click Save changes", + "Terraform": "```hcl\n# Terraform: enable connection logging on a Client VPN endpoint\nresource \"aws_ec2_client_vpn_endpoint\" \"\" {\n server_certificate_arn = \"arn:aws:acm:::certificate/\"\n client_cidr_block = \"10.0.0.0/22\"\n\n authentication_options {\n type = \"certificate-authentication\"\n root_certificate_chain_arn = \"arn:aws:acm:::certificate/\"\n }\n\n connection_log_options { # CRITICAL: enables client connection logging\n enabled = true # CRITICAL: turns on logging\n cloudwatch_log_group = \"\" # CRITICAL: destination log group\n }\n}\n```" }, "Recommendation": { - "Text": "To enable connection logging, see Enable connection logging for an existing Client VPN endpoint in the AWS Client VPN Administrator Guide.", - "Url": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-client-vpn-connection-log-enabled.html" + "Text": "Enable **client connection logging** on all Client VPN endpoints and send events to a centralized log group.\n- Enforce least privilege on log access\n- Define retention and immutability\n- Integrate with monitoring/alerts\n- Separate VPN operations from log administration\n- Review anomalous login patterns", + "Url": "https://hub.prowler.com/check/ec2_client_vpn_endpoint_connection_logging_enabled" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json index 8e61062980..691af1b376 100644 --- a/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ebs_default_encryption/ec2_ebs_default_encryption.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_ebs_default_encryption", - "CheckTitle": "Check if EBS Default Encryption is activated.", + "CheckTitle": "EBS default encryption is enabled", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "ebs", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "medium", - "ResourceType": "Other", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsEc2Volume", "ResourceGroup": "compute", - "Description": "Check if EBS Default Encryption is activated.", - "Risk": "If not enabled sensitive information at rest is not protected.", + "Description": "**EBS** uses `encryption by default` at the account and region level, ensuring new volumes, snapshots, and AMI-backed volumes are automatically encrypted with a chosen **KMS key**", + "Risk": "Without `encryption by default`, data on new **EBS volumes** and **snapshots** may be stored in plaintext. A compromised account or mis-shared snapshot can expose disk contents, enabling data exfiltration, offline analysis, and loss of **confidentiality**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://aws.amazon.com/premiumsupport/knowledge-center/ebs-automatic-encryption/", + "https://docs.aws.amazon.com/ebs/latest/userguide/encryption-by-default.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/configure-default-encryption.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 enable-ebs-encryption-by-default", - "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/general-policies/ensure-ebs-default-encryption-is-enabled#aws-console", - "Terraform": "https://docs.prowler.com/checks/aws/general-policies/ensure-ebs-default-encryption-is-enabled#terraform" + "CLI": "aws ec2 enable-ebs-encryption-by-default --region ", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::EC2::EBSEncryptionByDefault\n Properties:\n Enabled: true # Critical: turns on default EBS encryption in this region\n```", + "Other": "1. In the AWS console, switch to the affected Region\n2. Go to EC2 > Settings (or Account attributes) > EBS encryption\n3. Click Enable default encryption and Save", + "Terraform": "```hcl\nresource \"aws_ebs_encryption_by_default\" \"\" {\n enabled = true # Critical: enables default EBS encryption in this region\n}\n```" }, "Recommendation": { - "Text": "Enable Encryption. Use a CMK where possible. It will provide additional management and privacy benefits.", - "Url": "https://aws.amazon.com/premiumsupport/knowledge-center/ebs-automatic-encryption/" + "Text": "Enable `EBS encryption by default` in every region and select a **customer-managed KMS key**. Apply **least privilege** to key use, rotate keys, and monitor access. Enforce encrypted volume creation with organizational guardrails and secure templates as **defense in depth**.", + "Url": "https://hub.prowler.com/check/ec2_ebs_default_encryption" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json index b8e84aa4bb..cc602f1e7f 100644 --- a/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ebs_public_snapshot/ec2_ebs_public_snapshot.metadata.json @@ -1,29 +1,35 @@ { "Provider": "aws", "CheckID": "ec2_ebs_public_snapshot", - "CheckTitle": "Ensure there are no EBS Snapshots set as Public.", + "CheckTitle": "EBS snapshot is not public", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "snapshot", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Other", + "ResourceType": "AwsEc2Volume", "ResourceGroup": "compute", - "Description": "Ensure there are no EBS Snapshots set as Public.", - "Risk": "When you share a snapshot, you are giving others access to all of the data on the snapshot. Share snapshots only with people with whom you want to share all of your snapshot data.", + "Description": "**EBS snapshots** with **public sharing** permissions (accessible by all AWS accounts) are identified, as opposed to snapshots shared privately with specific accounts.", + "Risk": "Public snapshots expose full volume contents, harming **confidentiality**. Any account can create a volume from the snapshot to read files, secrets, or database data, enabling **data exfiltration**, broad reconnaissance, and facilitating **lateral movement**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-modifying-snapshot-permissions.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/public-snapshots.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 modify-snapshot-attribute --region --snapshot-id --attribute createVolumePermission --operation remove --user-ids all", + "CLI": "aws ec2 modify-snapshot-attribute --snapshot-id --attribute createVolumePermission --operation-type remove --group-names all", "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/public-policies/public_7", + "Other": "1. Open the AWS Management Console and go to EC2\n2. In the left menu, select Snapshots\n3. Select the snapshot \n4. Click Actions > Modify permissions\n5. Choose Private (remove Public/all if present)\n6. Click Save changes", "Terraform": "" }, "Recommendation": { - "Text": "Ensure the snapshot should be shared.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-modifying-snapshot-permissions.html" + "Text": "Keep snapshots **private** and share only with specific accounts under **least privilege**. Enable `Block public access for Amazon EBS snapshots` regionally. Prefer **CMEK encryption** and avoid sharing keys broadly. Regularly review sharing permissions and monitor snapshot usage.", + "Url": "https://hub.prowler.com/check/ec2_ebs_public_snapshot" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json index a896e1386f..88f1dfa7e8 100644 --- a/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ebs_snapshot_account_block_public_access/ec2_ebs_snapshot_account_block_public_access.metadata.json @@ -1,29 +1,35 @@ { "Provider": "aws", "CheckID": "ec2_ebs_snapshot_account_block_public_access", - "CheckTitle": "Ensure that public access to EBS snapshots is disabled", + "CheckTitle": "All EBS snapshots have public access blocked", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "snapshot", - "ResourceIdTemplate": "arn:partition:service:region:account-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AwsAccount", - "ResourceGroup": "governance", - "Description": "EBS snapshots can be shared with other AWS accounts or made public. By default, EBS snapshots are private and only the AWS account that created the snapshot can access it. If an EBS snapshot is shared with another AWS account or made public, the data in the snapshot can be accessed by the other account or by anyone on the internet. Ensure that public access to EBS snapshots is disabled.", - "Risk": "If public access to EBS snapshots is enabled, the data in the snapshot can be accessed by anyone on the internet.", - "RelatedUrl": "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots-work.html#block-public-access-snapshots-enable", + "ResourceType": "AwsEc2Volume", + "ResourceGroup": "compute", + "Description": "**EBS snapshots** account/Region configuration for **Block Public Access** is assessed to see whether public sharing is fully blocked (`block-all-sharing`) versus only new sharing (`block-new-sharing`) or unblocked. The state indicates if any snapshot can be publicly shared.", + "Risk": "Without `block-all-sharing`, previously public snapshots can remain accessible, exposing raw disk data.\n\nImpacts:\n- Loss of **confidentiality** (PII, keys, configs)\n- Unauthorized cloning enabling **lateral movement**\n- Cross-account copies create **irreversible data leakage**", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots-work.html#block-public-access-snapshots-enable", + "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots.html" + ], "Remediation": { "Code": { "CLI": "aws ec2 enable-snapshot-block-public-access --state block-all-sharing", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::EC2::SnapshotBlockPublicAccess\n Properties:\n State: block-all-sharing # CRITICAL: Blocks all public sharing of EBS snapshots in this Region to pass the check\n```", + "Other": "1. In the AWS console, select the target Region in the top-right.\n2. Go to EC2 > Snapshots.\n3. Click Settings > Block public access for snapshots.\n4. Select Block all sharing.\n5. Click Save changes.", + "Terraform": "```hcl\nresource \"aws_ebs_snapshot_block_public_access\" \"\" {\n state = \"block-all-sharing\" # CRITICAL: Blocks all public sharing of EBS snapshots in this Region\n}\n```" }, "Recommendation": { - "Text": "Use the following procedures to configure and monitor block public access for snapshots.", - "Url": "https://docs.aws.amazon.com/ebs/latest/userguide/block-public-access-snapshots-work.html#block-public-access-snapshots-enable" + "Text": "Set **Block Public Access** for EBS snapshots to `block-all-sharing` in all active Regions.\n\nApply **least privilege** and guardrails (SCPs) to prevent changes. Regularly inventory snapshots, remove public sharing, and use segregated accounts with strict reviews for any necessary external sharing.", + "Url": "https://hub.prowler.com/check/ec2_ebs_snapshot_account_block_public_access" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json index f98e613033..606e7c5eef 100644 --- a/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ebs_snapshots_encrypted/ec2_ebs_snapshots_encrypted.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_ebs_snapshots_encrypted", - "CheckTitle": "Check if EBS snapshots are encrypted.", + "CheckTitle": "EBS snapshot is encrypted", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "snapshot", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "medium", - "ResourceType": "Other", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsEc2Volume", "ResourceGroup": "compute", - "Description": "Check if EBS snapshots are encrypted.", - "Risk": "Data encryption at rest prevents data visibility in the event of its unauthorized access or theft.", + "Description": "**EBS snapshots** are evaluated for **encryption at rest** with AWS KMS. The finding identifies snapshots where encryption is not enabled.", + "Risk": "Unencrypted snapshots expose complete disk images to anyone with snapshot access or if mis-shared. Attackers can exfiltrate data, harvest credentials, and clone volumes for offline analysis, compromising **confidentiality** and enabling **lateral movement**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default", + "https://docs.aws.amazon.com/ebs/latest/userguide/ebs-encryption.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/snapshot-encrypted.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 --region enable-ebs-encryption-by-default", - "NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/general_3-encrypt-ebs-volume#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/general-policies/general_3-encrypt-ebs-volume#aws-console", - "Terraform": "https://docs.prowler.com/checks/aws/general-policies/general_3-encrypt-ebs-volume#terraform" + "CLI": "aws ec2 copy-snapshot --source-region --source-snapshot-id --encrypted --description \"Encrypted copy of \"", + "NativeIaC": "", + "Other": "1. In the AWS Console, go to EC2 > Snapshots and select the unencrypted snapshot\n2. Click Actions > Copy snapshot\n3. Check Encrypt this snapshot (leave the default KMS key unless a specific key is required)\n4. Click Copy snapshot and wait for the new encrypted snapshot to be available\n5. Select the original unencrypted snapshot > Actions > Delete snapshot > Delete", + "Terraform": "```hcl\nresource \"aws_ebs_snapshot_copy\" \"\" {\n source_snapshot_id = \"\"\n source_region = \"\"\n encrypted = true # Critical: creates an encrypted copy of the snapshot to remediate the finding\n}\n```" }, "Recommendation": { - "Text": "Encrypt all EBS Snapshot and Enable Encryption by default. You can configure your AWS account to enforce the encryption of the new EBS volumes and snapshot copies that you create. For example, Amazon EBS encrypts the EBS volumes created when you launch an instance and the snapshots that you copy from an unencrypted snapshot.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default" + "Text": "Encrypt all **EBS snapshots** and enable `encryption by default` to prevent unencrypted creations and copies. Use **customer-managed KMS keys** for control and rotation, restrict snapshot sharing, and enforce **least privilege** on snapshot and key permissions as **defense in depth**.", + "Url": "https://hub.prowler.com/check/ec2_ebs_snapshots_encrypted" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json index 79356a2544..1b4033c4b9 100644 --- a/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ebs_volume_encryption/ec2_ebs_volume_encryption.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_ebs_volume_encryption", - "CheckTitle": "Ensure there are no EBS Volumes unencrypted.", + "CheckTitle": "EBS volume is encrypted", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "volume", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "medium", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", "ResourceType": "AwsEc2Volume", - "ResourceGroup": "storage", - "Description": "Ensure there are no EBS Volumes unencrypted.", - "Risk": "Data encryption at rest prevents data visibility in the event of its unauthorized access or theft.", + "ResourceGroup": "compute", + "Description": "**EBS volumes** are assessed for **encryption at rest** using **AWS KMS**.\n\nThe finding identifies volumes whose `encrypted` state is disabled, meaning data is stored unencrypted on block storage.", + "Risk": "Unencrypted volumes or snapshots can be copied, shared, or recovered and reveal raw data, undermining **confidentiality**.\n\nAdversaries with host or account access can read disks offline, harvest secrets, or alter system images, affecting **integrity** and enabling **lateral movement**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/ebs-encrypted.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 create-snapshot --volume-id --description \"Snapshot for encryption\" && aws ec2 copy-snapshot --source-region --source-snapshot-id --encrypted --description \"Encrypted snapshot\" && aws ec2 create-volume --snapshot-id --availability-zone --encrypted", + "NativeIaC": "```yaml\n# CloudFormation: Encrypted EBS volume\nResources:\n :\n Type: AWS::EC2::Volume\n Properties:\n AvailabilityZone: \n Size: 1\n Encrypted: true # CRITICAL: enables EBS encryption so the volume is created encrypted\n```", + "Other": "1. In the AWS Console, go to EC2 > Volumes and select the unencrypted volume\n2. Choose Actions > Create snapshot and wait for it to complete\n3. Open the snapshot, click Actions > Create volume, select the same Availability Zone, and check Encrypted, then create\n4. Stop the instance using the old volume\n5. Detach the old (unencrypted) volume\n6. Attach the new encrypted volume to the instance using the same device name\n7. Start the instance\n8. Verify the new volume shows Encrypted = Yes", + "Terraform": "```hcl\n# Encrypted EBS volume\nresource \"aws_ebs_volume\" \"\" {\n availability_zone = \"\"\n size = 1\n encrypted = true # CRITICAL: ensures the volume is created encrypted\n}\n```" }, "Recommendation": { - "Text": "Encrypt all EBS volumes and Enable Encryption by default You can configure your AWS account to enforce the encryption of the new EBS volumes and snapshot copies that you create. For example, Amazon EBS encrypts the EBS volumes created when you launch an instance and the snapshots that you copy from an unencrypted snapshot.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html" + "Text": "Encrypt all EBS volumes and enable `encryption by default` for new volumes and snapshot copies.\n\nApply **least privilege** to KMS keys, restrict snapshot sharing, and enforce **defense in depth** with policies and templates that prevent creation of unencrypted storage.", + "Url": "https://hub.prowler.com/check/ec2_ebs_volume_encryption" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json index 7fe1bfa832..46b406cf1a 100644 --- a/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ebs_volume_protected_by_backup_plan/ec2_ebs_volume_protected_by_backup_plan.metadata.json @@ -1,33 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_ebs_volume_protected_by_backup_plan", - "CheckTitle": "Amazon EBS volumes should be protected by a backup plan.", + "CheckTitle": "EBS volume is protected by a backup plan", "CheckType": [ - "Software and Configuration Checks, AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Destruction" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:volume/volume-id", - "Severity": "low", + "ResourceIdTemplate": "", + "Severity": "medium", "ResourceType": "AwsEc2Volume", - "ResourceGroup": "storage", - "Description": "Evaluates if an Amazon EBS volume in in-use state is covered by a backup plan. The check fails if an EBS volume isn't covered by a backup plan. If you set the backupVaultLockCheck parameter equal to true, the control passes only if the EBS volume is backed up in an AWS Backup locked vault.", - "Risk": "Without backup coverage, Amazon EBS volumes are vulnerable to data loss or deletion, reducing the resilience of your systems and making recovery from incidents more difficult.", - "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ebs-resources-protected-by-backup-plan.html", + "ResourceGroup": "compute", + "Description": "**EBS volumes** are evaluated for coverage by an **AWS Backup plan**, whether explicitly targeted or included via broad resource selection, confirming scheduled, policy-driven backups exist for the volume.", + "Risk": "Absent backup coverage, volumes face **data loss**, weakened **integrity**, and reduced **availability**. Deletion or corruption-whether accidental or malicious-can leave no recovery path, causing prolonged outages, failed point-in-time restoration, unmet retention needs, and harder incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.amazonaws.cn/en_us/aws-backup/latest/devguide/vault-lock.html", + "https://aws.amazon.com/blogs/storage/protecting-your-critical-amazon-ebs-volumes-using-aws-backup/", + "https://docs.aws.amazon.com/config/latest/developerguide/ebs-resources-protected-by-backup-plan.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-28", - "Terraform": "" + "CLI": "aws backup create-backup-selection --backup-plan-id --backup-selection '{\"SelectionName\":\"\",\"IamRoleArn\":\"arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\",\"Resources\":[\"arn:aws:ec2:*:*:volume/*\"]}'", + "NativeIaC": "```yaml\n# CloudFormation: protect all EBS volumes by assigning them to a backup plan\nResources:\n BackupPlan:\n Type: AWS::Backup::BackupPlan\n Properties:\n BackupPlan:\n BackupPlanName: \n Rules:\n - RuleName: \n TargetBackupVault: Default\n\n BackupSelection:\n Type: AWS::Backup::BackupSelection\n Properties:\n BackupPlanId: !Ref BackupPlan\n BackupSelection:\n SelectionName: \n IamRoleArn: arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\n Resources:\n - arn:aws:ec2:*:*:volume/* # Critical: wildcard includes all EBS volumes so they are covered by the plan\n```", + "Other": "1. In the AWS Backup console, go to Backup plans and click Create backup plan\n2. Choose Start with a template (any), keep the Default vault, and create the plan\n3. Open the plan and click Assign resources\n4. Set Selection name and choose IAM role AWSBackupDefaultServiceRole\n5. Under Assign resources, choose Include specific resource types and select EBS\n6. For Resources, select all EBS volumes (or the specific volumes to protect) and click Assign resources", + "Terraform": "```hcl\n# Minimal AWS Backup plan protecting all EBS volumes\nresource \"aws_backup_plan\" \"\" {\n name = \"\"\n\n rule {\n rule_name = \"\"\n target_vault_name = \"Default\"\n }\n}\n\nresource \"aws_backup_selection\" \"\" {\n name = \"\"\n plan_id = aws_backup_plan..id\n iam_role_arn = \"arn:aws:iam:::role/service-role/AWSBackupDefaultServiceRole\"\n\n resources = [\n \"arn:aws:ec2:*:*:volume/*\" # Critical: selects all EBS volumes to satisfy the check\n ]\n}\n```" }, "Recommendation": { - "Text": "Ensure that all in-use Amazon EBS volumes are included in a backup plan, and consider using AWS Backup Vault Lock for additional protection.", - "Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/assigning-resources.html" + "Text": "Include all critical EBS volumes in standardized **AWS Backup** plans aligned to your `RPO`/`RTO`. Use tags for automatic assignment, enable cross-Region/account copies, apply **Vault Lock** for WORM retention, encrypt with KMS, enforce least-privilege access, and regularly test restores to verify integrity.", + "Url": "https://hub.prowler.com/check/ec2_ebs_volume_protected_by_backup_plan" } }, "Categories": [ - "redundancy" + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json b/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json index 338a977af9..d14225a216 100644 --- a/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_ebs_volume_snapshots_exists/ec2_ebs_volume_snapshots_exists.metadata.json @@ -1,32 +1,38 @@ { "Provider": "aws", "CheckID": "ec2_ebs_volume_snapshots_exists", - "CheckTitle": "Check if EBS snapshots exists.", + "CheckTitle": "EBS volume has at least one snapshot", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Data Destruction" ], "ServiceName": "ec2", - "SubServiceName": "volume", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "medium", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", "ResourceType": "AwsEc2Volume", - "ResourceGroup": "storage", - "Description": "Check if EBS snapshots exists.", - "Risk": "Ensure that your EBS volumes (available or in-use) have recent snapshots (taken weekly) available for point-in-time recovery for a better, more reliable data backup strategy.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html", + "ResourceGroup": "compute", + "Description": "**EBS volumes** are evaluated for the existence of at least one associated **snapshot**, identifying volumes without any point-in-time backup available.", + "Risk": "Missing **EBS snapshots** removes point-in-time recovery. Accidental deletion, corruption, or ransomware can cause **irrecoverable data loss** and prolonged **service outages**, degrading data **integrity** and **availability** and complicating recovery and forensics.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/ebs/latest/userguide/ebs-snapshots.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EBS/ebs-volumes-recent-snapshots.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 --region create-snapshot --volume-id ", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/aws/EBS/ebs-volumes-recent-snapshots.html", - "Terraform": "" + "CLI": "aws ec2 create-snapshot --region --volume-id ", + "NativeIaC": "```yaml\n# CloudFormation: create a snapshot of an existing EBS volume\nResources:\n :\n Type: AWS::EC2::Snapshot\n Properties:\n VolumeId: # Critical: creates a snapshot for this volume to pass the check\n```", + "Other": "1. In the AWS Console, go to EC2\n2. Click Volumes, select the target EBS volume\n3. Choose Actions > Create snapshot\n4. Click Create snapshot to confirm", + "Terraform": "```hcl\n# Create a snapshot for an existing EBS volume\nresource \"aws_ebs_snapshot\" \"\" {\n volume_id = \"\" # Critical: creating this snapshot makes the volume pass the check\n}\n```" }, "Recommendation": { - "Text": "Creating point-in-time EBS snapshots periodically will allow you to handle efficiently your data recovery process in the event of a failure, to save your data before shutting down an EC2 instance, to back up data for geographical expansion and to maintain your disaster recovery stack up to date.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html" + "Text": "Establish automated, policy-based **EBS snapshot** coverage for all volumes aligned to business `RPO/RTO`.\n- Schedule regular snapshots with retention controls\n- Encrypt snapshots and enforce **least privilege** access\n- Replicate to another Region/account for DR\n- Periodically test restores and document procedures", + "Url": "https://hub.prowler.com/check/ec2_ebs_volume_snapshots_exists" } }, "Categories": [ + "resilience", "forensics-ready" ], "DependsOn": [], diff --git a/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json b/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json index 3e21835ff1..45e3b083c3 100644 --- a/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_elastic_ip_shodan/ec2_elastic_ip_shodan.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_elastic_ip_shodan", - "CheckTitle": "Check if any of the Elastic or Public IP are in Shodan (requires Shodan API KEY).", + "CheckTitle": "EC2 Elastic IP address is not listed in Shodan", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Discovery" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "high", + "ResourceIdTemplate": "", + "Severity": "medium", "ResourceType": "AwsEc2Eip", "ResourceGroup": "network", - "Description": "Check if any of the Elastic or Public IP are in Shodan (requires Shodan API KEY).", - "Risk": "Sites like Shodan index exposed systems and further expose them to wider audiences as a quick way to find exploitable systems.", + "Description": "**EC2 Elastic IPs** are compared with **Shodan**'s index to identify publicly reachable addresses that have been scanned and cataloged, including metadata such as open ports, ISP, and geolocation", + "Risk": "Being listed on **Shodan** confirms Internet exposure and reveals open services, versions, and banners. Adversaries can rapidly target these hosts for credential attacks and CVE exploits, threatening **confidentiality** (data access), **integrity** (system takeover), and **availability** (service disruption).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.shodan.io/", + "https://support.icompaas.com/support/solutions/articles/62000229484-ensure-any-of-the-elastic-or-public-ip-are-in-shodan" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. In the AWS Console, go to EC2\n2. In the left pane, select Network & Security > Elastic IPs\n3. Select the flagged Elastic IP\n4. If it is associated: click Actions > Disassociate Elastic IP address > Disassociate\n5. Click Actions > Release Elastic IP address > Release", "Terraform": "" }, "Recommendation": { - "Text": "Check Identified IPs, consider changing them to private ones and delete them from Shodan.", - "Url": "https://www.shodan.io/" + "Text": "Reduce attack surface with **defense in depth**:\n- Avoid public exposure; use private networking or proxies\n- Enforce **least-privilege** ingress rules; close unused ports\n- Patch and harden services; limit verbose banners\n- Rotate exposed IPs and continuously monitor external visibility", + "Url": "https://hub.prowler.com/check/ec2_elastic_ip_shodan" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json b/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json index 6c62777b0b..d94244ada3 100644 --- a/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_elastic_ip_unassigned/ec2_elastic_ip_unassigned.metadata.json @@ -1,32 +1,38 @@ { "Provider": "aws", "CheckID": "ec2_elastic_ip_unassigned", - "CheckTitle": "Check if there is any unassigned Elastic IP.", + "CheckTitle": "Elastic IP is associated with an instance or network interface", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Resource Consumption" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsEc2Eip", "ResourceGroup": "network", - "Description": "Check if there is any unassigned Elastic IP.", - "Risk": "Unassigned Elastic IPs may result in extra cost.", + "Description": "**EC2 Elastic IPs** that are allocated but **not associated** with any instance or network interface. The evaluation identifies EIPs present in the account without an active association.", + "Risk": "Unused Elastic IPs consume public IPv4 capacity and incur ongoing charges. Hoarded addresses can exhaust quotas, blocking new allocations and delaying deployments (**availability**). Lack of ownership tracking increases operational drift and misconfigurations, risking unintended exposure when later reassigned.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 release-address --public-ip ", - "NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/general_19#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/general-policies/general_19#ec2-console", - "Terraform": "https://docs.prowler.com/checks/aws/general-policies/general_19#terraform" + "CLI": "aws ec2 release-address --allocation-id ", + "NativeIaC": "```yaml\n# Associate an existing unassigned Elastic IP to an instance\nResources:\n :\n Type: AWS::EC2::EIPAssociation\n Properties:\n AllocationId: # Critical: selects the unassigned EIP to associate\n InstanceId: # Critical: associates the EIP to this instance, fixing the finding\n```", + "Other": "1. In the AWS console, go to EC2 > Network & Security > Elastic IPs\n2. Select the Elastic IP with Status = Not associated\n3. Choose Actions > Associate Elastic IP address\n4. Select Instance (or Network interface), pick the target, and click Associate\n5. Alternatively, to remove the finding by deleting the unused EIP: Actions > Release Elastic IP address > Release", + "Terraform": "```hcl\n# Associate an existing unassigned Elastic IP to an instance\nresource \"aws_eip_association\" \"\" {\n allocation_id = \"\" # Critical: target unassigned EIP\n instance_id = \"\" # Critical: attach EIP to this instance to pass the check\n}\n```" }, "Recommendation": { - "Text": "Ensure Elastic IPs are not unassigned.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html" + "Text": "Release **unused Elastic IPs** or promptly associate them only where required. Enforce **least privilege** for address allocation, apply **tagging** to track ownership, and schedule periodic audits. Prefer **private networking** or managed front ends to reduce public IPv4 use. Automate reclaiming of `unassociated` addresses in lifecycle policies.", + "Url": "https://hub.prowler.com/check/ec2_elastic_ip_unassigned" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json index 93c5c3e260..513d93ce65 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_account_imdsv2_enabled/ec2_instance_account_imdsv2_enabled.metadata.json @@ -1,34 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_instance_account_imdsv2_enabled", - "CheckTitle": "Ensure Instance Metadata Service Version 2 (IMDSv2) is enforced for EC2 instances at the account level to protect against SSRF vulnerabilities.", + "CheckTitle": "IMDSv2 is required by default for EC2 instances at the account level", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Credential Access" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AwsAccount", - "ResourceGroup": "governance", - "Description": "Ensure Instance Metadata Service Version 2 (IMDSv2) is enforced for EC2 instances at the account level to protect against SSRF vulnerabilities.", - "Risk": "EC2 instances that use IMDSv1 are vulnerable to SSRF attacks.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html#set-imdsv2-account-defaults", + "ResourceType": "AwsEc2Instance", + "ResourceGroup": "compute", + "Description": "**EC2 account IMDS defaults** with `http_tokens`=`required` ensure new instances in the Region use **IMDSv2** by default and disable IMDSv1. *Existing instances keep their current setting.*", + "Risk": "Without a default of **IMDSv2**, new instances may enable **IMDSv1**, exposing metadata via simple HTTP. SSRF or proxy misconfigs can steal **temporary IAM credentials**, enabling data exfiltration (confidentiality), unauthorized API changes (integrity), and lateral movement that can disrupt services (availability).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html#set-imdsv2-account-defaults", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 modify-instance-metadata-defaults --region --http-tokens required --http-put-response-hop-limit 2", + "CLI": "aws ec2 modify-instance-metadata-defaults --region --http-tokens required", "NativeIaC": "", - "Other": "", + "Other": "1. In the AWS Console, open EC2 and select the target Region\n2. Go to EC2 Dashboard > Account attributes > Data protection and security\n3. Next to IMDS defaults, click Manage\n4. Set Metadata version to V2 only (token required)\n5. Click Update", "Terraform": "" }, "Recommendation": { - "Text": "Enable Instance Metadata Service Version 2 (IMDSv2) on the EC2 instances. Apply this configuration at the account level for each AWS Region to set the default instance metadata version.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html#set-imdsv2-account-defaults" + "Text": "Enforce **IMDSv2** at the account level in every Region by setting `http_tokens` to `required`. Add guardrails with **SCP/IAM conditions**. Standardize AMIs and launch templates to require tokens, validate workload compatibility, and apply **least privilege** to instance roles for defense in depth. *For containers*, prefer hop limit `2`.", + "Url": "https://hub.prowler.com/check/ec2_instance_account_imdsv2_enabled" } }, "Categories": [ - "internet-exposed", - "ec2-imdsv1" + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json index 60054d873b..90aeea9c90 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_detailed_monitoring_enabled/ec2_instance_detailed_monitoring_enabled.metadata.json @@ -1,32 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_instance_detailed_monitoring_enabled", - "CheckTitle": "Check if EC2 instances have detailed monitoring enabled.", + "CheckTitle": "EC2 instance has detailed monitoring enabled", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Check if EC2 instances have detailed monitoring enabled.", - "Risk": "Enabling detailed monitoring provides enhanced monitoring and granular insights into EC2 instance metrics. Not having detailed monitoring enabled may limit the ability to troubleshoot performance issues effectively.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html", + "Description": "**EC2 instances** are assessed for **CloudWatch detailed monitoring**, indicating whether 1-minute metrics collection is enabled.\n\nInstances lacking this setting provide only 5-minute metrics.", + "Risk": "Without 1-minute metrics, visibility drops, delaying detection of:\n- Sudden CPU/network/disk spikes affecting **availability**\n- **Malicious workloads** (crypto-mining, brute force)\n- **Data exfiltration** patterns\nSlower detection expands blast radius, raising incident impact and response cost.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/instance-detailed-monitoring.html", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html#enable-detailed-monitoring-instance" + ], "Remediation": { "Code": { "CLI": "aws ec2 monitor-instances --instance-ids ", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/instance-detailed-monitoring.html", - "Terraform": "https://docs.prowler.com/checks/aws/logging-policies/ensure-that-detailed-monitoring-is-enabled-for-ec2-instances#terraform" + "NativeIaC": "```yaml\n# CloudFormation: Enable detailed monitoring on an EC2 instance\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \"\"\n InstanceType: \"\"\n Monitoring: true # Critical: enables detailed (1-minute) CloudWatch monitoring\n```", + "Other": "1. Open the AWS Console and go to EC2 > Instances\n2. Select the instance\n3. Choose Actions > Monitor and troubleshoot > Manage detailed monitoring\n4. Check Enable detailed monitoring and click Save", + "Terraform": "```hcl\n# Enable detailed monitoring on an EC2 instance\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"\"\n monitoring = true # Critical: enables detailed (1-minute) CloudWatch monitoring\n}\n```" }, "Recommendation": { - "Text": "Enable detailed monitoring for EC2 instances to gain better insights into performance metrics.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html#enable-detailed-monitoring-instance" + "Text": "Enable **detailed monitoring** to collect `1-minute` metrics on critical instances. Use **defense in depth**: baseline normal behavior, create alerts for anomalies, and correlate metrics with logs and traces. Review dashboards regularly. *If costs matter*, prioritize production, internet-facing, and autoscaling fleets.", + "Url": "https://hub.prowler.com/check/ec2_instance_detailed_monitoring_enabled" } }, - "Categories": [], + "Categories": [ + "logging", + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json index d829d5f396..1fec3375c5 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_imdsv2_enabled/ec2_instance_imdsv2_enabled.metadata.json @@ -1,33 +1,42 @@ { "Provider": "aws", "CheckID": "ec2_instance_imdsv2_enabled", - "CheckTitle": "Check if EC2 Instance Metadata Service Version 2 (IMDSv2) is Enabled and Required.", + "CheckTitle": "EC2 instance requires IMDSv2 or has the instance metadata service disabled", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Credential Access" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Check if EC2 Instance Metadata Service Version 2 (IMDSv2) is Enabled and Required.", - "Risk": "Using IMDSv2 will protect from misconfiguration and SSRF vulnerabilities. IMDSv1 will not.", + "Description": "**EC2 instances** are evaluated for **IMDSv2 enforcement**: metadata endpoint enabled with `http_tokens: required`, or metadata service fully disabled (`http_endpoint: disabled`).", + "Risk": "Permitting **IMDSv1** or optional tokens lets SSRF or compromised workloads retrieve **temporary IAM credentials**, impacting confidentiality and integrity. Stolen role creds can drive **privilege escalation**, unauthorized data access, and lateral movement across AWS resources.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html", + "https://support.icompaas.com/support/solutions/articles/62000234166-5-7-ensure-that-the-ec2-metadata-service-only-allows-imdsv2-automated-", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#configuring-instance-metadata-options" + ], "Remediation": { "Code": { "CLI": "aws ec2 modify-instance-metadata-options --instance-id --http-tokens required --http-endpoint enabled", - "NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_31#cloudformation", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html", - "Terraform": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_31#terraform" + "NativeIaC": "```yaml\n# CloudFormation: enforce IMDSv2 on an EC2 instance\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \"\"\n InstanceType: \"\"\n MetadataOptions:\n HttpTokens: required # Critical: Require IMDSv2 tokens (blocks IMDSv1)\n```", + "Other": "1. In AWS Console, go to EC2 > Instances\n2. Select the instance > Actions > Instance settings > Modify instance metadata options\n3. Set Metadata version to IMDSv2 only (HTTP tokens: Required)\n4. Ensure Instance metadata service is Enabled (or set to Disabled to turn off IMDS entirely)\n5. Click Save", + "Terraform": "```hcl\n# Enforce IMDSv2 on an EC2 instance\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"\"\n\n metadata_options {\n http_tokens = \"required\" # Critical: Require IMDSv2 tokens (blocks IMDSv1)\n }\n}\n```" }, "Recommendation": { - "Text": "If you don't need IMDS you can turn it off. Using aws-cli you can force the instance to use only IMDSv2.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#configuring-instance-metadata-options" + "Text": "Apply defense in depth:\n- Require **IMDSv2** tokens on all instances (`http_tokens: required`)\n- Disable metadata where not needed (`http_endpoint: disabled`)\n- Minimize hop limit to `1` when feasible\n- Update SDKs/apps for IMDSv2\n- Restrict instance profile permissions (least privilege)\n- Block metadata access from untrusted workloads", + "Url": "https://hub.prowler.com/check/ec2_instance_imdsv2_enabled" } }, "Categories": [ - "ec2-imdsv1" + "identity-access", + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json index 97dd166750..bd2c1d7278 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_internet_facing_with_instance_profile/ec2_instance_internet_facing_with_instance_profile.metadata.json @@ -1,33 +1,41 @@ { "Provider": "aws", "CheckID": "ec2_instance_internet_facing_with_instance_profile", - "CheckTitle": "Check for internet facing EC2 instances with Instance Profiles attached.", + "CheckTitle": "EC2 instance is not internet-facing with an instance profile attached", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access", + "TTPs/Credential Access" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "medium", + "ResourceIdTemplate": "", + "Severity": "high", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Check for internet facing EC2 instances with Instance Profiles attached.", - "Risk": "Exposing an EC2 directly to internet increases the attack surface and therefore the risk of compromise.", + "Description": "**EC2 instances** with a public IP address and an attached **instance profile** (IAM role) are identified.\n\nInstances lacking public exposure or without an instance profile are excluded.", + "Risk": "Publicly reachable instances with **IAM role credentials** expand the blast radius. Remote exploits or misconfigurations can steal credentials via the **instance metadata service**, enabling unauthorized API calls, data exfiltration, and lateral movement, impacting confidentiality, integrity, and availability.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html", + "https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/", + "https://support.icompaas.com/support/solutions/articles/62000127121-ensure-instance-profile-is-attached-for-internet-facing-ec2-instances" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 disassociate-iam-instance-profile --association-id ", + "NativeIaC": "```yaml\n# CloudFormation: EC2 instance without a public IP to avoid being internet-facing\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: t3.micro\n NetworkInterfaces:\n - DeviceIndex: 0\n SubnetId: \n AssociatePublicIpAddress: false # Critical: disables public IPv4 so the instance is not internet-facing\n```", + "Other": "1. In the AWS Console, go to EC2 > Instances and select the instance\n2. Choose Actions > Security > Modify IAM role\n3. Set IAM role to None and click Update IAM role\n4. Verify the instance no longer lists an IAM role (instance profile)\n\nAlternative (if you need the role): remove internet exposure\n1. Select the instance > Networking tab\n2. If an Elastic IP is attached, choose Disassociate Elastic IP\n3. For auto-assigned public IPv4, stop the instance and relaunch without a public IP or in a subnet without auto-assign public IPv4", + "Terraform": "```hcl\n# EC2 instance without a public IP to avoid being internet-facing\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"t3.micro\"\n associate_public_ip_address = false # Critical: disables public IPv4 so the instance is not internet-facing\n}\n```" }, "Recommendation": { - "Text": "Use an ALB and apply WAF ACL.", - "Url": "https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/" + "Text": "Avoid direct Internet exposure. Place workloads behind an **Application Load Balancer** and protect HTTP apps with **WAF**. Remove public IPs or restrict ingress to trusted sources. Apply **least privilege** to instance profiles and enforce **IMDSv2**. Use **bastion hosts** or **Session Manager** for admin access.", + "Url": "https://hub.prowler.com/check/ec2_instance_internet_facing_with_instance_profile" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json index cff3ac2e09..41f39bb5f5 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_managed_by_ssm/ec2_instance_managed_by_ssm.metadata.json @@ -1,32 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_instance_managed_by_ssm", - "CheckTitle": "Check if EC2 instances are managed by Systems Manager.", + "CheckTitle": "EC2 instance is managed by AWS Systems Manager or not running", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Patch Management" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Check if EC2 instances are managed by Systems Manager.", - "Risk": "AWS Config provides AWS Managed Rules, which are predefined, customizable rules that AWS Config uses to evaluate whether your AWS resource configurations comply with common best practices.", + "Description": "**EC2 instances** are assessed for enrollment as **Systems Manager managed nodes**. Running instances lacking Systems Manager registration are marked as unmanaged; instances in `stopped`, `terminated`, or `pending` states are noted separately.", + "Risk": "Unmanaged instances lack centralized patching, inventory, and secure remote access. This increases exposure to brute force on SSH/RDP, delayed patching, and poor visibility. Exploits can enable lateral movement and persistence, degrading confidentiality, integrity, and availability.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/SSM/ssm-managed-instances.html", + "https://docs.aws.amazon.com/systems-manager/latest/userguide/managed_instances.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/SSM/ssm-managed-instances.html", - "Terraform": "" + "CLI": "aws ec2 stop-instances --instance-ids ", + "NativeIaC": "```yaml\n# CloudFormation: make the instance SSM-managed by attaching the required IAM role\nResources:\n Role:\n Type: AWS::IAM::Role\n Properties:\n AssumeRolePolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n Service: ec2.amazonaws.com\n Action: sts:AssumeRole\n ManagedPolicyArns:\n - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore # CRITICAL: grants SSM permissions required for management\n\n InstanceProfile:\n Type: AWS::IAM::InstanceProfile\n Properties:\n Roles:\n - !Ref Role\n\n Instance:\n Type: AWS::EC2::Instance\n Properties:\n ImageId: ami-\n InstanceType: t3.micro\n IamInstanceProfile: !Ref InstanceProfile # CRITICAL: attaches the SSM-enabled role to the instance\n```", + "Other": "1. In IAM console: Create role > AWS service > EC2 > Next; attach policy \"AmazonSSMManagedInstanceCore\"; Create role\n2. In EC2 console: Instances > select the instance > Actions > Security > Modify IAM role > choose the role created above > Update IAM role\n3. Wait a few minutes; in Systems Manager console: Managed nodes, verify the instance shows as Online\n4. If the instance OS does not include SSM Agent by default, install the SSM Agent for that OS, then verify again", + "Terraform": "```hcl\n# Terraform: make the instance SSM-managed by attaching the required IAM role\nresource \"aws_iam_role\" \"\" {\n name = \"\"\n assume_role_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Principal = { Service = \"ec2.amazonaws.com\" }\n Action = \"sts:AssumeRole\"\n }]\n })\n}\n\nresource \"aws_iam_role_policy_attachment\" \"_ssm\" {\n role = aws_iam_role..name\n policy_arn = \"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore\" # CRITICAL: grants SSM permissions required for management\n}\n\nresource \"aws_iam_instance_profile\" \"\" {\n name = \"\"\n role = aws_iam_role..name\n}\n\nresource \"aws_instance\" \"\" {\n ami = \"ami-\"\n instance_type = \"t3.micro\"\n iam_instance_profile = aws_iam_instance_profile..name # CRITICAL: attaches the SSM-enabled role to the instance\n}\n```" }, "Recommendation": { - "Text": "Verify and apply Systems Manager Prerequisites.", - "Url": "https://docs.aws.amazon.com/systems-manager/latest/userguide/managed_instances.html" + "Text": "Enroll all instances as **Systems Manager managed nodes**. Prefer **Session Manager** over SSH/RDP, restrict inbound admin ports, and use **least privilege** roles. Ensure connectivity to SSM endpoints (or private endpoints), automate patching and inventory, and monitor activity for defense-in-depth.", + "Url": "https://hub.prowler.com/check/ec2_instance_managed_by_ssm" } }, - "Categories": [], + "Categories": [ + "node-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json index d850e0f2c6..fb34f8b1c7 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_older_than_specific_days/ec2_instance_older_than_specific_days.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_instance_older_than_specific_days", - "CheckTitle": "Check EC2 Instances older than specific days.", + "CheckTitle": "EC2 instance is not older than the configured maximum age or is not running", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Patch Management" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Check EC2 Instances older than specific days.", - "Risk": "Having old instances within your AWS account could increase the risk of having vulnerable software.", + "Description": "**EC2 instances** are evaluated for age while in `running` state. Instances launched beyond the configurable limit (`max_ec2_instance_age_in_days`, default `180`) are flagged as older than the allowed lifetime. Stopped instances are ignored.", + "Risk": "Long-lived instances often run **unpatched OS and agents**, enabling:\n- Exploitation of known CVEs loss of confidentiality\n- Privilege escalation and tampering integrity compromise\n- Malware/crypto-mining and instability reduced availability\n\nAged hosts also drift from baselines and impede response.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/systems-manager/latest/userguide/viewing-patch-compliance-results.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/ec2-instance-too-old.html" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aws ec2 stop-instances --instance-ids ", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/ec2-instance-too-old.html", + "Other": "1. Sign in to the AWS Management Console and open EC2\n2. Go to Instances and select the noncompliant instance\n3. Choose Instance state > Stop instance\n4. Confirm Stop\n5. Verify the instance state is Stopped (the check passes when the instance is not running)", "Terraform": "" }, "Recommendation": { - "Text": "Check if software running in the instance is up to date and patched accordingly. Use AWS Systems Manager to patch instances and view patching compliance information.", - "Url": "https://docs.aws.amazon.com/systems-manager/latest/userguide/viewing-patch-compliance-results.html" + "Text": "Adopt **short-lived, patched workloads**:\n- Rebuild regularly from hardened, updated images; rotate AMIs\n- Use centralized patch management and vulnerability scanning\n- Retire or modernize legacy hosts; tag for lifecycle\n- Apply **least privilege** and **defense in depth** to limit blast radius\n\nAdjust `max_ec2_instance_age_in_days` to match policy.", + "Url": "https://hub.prowler.com/check/ec2_instance_older_than_specific_days" } }, "Categories": [], diff --git a/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json index 8dbe7b9788..6f8071954b 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_paravirtual_type/ec2_instance_paravirtual_type.metadata.json @@ -1,30 +1,39 @@ { "Provider": "aws", "CheckID": "ec2_instance_paravirtual_type", - "CheckTitle": "Amazon EC2 paravirtual virtualization type should not be used.", - "CheckType": [], + "CheckTitle": "EC2 instance virtualization type is HVM", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure that the virtualization type of an EC2 instance is not paravirtual. The control fails if the virtualizationType of the EC2 instance is set to paravirtual.", - "Risk": "Using paravirtual instances can limit performance and security benefits offered by hardware virtual machine (HVM) instances, such as improved CPU, network, and storage efficiency.", - "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-paravirtual-instance-check.html", + "Description": "**EC2 instances** are evaluated for their virtualization mode. Instances with `virtualization_type` set to `paravirtual` are identified; those using **HVM** are recognized as hardware-assisted virtualization.", + "Risk": "Using **paravirtual (PV)** weakens isolation versus **HVM/Nitro** and blocks features like `ENA` and `NVMe`. Confidentiality and integrity can suffer due to reliance on legacy hypercalls/drivers; availability and performance may degrade under load, increasing exposure to kernel/driver exploits and noisy-neighbor impacts.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-24", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-resize.html", + "https://docs.aws.amazon.com/config/latest/developerguide/ec2-paravirtual-instance-check.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-24", - "Terraform": "" + "CLI": "aws ec2 terminate-instances --instance-ids ", + "NativeIaC": "```yaml\n# Launch an EC2 instance using an HVM-based AMI\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: # Critical: Using an HVM AMI ensures virtualization type is HVM\n InstanceType: t3.micro\n```", + "Other": "1. In the AWS Console, go to EC2 > Instances and select the instance with Virtualization type = paravirtual\n2. Launch a replacement instance using any HVM-based AMI (e.g., Amazon Linux 2)\n3. Verify the new instance is running\n4. Back in EC2 > Instances, select the paravirtual instance, choose Instance state > Terminate instance, and confirm", + "Terraform": "```hcl\n# EC2 instance launched from an HVM AMI\nresource \"aws_instance\" \"\" {\n ami = \"\" # Critical: AMI must be HVM to pass the check\n instance_type = \"t3.micro\"\n}\n```" }, "Recommendation": { - "Text": "To update an EC2 instance to a new instance type, see Change the instance type in the Amazon EC2 User Guide.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-resize.html" + "Text": "Standardize on **HVM/Nitro**. Migrate PV workloads to HVM AMIs and current instance families; ensure support for `ENA` and `NVMe`, current kernels, and hardened configs. Apply **defense in depth** and **least privilege**. Use immutable images with staged testing, then retire PV images to prevent drift and regressions.", + "Url": "https://hub.prowler.com/check/ec2_instance_paravirtual_type" } }, - "Categories": [], + "Categories": [ + "node-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json index 6a4161a0e5..af54b617e8 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_cassandra_exposed_to_internet/ec2_instance_port_cassandra_exposed_to_internet.metadata.json @@ -1,29 +1,35 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_cassandra_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to Cassandra ports (TCP 7000, 7001, 7199, 9042, 9160).", + "CheckTitle": "EC2 instance does not have Cassandra ports (TCP 7000, 7001, 7199, 9042, 9160) open to the Internet", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to Cassandra ports (TCP 7000, 7001, 7199, 9042, 9160).", - "Risk": "Cassandra is a distributed database management system designed to handle large amounts of data across many commodity servers, providing high availability with no single point of failure. Exposing Cassandra ports to the internet can lead to unauthorized access to the database, data exfiltration, and data loss.", + "Description": "**EC2 instances** have **Cassandra service ports** (`7000`, `7001`, `7199`, `9042`, `9160`) reachable from the Internet through security group ingress.\n\nPublic IP presence and subnet exposure are considered to assess external reachability.", + "Risk": "Internet-exposed Cassandra enables unauthorized queries on `9042`, remote management via `7199` (JMX), and tampering with inter-node channels on `7000/7001` and `9160`.\n\nAttackers can read/modify data (**confidentiality, integrity**), disrupt or take over the cluster (**availability**), and pivot within the VPC.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000127020-ensure-security-groups-do-not-allow-unrestricted-ingress-access-to-cassandra-ports-7199-or-9160-or-88" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":7000,\"ToPort\":7000,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":7001,\"ToPort\":7001,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":7199,\"ToPort\":7199,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":9042,\"ToPort\":9042,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":9160,\"ToPort\":9160,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]]'", + "NativeIaC": "```yaml\n# Restrict Cassandra ports so they are not open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Cassandra ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 7000\n ToPort: 7000\n CidrIp: 10.0.0.0/8 # FIX: not 0.0.0.0/0; restricts Internet access\n - IpProtocol: tcp\n FromPort: 7001\n ToPort: 7001\n CidrIp: 10.0.0.0/8 # FIX\n - IpProtocol: tcp\n FromPort: 7199\n ToPort: 7199\n CidrIp: 10.0.0.0/8 # FIX\n - IpProtocol: tcp\n FromPort: 9042\n ToPort: 9042\n CidrIp: 10.0.0.0/8 # FIX\n - IpProtocol: tcp\n FromPort: 9160\n ToPort: 9160\n CidrIp: 10.0.0.0/8 # FIX\n```", + "Other": "1. Open the AWS Console > EC2 > Instances and select the instance\n2. In the Security tab, click the attached Security Group(s)\n3. Click Edit inbound rules\n4. Remove or change any rule allowing TCP 7000, 7001, 7199, 9042, or 9160 from Anywhere (0.0.0.0/0 or ::/0)\n5. If needed, re-add those ports with a specific trusted source CIDR or security group\n6. Save rules", + "Terraform": "```hcl\n# Security group with Cassandra ports not open to the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n # FIX: restrict these ports; do not use 0.0.0.0/0\n ingress { from_port = 7000 to_port = 7000 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 7001 to_port = 7001 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 7199 to_port = 7199 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 9042 to_port = 9042 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n ingress { from_port = 9160 to_port = 9160 protocol = \"tcp\" cidr_blocks = [\"10.0.0.0/8\"] }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP ports 7000, 7001, 7199, 9042 or 9160.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege network access**:\n- Remove `0.0.0.0/0` and `::/0` to Cassandra ports\n- Allow only trusted subnets or VPN/bastion\n- Keep nodes in private subnets; segment inter-node traffic\n- Enforce **authentication** and **TLS/mTLS** for clients and JMX\n- Add **defense in depth** with NACLs and monitoring", + "Url": "https://hub.prowler.com/check/ec2_instance_port_cassandra_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json index 1a013c4f8e..133c565e04 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_cifs_exposed_to_internet/ec2_instance_port_cifs_exposed_to_internet.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_cifs_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 139 or 445 (CIFS).", + "CheckTitle": "EC2 instance does not allow Internet ingress to TCP ports 139 or 445 (CIFS)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 139 or 445 (CIFS).", - "Risk": "CIFS is a file sharing protocol that is used to access files and printers on remote systems. It is not recommended to expose CIFS to the internet.", + "Description": "**EC2 instances** with security groups permitting **inbound** TCP `139` or `445` (**CIFS/SMB**) from `0.0.0.0/0` are identified.\n\nExposure level reflects whether the instance has a **public IP** and the subnet's Internet reachability.", + "Risk": "Publicly reachable **SMB** allows unauthorized access and **remote code execution**, enabling credential theft, NTLM relay, and share enumeration. Attackers can exfiltrate files, tamper or delete data, and spread **ransomware**, degrading **confidentiality**, **integrity**, and **availability**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-cifs-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":139,\"ToPort\":139,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":445,\"ToPort\":445,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: Security group without CIFS open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: SG without CIFS open to Internet\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 139\n ToPort: 139\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict CIFS (139) to a non-Internet CIDR to avoid 0.0.0.0/0\n - IpProtocol: tcp\n FromPort: 445\n ToPort: 445\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict CIFS (445) to a non-Internet CIDR to avoid 0.0.0.0/0\n```", + "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Edit Inbound rules\n4. Delete any rule allowing TCP port 139 or 445 from 0.0.0.0/0 or ::/0, or change the source to a specific trusted CIDR\n5. Save rules", + "Terraform": "```hcl\n# Security group without CIFS open to the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 139\n to_port = 139\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restrict CIFS (139); do not use 0.0.0.0/0\n }\n\n ingress {\n from_port = 445\n to_port = 445\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restrict CIFS (445); do not use 0.0.0.0/0\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 139 or 445 (CIFS).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict **CIFS/SMB** to trusted internal sources using **least privilege**; do not allow `0.0.0.0/0`.\n\nAdopt **defense in depth**: place hosts in private subnets, require **VPN** or controlled jump paths, and enforce **segmentation**. Disable SMB if unnecessary or use alternatives (e.g., SFTP). Require strong auth and SMB signing.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_cifs_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json index 974616bd14..a279f96116 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_elasticsearch_kibana_exposed_to_internet/ec2_instance_port_elasticsearch_kibana_exposed_to_internet.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_elasticsearch_kibana_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to Elasticsearch and Kibana ports (TCP 9200, 9300, 5601).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to Elasticsearch and Kibana ports (TCP 9200, 9300, 5601)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to Elasticsearch and Kibana ports (TCP 9200, 9300, 5601).", - "Risk": "Elasticsearch and Kibana are commonly used for log and data analysis. Allowing ingress from the internet to these ports can expose sensitive data to unauthorized users.", + "Description": "**EC2 instances** with **Elasticsearch/Kibana ports** (`9200`, `9300`, `5601`) exposed to the Internet through inbound security group rules.\n\nAssesses reachability considering instance public IP and subnet to reflect real exposure.", + "Risk": "Public access to Elasticsearch/Kibana can lead to:\n- Unauthorized queries or dashboard viewing confidentiality loss\n- Index changes or cluster control via `9300` integrity impact\n- Scans and bulk queries availability degradation\n\nEnables data exfiltration and lateral movement.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233821-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-elasticsearch-and-kibana-ports-tcp-9200-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":9200,\"ToPort\":9200,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":9300,\"ToPort\":9300,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":5601,\"ToPort\":5601,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict Elasticsearch/Kibana ports to a private CIDR (not the Internet)\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Elasticsearch/Kibana ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 9200\n ToPort: 9200\n CidrIp: 10.0.0.0/8 # CRITICAL: do not use 0.0.0.0/0 or ::/0; restrict to internal CIDR\n - IpProtocol: tcp\n FromPort: 9300\n ToPort: 9300\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict source to stop Internet exposure\n - IpProtocol: tcp\n FromPort: 5601\n ToPort: 5601\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict source to stop Internet exposure\n```", + "Other": "1. Open the AWS Console and go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. In Inbound rules, find any rule allowing TCP 9200, 9300, or 5601 from 0.0.0.0/0 or ::/0\n4. Edit inbound rules and either delete those rules or change the source to a restricted CIDR (e.g., your internal network)\n5. Save rules", + "Terraform": "```hcl\n# Terraform: restrict Elasticsearch/Kibana ports to a private CIDR (not the Internet)\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n # CRITICAL: restrict sources; do NOT use 0.0.0.0/0 or ::/0\n ingress {\n from_port = 9200\n to_port = 9200\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n ingress {\n from_port = 9300\n to_port = 9300\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n ingress {\n from_port = 5601\n to_port = 5601\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP ports 9200, 9300, 5601.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** to network exposure:\n- Restrict `9200`, `9300`, `5601` to trusted sources or keep them private\n- Use **private subnets**, **VPN/peering**, or **bastion/SSM** for admin access\n- Enforce **authentication** and **TLS** on Elasticsearch/Kibana\n- Avoid public IPs unless strictly required", + "Url": "https://hub.prowler.com/check/ec2_instance_port_elasticsearch_kibana_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json index 1017e862d2..7254e29872 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_ftp_exposed_to_internet/ec2_instance_port_ftp_exposed_to_internet.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_ftp_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 20 or 21 (FTP)", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 20 or 21 (FTP)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 20 or 21 (FTP).", - "Risk": "FTP is an insecure protocol and should not be used. If FTP is required, it should be used over a secure channel such as FTPS or SFTP.", + "Description": "**EC2 instances** with security groups permitting inbound **FTP** on `TCP 20-21` from any address (e.g., `0.0.0.0/0` or `::/0`) are identified.\n\nExposure is contextualized by the instance's public reachability (public IP and subnet).", + "Risk": "Exposed **FTP** invites Internet brute force and transmits in cleartext, enabling credential theft and packet sniffing (**confidentiality**).\n\nAttackers can upload/alter files (**integrity**) and abuse services for malware staging or DoS (**availability**). Publicly reachable hosts are rapidly probed by scanners.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-ftp-access.html", + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions 'IpProtocol=tcp,FromPort=20,ToPort=21,IpRanges=[{CidrIp=0.0.0.0/0}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict FTP (ports 20-21) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict FTP access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 20\n ToPort: 21\n CidrIp: 10.0.0.0/8 # CRITICAL: restrict source; not 0.0.0.0/0. Fixes Internet exposure.\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Find any rule allowing TCP ports 20-21 from 0.0.0.0/0 or ::/0\n5. Delete the rule, or change Source to a trusted CIDR (e.g., your office IP)\n6. Click Save rules", + "Terraform": "```hcl\n# Security group with FTP restricted\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 20\n to_port = 21\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restrict source; not 0.0.0.0/0. Fixes Internet exposure.\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 20 or 21 (FTP).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Deny public ingress to **FTP** ports `20-21` following **least privilege**. Prefer **SFTP** or **FTPS**; if transfers are required, restrict to trusted sources and use private access (VPN or dedicated network). Apply **defense in depth** with tightened security groups and network ACLs, and monitor authentication and access.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_ftp_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json index cca83acff0..852d4377bf 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_kafka_exposed_to_internet/ec2_instance_port_kafka_exposed_to_internet.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_kafka_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 9092 (Kafka).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 9092 (Kafka)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 9092 (Kafka).", - "Risk": "Kafka is a distributed streaming platform that is used to build real-time data pipelines and streaming applications. Exposing the Kafka port to the internet can lead to unauthorized access to the Kafka cluster, which can result in data leakage, data corruption, and data loss.", + "Description": "**EC2 instances** with security group rules that allow inbound `TCP 9092` (Kafka) from the Internet are reported. The evaluation inspects ingress rules to detect broad sources (for example `0.0.0.0/0` or `::/0`) that expose Kafka brokers.", + "Risk": "Public Kafka access undermines CIA: adversaries can read topics and metadata (**confidentiality**), publish or alter events (**integrity**), and overwhelm brokers (**availability**). Exposure also eases reconnaissance and lateral movement from the broker host.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233794-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-9092-kafka-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 9092 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict Kafka (9092) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Kafka port\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 9092\n ToPort: 9092\n CidrIp: 10.0.0.0/8 # Critical: do NOT use 0.0.0.0/0; restrict to trusted CIDR to close Internet access\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Remove the rule allowing TCP 9092 from 0.0.0.0/0 or ::/0 (Internet)\n5. If needed, add TCP 9092 with a restricted source (e.g., your VPC CIDR)\n6. Click Save rules", + "Terraform": "```hcl\n# Restrict Kafka (9092) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 9092\n to_port = 9092\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: restrict source; not 0.0.0.0/0\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 9092 (Kafka).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege**: restrict `TCP 9092` to trusted networks, not `0.0.0.0/0` or `::/0`. Keep brokers in private subnets and use private connectivity (VPN/peering). Enforce **TLS** and authenticated clients with granular ACLs, and add **defense in depth** via NACLs or proxies.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_kafka_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json index ffdb964ef7..c15d4f7eae 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_kerberos_exposed_to_internet/ec2_instance_port_kerberos_exposed_to_internet.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_kerberos_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 88, 464, 749 or 750 (Kerberos).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 88, 464, 749, or 750 (Kerberos)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access/Unauthorized Access" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 88, 464, 749 or 750 (Kerberos).", - "Risk": "Kerberos is a network authentication protocol that uses secret-key cryptography to authenticate clients and servers. It is typically used in environments where users need to authenticate to access network resources. If an EC2 instance allows ingress from the internet to TCP port 88 or 464, it may be vulnerable to unauthorized access.", + "Description": "**EC2 instances** whose security groups allow public **inbound TCP** access to Kerberos ports `88`, `464`, `749`, or `750` (authentication, password change, admin).\n\nRules permitting `0.0.0.0/0` or `::/0` are treated as Internet-exposed.", + "Risk": "Public Kerberos exposure risks CIA:\n- **Password spraying**/AS-REP roasting against accounts\n- Unauthorized password changes on `464`\n- Realm/user enumeration and DoS of KDC/services\n\nStolen tickets enable **lateral movement** and privilege escalation in Active Directory or the Kerberos realm.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233825-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-88-464-749-or-750-kerberos-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":88,\"ToPort\":88,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":464,\"ToPort\":464,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":749,\"ToPort\":749,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":750,\"ToPort\":750,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict Kerberos ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Kerberos ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 88\n ToPort: 88\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; restrict access to trusted CIDR\n - IpProtocol: tcp\n FromPort: 464\n ToPort: 464\n CidrIp: 10.0.0.0/8 # CRITICAL: blocks Internet exposure\n - IpProtocol: tcp\n FromPort: 749\n ToPort: 749\n CidrIp: 10.0.0.0/8 # CRITICAL: blocks Internet exposure\n - IpProtocol: tcp\n FromPort: 750\n ToPort: 750\n CidrIp: 10.0.0.0/8 # CRITICAL: blocks Internet exposure\n```", + "Other": "1. In the AWS console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Edit inbound rules\n4. Remove any rule allowing TCP ports 88, 464, 749, or 750 from 0.0.0.0/0 or ::/0\n5. If access is required, re-add these ports only from trusted CIDR(s) (e.g., your internal network)\n6. Save rules", + "Terraform": "```hcl\n# Terraform: restrict Kerberos ports from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 88\n to_port = 88\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; restrict to trusted CIDR\n }\n\n ingress {\n from_port = 464\n to_port = 464\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: blocks Internet exposure\n }\n\n ingress {\n from_port = 749\n to_port = 749\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: blocks Internet exposure\n }\n\n ingress {\n from_port = 750\n to_port = 750\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: blocks Internet exposure\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 88, 464, 749 or 750 (Kerberos).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict Kerberos ports to trusted sources only.\n- Prefer **private connectivity** (VPN, peering) over public exposure\n- Place KDCs/services in private subnets without public IPs\n- Apply **least privilege** with narrowly scoped security group rules and NACLs\n- Add defense-in-depth: host firewalls and monitor authentication activity", + "Url": "https://hub.prowler.com/check/ec2_instance_port_kerberos_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json index 564fd5153d..92cc42fefd 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_ldap_exposed_to_internet/ec2_instance_port_ldap_exposed_to_internet.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_ldap_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 389 or 636 (LDAP).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 389 or 636 (LDAP/LDAPS)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access/Unauthorized Access" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 389 or 636 (LDAP).", - "Risk": "LDAP is a protocol used for authentication and authorization. Exposing LDAP to the internet can lead to unauthorized access to the LDAP server and the data it contains.", + "Description": "**EC2 instances** with security groups permitting Internet-sourced access to **LDAP** on `TCP 389` or **LDAPS** on `TCP 636` are identified.\n\nPublic exposure context (presence of public IP and subnet reachability) is considered to gauge how broadly these ports can be accessed.", + "Risk": "Publicly reachable **LDAP/LDAPS** enables:\n- Directory enumeration and weak/anonymous bind attempts\n- **Password spraying** and credential theft (cleartext on `389`)\n- Unauthorized queries causing **data exfiltration**\n\nAbuse may lead to **privilege escalation** and availability impact via account lockouts.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":389,\"ToPort\":389,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":636,\"ToPort\":636,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict LDAP/LDAPS from the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restricted LDAP access\n VpcId: \"\"\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 389\n ToPort: 389\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; restricts LDAP (389) to internal range\n - IpProtocol: tcp\n FromPort: 636\n ToPort: 636\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; restricts LDAPS (636) to internal range\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. In Inbound rules, find rules for TCP 389 or 636 with Source set to Anywhere (0.0.0.0/0 or ::/0)\n4. Delete those rule(s)\n5. (If access is required) Add inbound rules for TCP 389 and/or 636 scoped to specific trusted CIDR(s) only\n6. Save rules", + "Terraform": "```hcl\n# Restrict LDAP/LDAPS from the Internet\nresource \"aws_security_group\" \"\" {\n name = \"restricted-ldap\"\n vpc_id = \"\"\n\n ingress {\n from_port = 389\n to_port = 389\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; restricts LDAP (389)\n }\n\n ingress {\n from_port = 636\n to_port = 636\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; restricts LDAPS (636)\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 389 or 636 (LDAP).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Limit LDAP to trusted networks:\n- Allowlist specific source CIDRs in security groups (*least privilege*)\n- Use **private connectivity** (peering/VPN) instead of Internet\n- Require **LDAPS**, strong certificates, and disable insecure binds\n- Add NACLs and monitoring for defense in depth\n\n*If external access is required*, place a proxy and enforce rate limits.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_ldap_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json index 608ed81cc2..8ec39a3011 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_memcached_exposed_to_internet/ec2_instance_port_memcached_exposed_to_internet.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_memcached_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 11211 (Memcached).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 11211 (Memcached)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 11211 (Memcached).", - "Risk": "Memcached is an open-source, high-performance, distributed memory object caching system. It is often used to speed up dynamic database-driven websites by caching data and objects in RAM to reduce the number of times an external data source must be read. Memcached is designed to be used in trusted environments and should not be exposed to the internet. If Memcached is exposed to the internet, it can be exploited by attackers to perform distributed denial-of-service (DDoS) attacks, data exfiltration, and other malicious activities.", + "Description": "**EC2 instances** are evaluated for **open Memcached access**: inbound `TCP 11211` allowed from any address (`0.0.0.0/0` or `::/0`) via their security groups, considering the instance's public exposure.", + "Risk": "Internet-exposed **Memcached** weakens:\n- **Availability**: abuse for reflection/amplification and resource exhaustion\n- **Confidentiality**: unauthorized reads of cached objects and metadata\n- **Integrity**: manipulation of cache entries influencing app behavior\n\nPublic reachability also aids reconnaissance and lateral movement.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000127021-ensure-security-groups-do-not-allow-unrestricted-ingress-access-to-memcached-port-11211" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 11211 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict Memcached (11211) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Memcached access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 11211\n ToPort: 11211\n CidrIp: 10.0.0.0/8 # FIX: not 0.0.0.0/0; limits access to internal range to avoid Internet exposure\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. In Inbound rules, find the rule allowing TCP 11211 from 0.0.0.0/0 or ::/0\n4. Delete the rule or edit the Source to a restricted range (e.g., a private CIDR or a specific security group)\n5. Save rules", + "Terraform": "```hcl\n# Security group with Memcached (11211) not exposed to the Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 11211\n to_port = 11211\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # FIX: not 0.0.0.0/0; restricts access to internal range\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 11211 (Memcached).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** on network access:\n- Restrict `TCP 11211` to trusted sources or internal subnets only\n- Place instances in private subnets; avoid public IPs\n- Layer **defense in depth** with NACLs and routing to block Internet paths\n- Prefer private connectivity (peering/VPN) and implement service-level authentication where available", + "Url": "https://hub.prowler.com/check/ec2_instance_port_memcached_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json index a738196e04..7c3cf7c05e 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_mongodb_exposed_to_internet/ec2_instance_port_mongodb_exposed_to_internet.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_mongodb_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 27017 or 27018 (MongoDB)", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 27017 or 27018 (MongoDB)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 27017 or 27018 (MongoDB).", - "Risk": "MongoDB is a popular NoSQL database that is often used in web applications. If an EC2 instance allows ingress from the internet to TCP port 27017 or 27018, it may be vulnerable to unauthorized access and data exfiltration.", + "Description": "**EC2 instances** with security groups permitting inbound `TCP 27017` or `27018` (MongoDB) from `0.0.0.0/0` or `::/0` are identified, factoring the instance's public reachability to gauge exposure.", + "Risk": "Internet-exposed MongoDB invites scanning, brute force, and exploits leading to:\n- Data extraction (**confidentiality**)\n- Collection tampering or deletion (**integrity**)\n- DoS or ransomware disruptions (**availability**)\nA compromised DB host can also enable lateral movement within the environment.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233752-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-27017-or-27018-mongodb-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":27017,\"ToPort\":27017,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":27018,\"ToPort\":27018,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict MongoDB ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict MongoDB ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 27017\n ToPort: 27017\n CidrIp: 10.0.0.0/8 # Critical: restricts 27017 to internal CIDR (not 0.0.0.0/0)\n - IpProtocol: tcp\n FromPort: 27018\n ToPort: 27018\n CidrIp: 10.0.0.0/8 # Critical: restricts 27018 to internal CIDR (not 0.0.0.0/0)\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Delete any rule allowing TCP port 27017 or 27018 from 0.0.0.0/0 or ::/0\n5. If access is required, add a rule for those ports limited to a specific trusted CIDR (e.g., your VPC CIDR)\n6. Click Save rules", + "Terraform": "```hcl\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n # Critical: restrict MongoDB ports to internal CIDR, not 0.0.0.0/0 or ::/0\n ingress {\n from_port = 27017\n to_port = 27017\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n\n ingress {\n from_port = 27018\n to_port = 27018\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 27017 or 27018 (MongoDB).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** to MongoDB access:\n- Remove Internet-wide rules; allow only trusted sources\n- Keep DBs on **private subnets** without public IPs; use private connectivity or proxies\n- Enforce strong auth and **TLS**\n- Add segmentation and monitoring for **defense in depth**", + "Url": "https://hub.prowler.com/check/ec2_instance_port_mongodb_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json index dbf2fd08fc..c67004ef3a 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_mysql_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 3306 (MySQL).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 3306 (MySQL)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 3306 (MySQL).", - "Risk": "MySQL is a popular open-source relational database management system that is widely used in web applications. Exposing MySQL to the internet can lead to unauthorized access and data exfiltration.", + "Description": "**EC2 instances** with security groups that expose **MySQL** on `TCP 3306` to the Internet (`0.0.0.0/0` or `::/0`) are identified, with context on public IP and subnet exposure.", + "Risk": "Publicly reachable **MySQL** enables Internet scanning, brute force, and credential stuffing, leading to unauthorized queries and data dumps (**confidentiality**). Attackers can alter or delete data (**integrity**), overload the service with query floods (**availability**), and pivot from the DB host into adjacent workloads.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-mysql-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 3306 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict MySQL (3306) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict MySQL access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 3306\n ToPort: 3306\n CidrIp: 10.0.0.0/8 # Critical: do NOT use 0.0.0.0/0; restrict 3306 to a private range\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Click Inbound rules > Edit inbound rules\n4. Find the rule allowing TCP 3306 from 0.0.0.0/0 or ::/0 and delete it\n5. (If access is required) Add a rule for TCP 3306 from a specific private CIDR or trusted IP range only\n6. Save rules", + "Terraform": "```hcl\n# Restrict MySQL (3306) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 3306\n to_port = 3306\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: do NOT use 0.0.0.0/0; restrict 3306 to a private CIDR\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 3306 (MySQL).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict `TCP 3306` to trusted sources per **least privilege**:\n- Allow DB access only from specific application subnets or security groups\n- Place database hosts in private subnets without public IPs\n- Apply **defense in depth** with VPN/peering for admin access, TLS for connections, and host firewalls; optionally reinforce with NACLs", + "Url": "https://hub.prowler.com/check/ec2_instance_port_mysql_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json index 1c73fb9265..fa42c016ab 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_oracle_exposed_to_internet/ec2_instance_port_oracle_exposed_to_internet.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_oracle_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 1521, 2483 or 2484 (Oracle).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 1521, 2483, or 2484 (Oracle)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 1521, 2483 or 2484 (Oracle).", - "Risk": "Oracle database servers are a high value target for attackers. Allowing internet access to these ports could lead to unauthorized access to the database.", + "Description": "**EC2 instances** with security groups allowing inbound `TCP` from any address to Oracle listener ports `1521`, `2483`, or `2484`", + "Risk": "Exposed Oracle listener ports enable SID enumeration, credential brute force, and TNS abuse. A successful intrusion can grant database access, causing data exfiltration (C), unauthorized changes (I), and outages via exploits or DoS (A). Internet scanning quickly finds these endpoints, enlarging the attack surface.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-oracle-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 1521 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: Restrict Oracle port from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Oracle port access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 1521\n ToPort: 1521\n CidrIp: 10.0.0.0/16 # FIX: do not use 0.0.0.0/0 or ::/0; limits access to a trusted CIDR to block Internet\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. For TCP ports 1521, 2483, and 2484, delete any rule with Source 0.0.0.0/0 or ::/0\n5. If access is required, change the Source to a specific trusted CIDR only\n6. Click Save rules", + "Terraform": "```hcl\n# Security group with Oracle port restricted from Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 1521\n to_port = 1521\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"] # FIX: restrict source; do not use 0.0.0.0/0 or ::/0\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 1521, 2483 or 2484.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict Oracle ports to trusted sources; remove `0.0.0.0/0` and `::/0`. Place databases in private subnets without public IPs. Use VPN/Direct Connect or bastions for access. Enable TLS on `2484`, strong auth, and apply **least privilege** rules with **defense in depth** using NACLs and monitoring.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_oracle_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json index 5e6a2c09d6..8ce8482a28 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_postgresql_exposed_to_internet/ec2_instance_port_postgresql_exposed_to_internet.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_postgresql_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 5432 (PostgreSQL)", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 5432 (PostgreSQL)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 5432 (PostgreSQL).", - "Risk": "PostgreSQL is a popular open-source relational database management system. Exposing the PostgreSQL port to the internet can lead to unauthorized access to the database, data exfiltration, and other security risks.", + "Description": "**EC2 instances** with security group rules allowing inbound **PostgreSQL** on `TCP 5432` from the Internet (`0.0.0.0/0` or `::/0`) are identified, considering the instance's public reachability via IP and subnet.", + "Risk": "Exposed `TCP 5432` enables unauthenticated Internet probes and **brute-force** attempts against PostgreSQL, risking database **confidentiality**, **integrity**, and **availability**. Attackers could dump data, alter schemas, create backdoor accounts, pivot within the VPC, or exploit unpatched flaws at scale.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-postgresql-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 5432 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict PostgreSQL (5432) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict PostgreSQL\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 5432\n ToPort: 5432\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; limits access to internal range\n```", + "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the group attached to the instance\n3. In Inbound rules, find any rule for PostgreSQL (TCP 5432) with source 0.0.0.0/0 or ::/0\n4. Delete the rule or change the source to a specific trusted CIDR only\n5. Save rules", + "Terraform": "```hcl\n# Restrict PostgreSQL (5432) from Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 5432\n to_port = 5432\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; prevents Internet exposure\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 5432 (PostgreSQL).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict PostgreSQL to trusted sources only:\n- Remove `0.0.0.0/0` and `::/0` rules\n- Apply **least privilege** security groups (allow from app tier or VPN)\n- Place instances in private subnets without public IPs\n- Enforce **TLS** and strong auth; disable unused listeners\n- Layer with NACLs and monitoring for **defense in depth**", + "Url": "https://hub.prowler.com/check/ec2_instance_port_postgresql_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json index 92dc5338e5..2caf66efa8 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_rdp_exposed_to_internet/ec2_instance_port_rdp_exposed_to_internet.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_rdp_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 3389 (RDP)", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 3389 (RDP)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/External Remote Services" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 3389 (RDP).", - "Risk": "RDP is a proprietary protocol developed by Microsoft for connecting to Windows systems. Exposing RDP to the internet can allow attackers to brute force the login credentials and gain unauthorized access to the EC2 instance.", + "Description": "**EC2 instances** whose security groups allow Internet-wide inbound **RDP** on `TCP 3389` (`0.0.0.0/0` or `::/0`). The instance's public IP and subnet routing are considered to determine external reachability.", + "Risk": "Internet-exposed **RDP** allows:\n- **Brute force** and credential reuse on Windows logons\n- Exploitation of RDP flaws for remote code execution\n- **Lateral movement** and data exfiltration\nThis threatens **confidentiality**, **integrity**, and **availability** through data theft, tampering, account lockouts, or ransomware.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233789-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-3389-rdp-", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-rdp-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 3389 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict RDP so it's not open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict RDP access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 3389\n ToPort: 3389\n CidrIp: # CRITICAL: limit RDP to a specific CIDR to avoid 0.0.0.0/0 (::/0)\n```", + "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Open each security group attached to the affected instance\n3. In Inbound rules, find any rule allowing TCP 3389 from 0.0.0.0/0 or ::/0\n4. Delete the rule, or edit Source to a specific trusted IP/CIDR only\n5. Click Save rules", + "Terraform": "```hcl\n# Restrict RDP so it's not open to the Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 3389\n to_port = 3389\n protocol = \"tcp\"\n cidr_blocks = [\"\"] # CRITICAL: restrict RDP to a specific CIDR; not 0.0.0.0/0 or ::/0\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 3389 (RDP).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Remove Internet-wide RDP. Apply **least privilege**:\n- Restrict `TCP 3389` to trusted IPs\n- Prefer private access via **VPN** or a hardened **bastion**; consider **Session Manager**\n- Use **just-in-time** access and short-lived rules\n- Enforce strong auth (e.g., NLA) and monitor logs\nAdopt **defense in depth** with layered network controls.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_rdp_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json index 10451778c6..d37769600e 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_redis_exposed_to_internet/ec2_instance_port_redis_exposed_to_internet.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_redis_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 6379 (Redis).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 6379 (Redis)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 6379 (Redis).", - "Risk": "Redis is an open-source, in-memory data structure store, used as a database, cache, and message broker. Redis is often used to store sensitive data, such as session tokens, user credentials, and other sensitive information. Allowing ingress from the internet to TCP port 6379 (Redis) can expose sensitive data to unauthorized users.", + "Description": "**EC2 instances** with security groups permitting Internet access to **Redis** on `TCP 6379` are identified.\n\nExposure is assessed using public IP assignment and subnet reachability to reflect how broadly the service can be contacted.", + "Risk": "Exposed **Redis** allows remote access to cached data and secrets, reducing **confidentiality**. Unauthorized commands (`SET`, `DEL`, `FLUSHALL`, config changes) can corrupt or erase data, harming **integrity**. Internet scanning and abuse can exhaust memory and disrupt service, degrading **availability** and enabling lateral movement.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233806-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-6379-redis-", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-redis-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 6379 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict Redis (6379) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Redis ingress\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 6379\n ToPort: 6379\n CidrIp: 10.0.0.0/8 # Critical: restrict source CIDR (not 0.0.0.0/0 or ::/0) to block Internet access\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Find any rule allowing TCP port 6379 from 0.0.0.0/0 or ::/0\n5. Delete the rule, or change the source to a specific trusted CIDR or security group\n6. Click Save rules", + "Terraform": "```hcl\n# Terraform: restrict Redis (6379) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 6379\n to_port = 6379\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: not 0.0.0.0/0 or ::/0; restrict to trusted range\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 6379 (Redis).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** network access: restrict Redis to trusted sources or VPC-only, place instances in private subnets, and avoid public IPs.\n\nLayer controls with **NACLs** and host firewalls, enforce **authentication and TLS** on Redis, and use **VPN/bastion** or proxies to broker access.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_redis_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json index 26c6be9f99..a23441b72b 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_sqlserver_exposed_to_internet/ec2_instance_port_sqlserver_exposed_to_internet.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_sqlserver_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 1433 or 1434 (SQL Server).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP ports 1433 or 1434 (SQL Server)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 1433 or 1434 (SQL Server).", - "Risk": "SQL Server is a database management system that is used to store and retrieve data. If an EC2 instance allows ingress from the internet to TCP port 1433 or 1434, it may be vulnerable to unauthorized access and data exfiltration.", + "Description": "**EC2 instances** with security groups permitting any source to `TCP 1433` or `1434` (SQL Server) are identified, considering the instance's public reachability based on IP and subnet exposure.", + "Risk": "Internet-reachable SQL services enable:\n- Brute-force and credential-stuffing of DB logins\n- Exploitation of SQL Server flaws for remote code execution\n- Unauthorized queries and data exfiltration\nThis threatens **confidentiality** and **integrity**, and facilitates **lateral movement** from the database host.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000223371-ensure-no-security-groups-allow-ingress-from-0-0-0-0-0-or-0-to-windows-sql-server-ports-1433-or-14", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-mssql-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":1433,\"ToPort\":1434,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict SQL Server ports to non-Internet sources\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict SQL ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 1433\n ToPort: 1434\n CidrIp: 10.0.0.0/8 # FIX: do not use 0.0.0.0/0; limits access to internal range to close Internet exposure\n```", + "Other": "1. In the AWS Console, go to VPC > Security Groups\n2. Select the security group attached to the affected EC2 instance\n3. In the Inbound rules tab, click Edit inbound rules\n4. Delete any rule allowing TCP 1433 or 1434 from 0.0.0.0/0 or ::/0\n5. If access is required, add a rule for TCP 1433-1434 with a specific trusted source (e.g., your office IP or another security group)\n6. Click Save rules", + "Terraform": "```hcl\n# Restrict SQL Server ports to non-Internet sources\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 1433\n to_port = 1434\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # FIX: do not use 0.0.0.0/0; restricts access to prevent Internet exposure\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group to remove the rule that allows ingress from the internet to TCP port 1433 or 1434 (SQL Server).", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Enforce **least privilege** and **defense in depth**:\n- Remove `0.0.0.0/0` and `::/0` to `1433-1434`\n- Allow only trusted IPs or app tiers via security group references\n- Keep databases in private subnets without public IPs; access via VPN or bastion\n- Require TLS and strong authentication; monitor access.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_sqlserver_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json index 2fbce38d91..727e089d00 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_ssh_exposed_to_internet/ec2_instance_port_ssh_exposed_to_internet.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_ssh_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 22 (SSH)", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 22 (SSH)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/External Remote Services" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 22 (SSH).", - "Risk": "SSH is a common target for brute force attacks. If an EC2 instance allows ingress from the internet to TCP port 22, it is at risk of being compromised.", + "Description": "**EC2 instances** with **SSH (TCP 22)** exposed to the Internet via security group inbound rules allowing `0.0.0.0/0` or `::/0`.\n\nExposure is qualified using the instance's public IP status and subnet reachability.", + "Risk": "**Internet-exposed SSH** invites **brute force** and **credential stuffing**. A successful sign-in grants **remote shell**, enabling data exfiltration, tampering of workloads, and **lateral movement** within the VPC, degrading confidentiality, integrity, and availability.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-ssh-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":22,\"ToPort\":22,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":22,\"ToPort\":22,\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: Restrict SSH so it's not open to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict SSH\n VpcId: \"\"\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 22\n ToPort: 22\n CidrIp: 10.0.0.0/8 # Critical: restrict SSH to a specific CIDR, not 0.0.0.0/0\n```", + "Other": "1. Open the Amazon EC2 console and go to Security Groups\n2. Select the security group attached to the instance\n3. Click Inbound rules > Edit inbound rules\n4. Delete any rule allowing SSH (port 22) from 0.0.0.0/0 or ::/0\n5. Save rules", + "Terraform": "```hcl\n# Terraform: Restrict SSH so it's not open to the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 22\n to_port = 22\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: restrict SSH to a specific CIDR, not 0.0.0.0/0\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 22.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** on SSH:\n- Restrict ingress to trusted IPs; avoid `0.0.0.0/0` and `::/0`\n- Prefer **Session Manager** or a hardened **bastion** behind VPN\n- Use **key-based auth**; disable passwords\n- Add **defense in depth** with network controls and monitor access logs", + "Url": "https://hub.prowler.com/check/ec2_instance_port_ssh_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json index 371501b8ca..733c055fea 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_telnet_exposed_to_internet/ec2_instance_port_telnet_exposed_to_internet.metadata.json @@ -1,29 +1,35 @@ { "Provider": "aws", "CheckID": "ec2_instance_port_telnet_exposed_to_internet", - "CheckTitle": "Ensure no EC2 instances allow ingress from the internet to TCP port 23 (Telnet).", + "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 23 (Telnet)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access/Unauthorized Access" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure no EC2 instances allow ingress from the internet to TCP port 23 (Telnet).", - "Risk": "Telnet is an insecure protocol that transmits data in plain text. Exposure of Telnet services to the internet can lead to unauthorized access to the EC2 instance.", + "Description": "EC2 instances with security groups allowing inbound **Telnet** on `TCP 23` from the Internet are identified, including open IPv4/IPv6 sources like `0.0.0.0/0` and `::/0`.\n\nExposure is evaluated considering public IP assignment and subnet reachability.", + "Risk": "Exposed **Telnet** weakens **confidentiality** and **integrity**: credentials and commands are plaintext, enabling interception and session hijacking. Attackers can brute-force to gain shell, run remote commands, exfiltrate data, and pivot laterally, also threatening **availability** through misuse or takeover.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-telnet-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 23 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict Telnet (port 23) from the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Telnet access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 23\n ToPort: 23\n CidrIp: 10.0.0.0/8 # Critical: do NOT use 0.0.0.0/0; restrict Telnet to trusted CIDR to remediate exposure\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the affected instance\n3. Open the Inbound rules tab and find any rule allowing TCP port 23 from 0.0.0.0/0 or ::/0\n4. Delete the rule, or edit it to a specific trusted CIDR only\n5. Click Save rules", + "Terraform": "```hcl\n# Restrict Telnet (port 23) from the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 23\n to_port = 23\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: do NOT use 0.0.0.0/0; restrict Telnet to trusted CIDR to fix the finding\n }\n}\n```" }, "Recommendation": { - "Text": "Modify the security group associated with the EC2 instance to remove the rule that allows ingress from the internet to TCP port 23.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Eliminate Telnet: disable the service and block `TCP 23`.\n\nApply **least privilege** network access-restrict admin connectivity via **SSH** through bastion or **VPN**, keep management paths private, and segregate hosts. Use **defense in depth** with monitoring and strong authentication for any legacy needs.", + "Url": "https://hub.prowler.com/check/ec2_instance_port_telnet_exposed_to_internet" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json index 43dfe535ea..6cc1b53430 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_profile_attached/ec2_instance_profile_attached.metadata.json @@ -1,32 +1,39 @@ { "Provider": "aws", "CheckID": "ec2_instance_profile_attached", - "CheckTitle": "Ensure IAM instance roles are used for AWS resource access from instances", + "CheckTitle": "EC2 instance is associated with an IAM instance profile role", "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Ensure IAM instance roles are used for AWS resource access from instances.", - "Risk": "AWS access from within AWS instances can be done by either encoding AWS keys into AWS API calls or by assigning the instance to a role which has an appropriate permissions policy for the required access. AWS IAM roles reduce the risks associated with sharing and rotating credentials that can be used outside of AWS itself. If credentials are compromised, they can be used from outside of the AWS account.", + "Description": "**EC2 instances** are evaluated for association with an **IAM instance profile role** that delivers temporary credentials to workloads running on the instance", + "Risk": "Without an instance profile, apps often rely on long-term access keys on the host. Exposed keys can be used from anywhere to read data, alter resources, or disrupt services, impacting confidentiality, integrity, and availability. Keys may persist in AMIs, images, or logs, hindering rotation and amplifying blast radius.", "RelatedUrl": "", + "AdditionalURLs": [ + "http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/ec2-instance-using-iam-roles.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://github.com/cloudmatos/matos/tree/master/remediations/aws/ec2/attach_iam_roles_ec2_instances", - "Terraform": "" + "CLI": "aws ec2 associate-iam-instance-profile --instance-id --iam-instance-profile Name=", + "NativeIaC": "```yaml\n# CloudFormation: attach an IAM instance profile to the EC2 instance\nResources:\n ExampleInstance:\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: \n IamInstanceProfile: # Critical: associates an instance profile so the check passes\n```", + "Other": "1. Open the AWS Management Console and go to EC2\n2. Select Instances and choose the target instance\n3. Click Actions > Security > Modify IAM role\n4. Select the IAM role (instance profile) to attach\n5. Click Update IAM role", + "Terraform": "```hcl\n# Attach an IAM instance profile to the EC2 instance\nresource \"aws_instance\" \"example\" {\n ami = \"\"\n instance_type = \"\"\n iam_instance_profile = \"\" # Critical: associates an instance profile so the check passes\n}\n```" }, "Recommendation": { - "Text": "Create an IAM instance role if necessary and attach it to the corresponding EC2 instance..", - "Url": "http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html" + "Text": "Attach an **IAM instance profile** to every instance and grant only permissions each workload requires (**least privilege**). Eliminate static keys on hosts; use **temporary credentials** with automatic rotation. Separate roles per application, enforce **separation of duties**, and limit who can assign roles (govern via `iam:PassRole`). Monitor role usage for anomalies.", + "Url": "https://hub.prowler.com/check/ec2_instance_profile_attached" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json index f835b8ae7b..509b7394c7 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_public_ip/ec2_instance_public_ip.metadata.json @@ -1,29 +1,35 @@ { "Provider": "aws", "CheckID": "ec2_instance_public_ip", - "CheckTitle": "Check for EC2 Instances with Public IP.", + "CheckTitle": "EC2 instance does not have a public IP address", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "instance", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Check for EC2 Instances with Public IP.", - "Risk": "Exposing an EC2 directly to internet increases the attack surface and therefore the risk of compromise.", + "Description": "**EC2 instances** are assessed for the presence of a **public IPv4 address** and public DNS. A public IP indicates the instance is directly reachable from the Internet; no public IP implies access only through private networking paths such as load balancers, gateways, or proxies.", + "Risk": "Publicly addressed instances are Internet-scannable, enabling direct probing and brute-force of exposed services and management ports. This increases risks of unauthorized access, remote code execution, and data exfiltration (**confidentiality, integrity**), and allows direct DDoS targeting, degrading **availability**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/aws-ec2-public-ip.html", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-instance-addressing.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/public-policies/public_12#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/public-policies/public_12#aws-console", - "Terraform": "https://docs.prowler.com/checks/aws/public-policies/public_12#terraform" + "NativeIaC": "```yaml\n# CloudFormation: Launch EC2 without a public IP\nResources:\n ExampleInstance:\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: \n NetworkInterfaces:\n - DeviceIndex: 0\n SubnetId: \n AssociatePublicIpAddress: false # CRITICAL: ensures the instance has no public IPv4 address\n```", + "Other": "1. In the AWS Console, go to EC2 > Instances and select the instance with a public IPv4 address\n2. Check the Networking tab to see if an Elastic IP is attached\n3. If an Elastic IP is attached:\n - Go to EC2 > Elastic IPs, select the address, choose Actions > Disassociate Elastic IP\n4. If the public IPv4 is auto-assigned (no Elastic IP shown):\n - Create a new instance (or an AMI from the current one) and, during launch, in Network settings, set Auto-assign public IP to Disable\n - Verify the new instance has no public IPv4, then migrate and terminate the old instance", + "Terraform": "```hcl\n# Terraform: Launch EC2 without a public IP\nresource \"aws_instance\" \"example\" {\n ami = \"\"\n instance_type = \"\"\n subnet_id = \"\"\n\n associate_public_ip_address = false # CRITICAL: ensures no public IPv4 is assigned\n}\n```" }, "Recommendation": { - "Text": "Use an ALB and apply WAF ACL.", - "Url": "https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/" + "Text": "Avoid assigning public IPs unless strictly required. Place workloads in private subnets and expose only via **load balancers** with **WAF**; use **bastions** or **Session Manager** for administration. Enforce **least privilege** security groups, prefer **private endpoints**, and route egress via **NAT** for **defense in depth**.", + "Url": "https://hub.prowler.com/check/ec2_instance_public_ip" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json index 2adc3bb922..455bfce9f7 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_secrets_user_data/ec2_instance_secrets_user_data.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_instance_secrets_user_data", - "CheckTitle": "Find secrets in EC2 User Data.", + "CheckTitle": "EC2 instance user data contains no secrets", "CheckType": [ - "IAM" + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Security", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:access-analyzer:region:account-id:analyzer/resource-id", - "Severity": "critical", + "ResourceIdTemplate": "", + "Severity": "high", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "Find secrets in EC2 User Data.", - "Risk": "Secrets hardcoded into instance user data can be used by malware and bad actors to gain lateral access to other services.", + "Description": "**EC2 instance User Data** is inspected for **secret-like values** (credentials, tokens, keys). Both plain and compressed content are parsed, honoring configured exclusions, to identify patterns that resemble sensitive material within initialization scripts.", + "Risk": "**Secrets embedded in User Data** undermine confidentiality and integrity. Anyone with instance or build-system access can read them, reuse credentials to call services, exfiltrate data, or move laterally. Exposure may persist in AMIs, snapshots, and backups, increasing blast radius over time.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/tutorials_basic.html", + "https://support.icompaas.com/support/solutions/articles/62000127092-ensure-no-secrets-are-found-in-ec2-user-data" + ], "Remediation": { "Code": { - "CLI": "aws ec2 describe-instance-attribute --attribute userData --region --instance-id --query UserData.Value --output text > encodeddata; base64 --decode encodeddata", - "NativeIaC": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_1#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_1", - "Terraform": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_1#terraform" + "CLI": "aws ec2 modify-instance-attribute --instance-id --user-data \"Value=\"", + "NativeIaC": "```yaml\n# CloudFormation: EC2 instance with empty user data\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: t3.micro\n UserData: !Base64 \"\" # Critical: empty user data ensures no secrets are present\n```", + "Other": "1. Open the AWS EC2 console and go to Instances\n2. Select the affected instance\n3. Click Actions > Instance settings > Edit user data\n4. Delete all contents of the user data field\n5. Click Save", + "Terraform": "```hcl\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"t3.micro\"\n user_data = \"\" # Critical: empty user data so no secrets are stored\n}\n```" }, "Recommendation": { - "Text": "Implement automated detective control (e.g. using tools like Prowler) to scan accounts for passwords and secrets. Use secrets manager service to store and retrieve passwords and secrets.", - "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/tutorials_basic.html" + "Text": "Avoid placing secrets in User Data. Store them in a **managed secret service** and fetch at runtime via a **least-privilege instance role**. Prefer short-lived credentials with **regular rotation**. Limit who can view or edit User Data and apply **defense in depth** with automated secret scanning in build pipelines.", + "Url": "https://hub.prowler.com/check/ec2_instance_secrets_user_data" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json index edc64e0fcf..60fa42cf35 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_uses_single_eni/ec2_instance_uses_single_eni.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_instance_uses_single_eni", - "CheckTitle": "Amazon EC2 instances should not use multiple ENIs", + "CheckTitle": "EC2 instance has no more than one Elastic Network Interface (ENI) attached", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:instance/resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "This control checks whether an EC2 instance uses multiple Elastic Network Interfaces (ENIs) or Elastic Fabric Adapters (EFAs). This control passes if a single network adapter is used. The control includes an optional parameter list to identify the allowed ENIs. This control also fails if an EC2 instance that belongs to an Amazon EKS cluster uses more than one ENI. If your EC2 instances need to have multiple ENIs as part of an Amazon EKS cluster, you can suppress those control findings.", - "Risk": "Multiple ENIs can cause dual-homed instances, meaning instances that have multiple subnets. This can add network security complexity and introduce unintended network paths and access.", - "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-instance-multiple-eni-check.html", + "Description": "**EC2 instances** are evaluated for attached network adapters. It identifies instances with more than one `ENI`-including `efa`, `interface`, or `trunk` types-and distinguishes those using a single adapter.", + "Risk": "**Multiple ENIs** create dual-homed hosts across subnets and security groups, enabling unintended routing and policy bypass. Adversaries can pivot between segments, use alternate egress for **data exfiltration**, or exploit asymmetric paths, undermining segmentation and **confidentiality/integrity** while complicating containment.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-eni.html#detach_eni", + "https://docs.aws.amazon.com/config/latest/developerguide/ec2-instance-multiple-eni-check.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-17" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-17", - "Terraform": "" + "CLI": "aws ec2 detach-network-interface --attachment-id ", + "NativeIaC": "```yaml\n# CloudFormation: ensure the instance has only one ENI\nResources:\n :\n Type: AWS::EC2::Instance\n Properties:\n ImageId: \n InstanceType: t3.micro\n SubnetId: # FIX: creates a single primary ENI; do not add extra NetworkInterfaces/attachments\n```", + "Other": "1. Open the AWS EC2 console and go to Network Interfaces\n2. Filter by the affected instance ID\n3. Select each non-primary network interface (Primary cannot be detached)\n4. Choose Actions > Detach\n5. Confirm the detach for each secondary ENI", + "Terraform": "```hcl\n# Terraform: instance with only the primary ENI\nresource \"aws_instance\" \"\" {\n ami = \"\"\n instance_type = \"t3.micro\"\n subnet_id = \"\" # FIX: only primary ENI; no additional network_interface attachments\n}\n```" }, "Recommendation": { - "Text": "To detach a network interface from an EC2 instance, follow the instructions in the Amazon EC2 User Guide.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-eni.html#detach_eni" + "Text": "Prefer a **single ENI per instance**.\n\nIf multi-homing is unavoidable:\n- Place ENIs in least-privilege subnets/SGs\n- Keep `source/destination check` enabled and routes explicit\n- Use gateways/LBs for NAT or ingress, not the host\n- Monitor flow logs and formally approve exceptions\n\nEmbed **defense in depth** and **zero trust**.", + "Url": "https://hub.prowler.com/check/ec2_instance_uses_single_eni" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json index cb207e36bf..abde63f8ea 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json @@ -1,30 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_instance_with_outdated_ami", - "CheckTitle": "Check for EC2 Instances Using Outdated AMIs", - "CheckType": [], + "CheckTitle": "EC2 instance uses a non-deprecated Amazon AMI", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Patch Management", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:instance/resource-id", - "Severity": "high", + "ResourceIdTemplate": "", + "Severity": "medium", "ResourceType": "AwsEc2Instance", "ResourceGroup": "compute", - "Description": "This check identifies EC2 instances using outdated Amazon Machine Images (AMIs) by auditing instances to gather AMI IDs, comparing them against the latest available versions, verifying suppo and security update status, and checking for deprecation.", - "Risk": "Using outdated AMIs can expose EC2 instances to security vulnerabilities, lack of support, and missing critical updates, increasing the risk of exploitation.", + "Description": "**EC2 instances** launched from **Amazon-owned AMIs** are evaluated for the AMI's `DeprecationTime`; instances tied to images with a deprecation date in the past are reported as using **deprecated AMIs**.", + "Risk": "Running on a **deprecated AMI** undermines security and availability:\n- Missing patches enable exploitation of known CVEs (confidentiality/integrity)\n- Unsupported components hinder hardening and forensics\n- AMI removal from catalogs complicates scale-out and recovery (availability)", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ami-deprecate.html", + "https://repost.aws/knowledge-center/ec2-find-deprecated-ami" + ], "Remediation": { "Code": { - "CLI": "aws ec2 describe-images --image-ids ", - "NativeIaC": "", - "Other": "https://repost.aws/knowledge-center/ec2-find-deprecated-ami", - "Terraform": "" + "CLI": "", + "NativeIaC": "```yaml\n# Use a non-deprecated Amazon AMI for instances launched via this template\nResources:\n :\n Type: AWS::EC2::LaunchTemplate\n Properties:\n LaunchTemplateData:\n ImageId: \"\" # Critical: Amazon-owned AMI with no DeprecationTime\n```", + "Other": "1. In the EC2 console, go to AMIs\n2. Set Owner to \"Amazon\" and ensure deprecated AMIs are not included; copy the AMI ID\n3. If using an Auto Scaling Group:\n - Launch templates > select the one in use > Create new version with Image ID set to the copied AMI and set it as default\n - Auto Scaling Groups > select the group > Start instance refresh\n4. If it is a standalone instance:\n - Launch a new instance using the copied Amazon AMI\n - Move workloads and terminate the old instance", + "Terraform": "```hcl\n# EC2 instance using a non-deprecated Amazon AMI\nresource \"aws_instance\" \"\" {\n ami = \"\" # Critical: Amazon-owned AMI with no DeprecationTime\n instance_type = \"t3.micro\"\n}\n```" }, "Recommendation": { - "Text": "Regularly update your EC2 instances to use the latest AMIs to ensure they receive the latest security patches and updates.", - "Url": "https://repost.aws/knowledge-center/ec2-find-deprecated-ami" + "Text": "Adopt **non-deprecated, maintained AMIs** and perform rolling replacements of affected instances. Standardize on hardened golden images with **regular AMI rotation** and `DeprecationTime` monitoring. Update launch templates/ASGs to reference current images. Automate patching via an image pipeline and apply **defense in depth**.", + "Url": "https://hub.prowler.com/check/ec2_instance_with_outdated_ami" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.metadata.json b/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.metadata.json index 6d56feb488..2a7517c5bb 100644 --- a/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.metadata.json @@ -1,32 +1,43 @@ { "Provider": "aws", "CheckID": "ec2_launch_template_imdsv2_required", - "CheckTitle": "Amazon EC2 launch templates should have IMDSv2 enabled and required.", + "CheckTitle": "EC2 launch template has IMDSv2 enabled and required or instance metadata service disabled", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Credential Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:ec2:region:account-id:launch-template/resource-id", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2LaunchTemplate", "ResourceGroup": "compute", - "Description": "This control checks if Amazon EC2 launch templates are configured with IMDSv2 enabled and required. The control fails if IMDSv2 is not enabled or required in the launch template versions.", - "Risk": "Without IMDSv2 required, EC2 instances may be vulnerable to metadata service attacks, allowing unauthorized access to instance metadata, potentially leading to compromise of instance credentials or other sensitive data.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html", + "Description": "EC2 launch templates are inspected for **Instance Metadata Service** configuration. It identifies versions where `http_endpoint` is `enabled` and `http_tokens` is `required` (IMDSv2 enforced), versions with the metadata service `disabled`, and versions that allow metadata without requiring tokens.", + "Risk": "Allowing metadata access without **IMDSv2** enables SSRF and open proxy paths to query instance metadata, exposing temporary credentials and secrets. Attackers can steal IAM role credentials to access data, modify resources, and pivot within the account, threatening confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-launch-template.html#change-metadata-options", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-170" + ], "Remediation": { "Code": { - "CLI": "aws ec2 modify-launch-template --launch-template-id --version --metadata-options HttpTokens=required", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-170", - "Terraform": "" + "CLI": "aws ec2 create-launch-template-version --launch-template-id --source-version --launch-template-data '{\"MetadataOptions\":{\"HttpTokens\":\"required\"}}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::EC2::LaunchTemplate\n Properties:\n LaunchTemplateName: \n LaunchTemplateData:\n MetadataOptions:\n HttpTokens: required # CRITICAL: Require IMDSv2 (blocks IMDSv1) to pass the check\n```", + "Other": "1. In the AWS Console, go to EC2 > Launch Templates\n2. Select the launch template, then choose Actions > Modify template (Create new version)\n3. Expand Advanced details > Metadata options\n4. Set Http tokens to Required (or disable Metadata accessible)\n5. Click Create template version\n6. (Optional) Set this new version as Default if you want it used for future launches", + "Terraform": "```hcl\nresource \"aws_launch_template\" \"\" {\n name = \"\"\n\n metadata_options {\n http_tokens = \"required\" # CRITICAL: Require IMDSv2 (blocks IMDSv1) to pass the check\n }\n}\n```" }, "Recommendation": { - "Text": "To ensure EC2 launch templates have IMDSv2 enabled and required, update the template to configure the Instance Metadata Service Version 2 as required.", - "Url": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-launch-template.html#change-metadata-options" + "Text": "Enforce **IMDSv2** in all launch template versions by setting token use to `required`; disable the metadata service when not needed. Apply **least privilege** to instance roles and use **defense in depth** (egress filtering, input validation) to reduce SSRF paths. Ensure applications and SDKs are compatible with IMDSv2.", + "Url": "https://hub.prowler.com/check/ec2_launch_template_imdsv2_required" } }, - "Categories": [], + "Categories": [ + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_launch_template_no_public_ip/ec2_launch_template_no_public_ip.metadata.json b/prowler/providers/aws/services/ec2/ec2_launch_template_no_public_ip/ec2_launch_template_no_public_ip.metadata.json index 8f9c806585..098fb50adf 100644 --- a/prowler/providers/aws/services/ec2/ec2_launch_template_no_public_ip/ec2_launch_template_no_public_ip.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_launch_template_no_public_ip/ec2_launch_template_no_public_ip.metadata.json @@ -1,30 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_launch_template_no_public_ip", - "CheckTitle": "Amazon EC2 launch templates should not assign public IPs to network interfaces.", - "CheckType": [], + "CheckTitle": "Amazon EC2 launch template has no public IP addresses configured on network interfaces", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2LaunchTemplate", "ResourceGroup": "compute", - "Description": "This control checks if Amazon EC2 launch templates are configured to assign public IP addresses to network interfaces upon launch. The control fails if an EC2 launch template is configured to assign a public IP address to network interfaces or if there is at least one network interface that has a public IP address.", - "Risk": "A public IP address is reachable from the internet, making associated resources potentially accessible from the internet. EC2 resources should not be publicly accessible to avoid unintended access to workloads.", - "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-launch-template-public-ip-disabled.html", + "Description": "**EC2 launch templates** with versions that either enable `associate_public_ip_address` for network interfaces or reference **ENIs** already associated with public IPs", + "Risk": "Assigning **public IPs** makes instances Internet-reachable, enabling:\n- Loss of **confidentiality** via unauthorized access and data exfiltration\n- Compromised **integrity** through remote exploitation and tampering\n- Reduced **availability** from DDoS and brute-force traffic\nAttackers can scan exposed services and pivot within the VPC.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-launch-template.html#change-network-interface", + "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-25", + "https://docs.aws.amazon.com/config/latest/developerguide/ec2-launch-template-public-ip-disabled.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-25", - "Terraform": "" + "CLI": "aws ec2 create-launch-template-version --launch-template-id --launch-template-data '{\"NetworkInterfaces\":[{\"DeviceIndex\":0,\"AssociatePublicIpAddress\":false}]}' --set-default-version", + "NativeIaC": "```yaml\n# CloudFormation: Launch template configured to not assign public IPs\nResources:\n :\n Type: AWS::EC2::LaunchTemplate\n Properties:\n LaunchTemplateData:\n NetworkInterfaces:\n - DeviceIndex: 0\n AssociatePublicIpAddress: false # Critical: disables public IP assignment on the primary ENI\n```", + "Other": "1. Open the EC2 console and go to Launch Templates\n2. Select the template and choose Actions > Create new version\n3. Under Network settings > Advanced network configuration, set Auto-assign public IP to Disable\n4. Ensure no Network interface is attached that already has a public IP\n5. Check Set as default version and choose Create launch template version", + "Terraform": "```hcl\n# EC2 launch template configured to not assign public IPs\nresource \"aws_launch_template\" \"\" {\n name = \"\"\n\n network_interfaces {\n device_index = 0\n associate_public_ip_address = false # Critical: disables public IP assignment on the primary ENI\n }\n}\n```" }, "Recommendation": { - "Text": "To update an EC2 launch template, see Change the default network interface settings in the Amazon EC2 Auto Scaling User Guide.", - "Url": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-launch-template.html#change-network-interface" + "Text": "Apply **least privilege** and network segmentation:\n- Set `associate_public_ip_address=false` in launch templates\n- Avoid referencing ENIs with public IPs\n- Place instances in private subnets behind **NAT/ALB**\n- Use **Session Manager**, bastions, or **VPC endpoints/PrivateLink** for access\nAdopt **defense in depth** to minimize exposure.", + "Url": "https://hub.prowler.com/check/ec2_launch_template_no_public_ip" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.metadata.json b/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.metadata.json index 48fc89a4c3..a3c9d3c7c3 100644 --- a/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_launch_template_no_secrets/ec2_launch_template_no_secrets.metadata.json @@ -1,27 +1,38 @@ { "Provider": "aws", "CheckID": "ec2_launch_template_no_secrets", - "CheckTitle": "Find secrets in EC2 Launch Template", - "CheckType": [], + "CheckTitle": "EC2 launch template user data contains no secrets in any version", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Security", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure", + "TTPs/Credential Access" + ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:ec2:region:account-id:launch-template/template-id", - "Severity": "critical", + "ResourceIdTemplate": "", + "Severity": "high", "ResourceType": "AwsEc2LaunchTemplate", "ResourceGroup": "compute", - "Description": "Find secrets in EC2 Launch Template", - "Risk": "The use of a hard-coded password increases the possibility of password guessing. If hard-coded passwords are used, it is possible that malicious users gain access through the account in question.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-launch-templates.html", + "Description": "**EC2 launch template** user data is analyzed across versions to identify embedded secrets-hard-coded passwords, tokens, API keys, or private keys-within the startup scripts or configuration supplied to instances.", + "Risk": "Secrets in user data can be read by identities able to view launch templates, eroding confidentiality.\n\nExposed credentials enable unauthorized API actions, data exfiltration, and lateral movement. Past template versions retain leaked values, complicating rotation and recovery.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-launch-templates.html", + "https://support.icompaas.com/support/solutions/articles/62000233727-ensure-no-secrets-are-hardcoded-in-ec2-launch-templates" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: Launch Template without user data secrets\nResources:\n :\n Type: AWS::EC2::LaunchTemplate\n Properties:\n LaunchTemplateData:\n UserData: \"\" # Critical: empty user data ensures no secrets are stored in any new version\n```", + "Other": "1. In the AWS Console, go to EC2 > Launch Templates\n2. Select the launch template and click Create new version\n3. In Advanced details, clear the User data field so it is blank\n4. Save and set this clean version as the Default version\n5. Back in the Versions tab, select all versions that contain secrets and click Actions > Delete versions\n6. Ensure only versions with blank (or non-secret) user data remain", + "Terraform": "```hcl\n# Terraform: Launch Template with empty user data\nresource \"aws_launch_template\" \"\" {\n name = \"\"\n user_data = \"\" # Critical: empty user data prevents secrets from being stored\n}\n```" }, "Recommendation": { - "Text": "Do not include sensitive information in user data within the launch templates, try to use Secrets Manager instead.", - "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html" + "Text": "Keep user data free of secrets. Retrieve sensitive values at runtime from **AWS Secrets Manager** or **SSM Parameter Store** `SecureString` using instance roles.\n\nEnforce **least privilege**, rotate to short-lived credentials, and review template history; if exposure occurred, rotate affected secrets.", + "Url": "https://hub.prowler.com/check/ec2_launch_template_no_secrets" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_any_port/ec2_networkacl_allow_ingress_any_port.metadata.json b/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_any_port/ec2_networkacl_allow_ingress_any_port.metadata.json index e629d7c52b..c8db63f27f 100644 --- a/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_any_port/ec2_networkacl_allow_ingress_any_port.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_any_port/ec2_networkacl_allow_ingress_any_port.metadata.json @@ -1,31 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_networkacl_allow_ingress_any_port", - "CheckTitle": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to any port.", + "CheckTitle": "Network ACL does not allow ingress from 0.0.0.0/0 to any port", "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS AWS Foundations Benchmark" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "networkacl", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "medium", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", "ResourceType": "AwsEc2NetworkAcl", "ResourceGroup": "network", - "Description": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to any port.", - "Risk": "Even having a perimeter firewall, having network acls open allows any user or malware with vpc access to scan for well known and sensitive ports and gain access to instance.", + "Description": "**VPC network ACLs** with **inbound entries** that permit traffic from `0.0.0.0/0` to any port (any protocol) are identified at the subnet boundary.", + "Risk": "Allowing Internet-wide ingress at the subnet layer enables broad port scanning and unsolicited connections. Attackers can probe and exploit exposed services, risking data disclosure and tampering (confidentiality, integrity) and causing outages via floods or brute-force (availability). Any security group lapse then lacks a compensating control.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html", + "https://support.icompaas.com/support/solutions/articles/62000233809-ensure-no-network-acls-allow-ingress-from-0-0-0-0-0-to-any-port" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 replace-network-acl-entry --network-acl-id --ingress --rule-number --protocol -1 --rule-action deny --cidr-block 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: deny all inbound from the Internet on the NACL\nResources:\n NetworkAclDenyAllIngress:\n Type: AWS::EC2::NetworkAclEntry\n Properties:\n NetworkAclId: \"\"\n RuleNumber: 100\n Protocol: -1\n Egress: false\n RuleAction: deny # CRITICAL: Denies ingress\n CidrBlock: 0.0.0.0/0 # CRITICAL: From the Internet (any IP)\n```", + "Other": "1. In AWS Console, go to VPC > Network ACLs\n2. Select the NACL used by the affected subnet\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Find any rule that allows 0.0.0.0/0 to all ports and change Action to Deny (or delete the allow-all rule)\n5. Save changes", + "Terraform": "```hcl\n# Deny all inbound from the Internet on the NACL\nresource \"aws_network_acl_rule\" \"\" {\n network_acl_id = \"\"\n rule_number = 100\n egress = false\n protocol = \"-1\"\n rule_action = \"deny\" # CRITICAL: Denies ingress\n cidr_block = \"0.0.0.0/0\" # CRITICAL: From the Internet (any IP)\n}\n```" }, "Recommendation": { - "Text": "Apply Zero Trust approach. Implement a process to scan and remediate unrestricted or overly permissive network acls. Recommended best practices is to narrow the definition for the minimum ports required.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html" + "Text": "Adopt a **deny-by-default** NACL posture: block `0.0.0.0/0` and allow only required ports from trusted CIDRs. Apply **least privilege** using security groups for fine-grained access, with NACLs as coarse stateless filters. Review and prune rules regularly, and employ **defense in depth** with monitoring and alerting.", + "Url": "https://hub.prowler.com/check/ec2_networkacl_allow_ingress_any_port" } }, "Categories": [ @@ -33,5 +39,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "Infrastructure Security" + "Notes": "" } diff --git a/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_22/ec2_networkacl_allow_ingress_tcp_port_22.metadata.json b/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_22/ec2_networkacl_allow_ingress_tcp_port_22.metadata.json index 1bf207e19b..4837548c21 100644 --- a/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_22/ec2_networkacl_allow_ingress_tcp_port_22.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_22/ec2_networkacl_allow_ingress_tcp_port_22.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_networkacl_allow_ingress_tcp_port_22", - "CheckTitle": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to SSH port 22", + "CheckTitle": "Network ACL does not allow ingress from the Internet to TCP port 22 (SSH)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "networkacl", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2NetworkAcl", "ResourceGroup": "network", - "Description": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to SSH port 22", - "Risk": "Even having a perimeter firewall, having network acls open allows any user or malware with vpc access to scan for well known and sensitive ports and gain access to instance.", + "Description": "**VPC network ACLs** are evaluated for inbound rules that permit `0.0.0.0/0` to access **SSH** on `TCP 22` at the subnet boundary.", + "Risk": "An ACL allowing Internet-wide SSH erodes **defense in depth**. Systems reachable on `TCP 22` face **brute-force**, credential stuffing, reconnaissance, and SSH exploit attempts.\n\nChanges to routes or security groups can create direct exposure, enabling unauthorized access and **lateral movement**, undermining **confidentiality** and **integrity**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000233578-ensure-no-network-acls-allow-ingress-from-0-0-0-0-0-to-ssh-port-22", + "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/ensure-aws-nacl-does-not-allow-ingress-from-00000-to-port-22#cloudformation", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/aws/networking-policies/ensure-aws-nacl-does-not-allow-ingress-from-00000-to-port-22#terraform" + "CLI": "aws ec2 replace-network-acl-entry --network-acl-id --ingress --rule-number --protocol 6 --rule-action deny --cidr-block 0.0.0.0/0 --port-range From=22,To=22", + "NativeIaC": "```yaml\n# CloudFormation: Deny SSH (22) from Internet on the NACL\nResources:\n :\n Type: AWS::EC2::NetworkAclEntry\n Properties:\n NetworkAclId: \n RuleNumber: 1\n Protocol: 6\n RuleAction: deny # Critical: blocks the traffic instead of allowing it\n Egress: false\n CidrBlock: 0.0.0.0/0 # Critical: matches Internet sources\n PortRange:\n From: 22 # Critical: SSH port\n To: 22\n```", + "Other": "1. In AWS Console, go to VPC > Network ACLs\n2. Select and open the Inbound rules tab\n3. Delete any rule that ALLOWS TCP port 22 from 0.0.0.0/0 or ::/0\n4. Save changes\n5. If you cannot delete it, edit the rule and set Action to Deny for TCP port 22 with source 0.0.0.0/0 (and ::/0 if present), then save", + "Terraform": "```hcl\n# Deny SSH (22) from Internet on the NACL\nresource \"aws_network_acl_rule\" \"\" {\n network_acl_id = \"\"\n rule_number = 1\n egress = false\n protocol = \"tcp\"\n rule_action = \"deny\" # Critical: blocks SSH ingress\n cidr_block = \"0.0.0.0/0\" # Critical: Internet sources\n from_port = 22 # Critical: SSH port\n to_port = 22\n}\n```" }, "Recommendation": { - "Text": "Apply Zero Trust approach. Implement a process to scan and remediate unrestricted or overly permissive network acls. Recommended best practices is to narrow the definition for the minimum ports required.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html" + "Text": "Apply **least privilege** at the subnet layer:\n- Do not allow `0.0.0.0/0` to `TCP 22`\n- Restrict SSH to trusted sources, or avoid direct SSH via **Session Manager** or a bastion behind **VPN**\n\nPair tight **security groups** with periodic rule reviews and change control to maintain **defense in depth**.", + "Url": "https://hub.prowler.com/check/ec2_networkacl_allow_ingress_tcp_port_22" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_3389/ec2_networkacl_allow_ingress_tcp_port_3389.metadata.json b/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_3389/ec2_networkacl_allow_ingress_tcp_port_3389.metadata.json index 1269640e32..4dc401c723 100644 --- a/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_3389/ec2_networkacl_allow_ingress_tcp_port_3389.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_networkacl_allow_ingress_tcp_port_3389/ec2_networkacl_allow_ingress_tcp_port_3389.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_networkacl_allow_ingress_tcp_port_3389", - "CheckTitle": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to Microsoft RDP port 3389", + "CheckTitle": "Network ACL does not allow ingress from the Internet to TCP port 3389 (RDP)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "networkacl", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2NetworkAcl", "ResourceGroup": "network", - "Description": "Ensure no Network ACLs allow ingress from 0.0.0.0/0 to Microsoft RDP port 3389", - "Risk": "Even having a perimeter firewall, having network acls open allows any user or malware with vpc access to scan for well known and sensitive ports and gain access to instance.", + "Description": "**VPC network ACLs** with inbound rules allowing **RDP** on `TCP 3389` from `0.0.0.0/0` are identified.\n\nAssessment focuses on subnet-level ACL entries that permit this traffic.", + "Risk": "Internet-exposed **RDP** enables **password spraying**, brute force, and exploitation of RDP flaws to gain remote control. Allowing it at the subnet layer weakens **defense in depth**-a misconfigured security group or route can expose instances-leading to data exfiltration, privilege escalation, and ransomware, impacting confidentiality and integrity.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000223179-ensure-no-network-acls-allow-ingress-from-0-0-0-0-0-to-microsoft-rdp-port-3389-", + "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/ensure-aws-nacl-does-not-allow-ingress-from-00000-to-port-3389#cloudformation", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/aws/networking-policies/ensure-aws-nacl-does-not-allow-ingress-from-00000-to-port-3389#terraform" + "CLI": "aws ec2 delete-network-acl-entry --network-acl-id --ingress --rule-number ", + "NativeIaC": "```yaml\n# CloudFormation: deny inbound RDP (TCP 3389) from the Internet on an existing NACL\nResources:\n NetworkAclDenyRDP:\n Type: AWS::EC2::NetworkAclEntry\n Properties:\n NetworkAclId: \n RuleNumber: 1 # Critical: ensure this deny is evaluated before any allow\n Protocol: 6 # TCP\n Egress: false # Ingress rule\n RuleAction: deny # Critical: block the traffic\n CidrBlock: 0.0.0.0/0 # Critical: Internet source\n PortRange:\n From: 3389 # Critical: RDP port\n To: 3389\n```", + "Other": "1. In the AWS Console, go to VPC > Network ACLs and select the ACL used by the affected subnet(s)\n2. Open the Inbound rules tab\n3. Find any rule allowing TCP port 3389 (RDP) from 0.0.0.0/0 or ::/0\n4. Select that rule and click Delete, then Save\n5. If you must keep broad allows, instead click Edit inbound rules and add a new rule with a lower rule number that Denies TCP 3389 from 0.0.0.0/0, then Save", + "Terraform": "```hcl\n# Deny inbound RDP (TCP 3389) from the Internet on an existing NACL\nresource \"aws_network_acl_rule\" \"\" {\n network_acl_id = \"\"\n rule_number = 1 # Critical: lower than any allow so deny takes precedence\n egress = false # Ingress rule\n protocol = \"tcp\"\n rule_action = \"deny\" # Critical: block the traffic\n cidr_block = \"0.0.0.0/0\" # Critical: Internet source\n from_port = 3389 # Critical: RDP port\n to_port = 3389\n}\n```" }, "Recommendation": { - "Text": "Apply Zero Trust approach. Implement a process to scan and remediate unrestricted or overly permissive network acls. Recommended best practices is to narrow the definition for the minimum ports required.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html" + "Text": "Enforce **least privilege**: do not allow `TCP 3389` from `0.0.0.0/0` in network ACLs.\n\n- Restrict RDP to specific admin IP ranges\n- Prefer **bastion hosts** or **Session Manager** over direct RDP\n- Use private subnets and layer controls for **defense in depth**", + "Url": "https://hub.prowler.com/check/ec2_networkacl_allow_ingress_tcp_port_3389" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json b/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json index 5494405ba1..6f6c696085 100644 --- a/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json @@ -1,33 +1,41 @@ { "Provider": "aws", "CheckID": "ec2_networkacl_unused", - "CheckTitle": "Unused Network Access Control Lists should be removed.", - "CheckType": [], + "CheckTitle": "Non-default network ACL is associated with a subnet", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "ec2", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsEc2NetworkAcl", "ResourceGroup": "network", - "Description": "Ensure that there are no unused network access control lists (network ACLs) in your virtual private cloud (VPC). The control fails if the network ACL isn't associated with a subnet. The control doesn't generate findings for an unused default network ACL.", - "Risk": "Unused network ACLs may represent a potential security risk if left in place without purpose, as they could be mistakenly associated with subnets later.", - "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/vpc-network-acl-unused-check.html", + "Description": "**VPC network ACLs** that are **not associated with any subnet** are considered unused. The evaluation focuses on non-default ACLs and identifies those without a current subnet association; the default network ACL is excluded.", + "Risk": "Unused ACLs raise the risk of **misassociation**, unexpectedly changing subnet filtering. A permissive ACL could expose workloads (**confidentiality, integrity**), while an overly restrictive one could disrupt traffic (**availability**). Stale objects also hinder reviews and conceal drift.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html#vpc-network-acl-delete", + "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-16", + "https://docs.aws.amazon.com/config/latest/developerguide/vpc-network-acl-unused-check.html" + ], "Remediation": { "Code": { "CLI": "aws ec2 delete-network-acl --network-acl-id ", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-16", - "Terraform": "" + "NativeIaC": "```yaml\n# Associate the unused non-default NACL to a subnet so it's in use\nResources:\n :\n Type: AWS::EC2::SubnetNetworkAclAssociation\n Properties:\n SubnetId: # Critical: makes the subnet use this NACL\n NetworkAclId: # Critical: the unused non-default NACL to associate\n```", + "Other": "1. In the AWS console, open VPC > Network ACLs\n2. Select the non-default NACL with Association: None\n3. Choose Actions > Delete ACL > Delete\n\nAlternative (if you want to keep it):\n1. Select the NACL > Actions > Edit subnet associations\n2. Check a subnet to associate > Save", + "Terraform": "```hcl\n# Associate the unused non-default NACL to a subnet so it's in use\nresource \"aws_network_acl_association\" \"\" {\n subnet_id = \"\" # Critical: makes the subnet use this NACL\n network_acl_id = \"\" # Critical: the unused non-default NACL to associate\n}\n```" }, "Recommendation": { - "Text": "For instructions on deleting an unused network ACL, see Deleting a network ACL in the Amazon VPC User Guide.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html#vpc-network-acl-delete" + "Text": "Remove **unused non-default ACLs** to minimize drift. Apply **least privilege** and **change control** to ACL creation and associations. If retention is necessary, tag owner and purpose, restrict who can associate ACLs, and review regularly as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/ec2_networkacl_unused" } }, "Categories": [ - "internet-exposed" + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], - "Notes": "Infrastructure Security" + "Notes": "" } diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_all_ports/ec2_securitygroup_allow_ingress_from_internet_to_all_ports.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_all_ports/ec2_securitygroup_allow_ingress_from_internet_to_all_ports.metadata.json index de12363a49..4ceccb1f1d 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_all_ports/ec2_securitygroup_allow_ingress_from_internet_to_all_ports.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_all_ports/ec2_securitygroup_allow_ingress_from_internet_to_all_ports.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to all ports.", + "CheckTitle": "Security group does not have all ports open to the Internet", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to all ports.", - "Risk": "If Security groups are not properly configured the attack surface is increased. An attacker could exploit this misconfiguration to gain unauthorized access to resources.", + "Description": "**EC2 security groups** with **inbound rules** permitting Internet sources (`0.0.0.0/0`, `::/0`) to `all ports` across any protocol", + "Risk": "Opening every port to the Internet enables broad scanning and exploit attempts, leading to **unauthorized access**, **remote code execution**, and **data exfiltration**, with easier lateral movement into the VPC. Confidentiality, integrity, and availability are all at risk.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/security-group-ingress-any.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/aws/networking-policies/ensure-aws-security-group-does-not-allow-all-traffic-on-all-ports/" + "NativeIaC": "```yaml\n# CloudFormation: Security Group without an inbound rule that opens all ports to the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Example SG\n VpcId: \n # Critical: Omit SecurityGroupIngress to ensure no rule with IpProtocol \"-1\" from 0.0.0.0/0 or ::/0 exists,\n # which prevents all ports from being open to the Internet.\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the affected security group\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Delete any rule where Type is All traffic (protocol = All) with Source 0.0.0.0/0 or ::/0\n5. Click Save rules", + "Terraform": "```hcl\n# Security Group with no inbound rules (prevents all ports open to the Internet)\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n # Critical: no ingress blocks; avoids any rule with protocol \"-1\" from 0.0.0.0/0 or ::/0\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Enforce **least privilege** on ingress: allow only required ports from trusted sources, avoid `0.0.0.0/0` and `::/0`. Prefer private access (VPN, bastion, or Session Manager), use security group references, and layer **defense in depth** with network ACLs. Periodically review and remove unused rules.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_all_ports" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port/ec2_securitygroup_allow_ingress_from_internet_to_any_port.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port/ec2_securitygroup_allow_ingress_from_internet_to_any_port.metadata.json index 289cb4298a..9f4d1525bf 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port/ec2_securitygroup_allow_ingress_from_internet_to_any_port.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port/ec2_securitygroup_allow_ingress_from_internet_to_any_port.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_any_port", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to any port.", + "CheckTitle": "Security group has no 0.0.0.0/0 or ::/0 ingress to any port, or is attached only to allowed interface types or instance owners", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to any port and not attached to a network interface with not allowed network interface types or instance owners. By default, the allowed network interface types are 'api_gateway_managed' and 'vpc_endpoint', and the allowed instance owners are 'amazon-elb', you can customize these values by setting the 'ec2_allowed_interface_types' and 'ec2_allowed_instance_owners' variables.", - "Risk": "The security group allows all traffic from the internet to any port. This could allow an attacker to access the instance.", + "Description": "**EC2 security groups** with **internet-sourced ingress** from `0.0.0.0/0` or `::/0` to any port, and their attachments, are evaluated. Groups linked to network interfaces or instance owners outside an approved list for public exposure are identified.", + "Risk": "Open ingress to any port on non-approved interfaces enables external scanning, brute force, and exploitation of unintended services. This threatens **confidentiality** (unauthorized access), **integrity** (tampering), and **availability** (DoS), and facilitates **lateral movement**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000234274-5-3-ensure-no-security-groups-allow-ingress-from-0-0-0-0-0-to-remote-server-administration-ports-aut" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: security group without Internet-open ingress\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: \"SG without Internet ingress\"\n VpcId: \"\"\n SecurityGroupIngress: [] # Critical: no 0.0.0.0/0 or ::/0 inbound; denies all inbound\n```", + "Other": "1. In the AWS console, go to EC2 > Security Groups\n2. Select the affected security group\n3. Open Inbound rules > Edit inbound rules\n4. Delete any rule with Source 0.0.0.0/0 or ::/0\n5. Save rules", + "Terraform": "```hcl\n# Security group with no Internet-open ingress\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n # Critical: no ingress blocks -> prevents 0.0.0.0/0 or ::/0 inbound\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege**: restrict ingress to required ports and trusted sources; avoid `0.0.0.0/0` and `::/0` except for managed public endpoints. Place workloads behind **load balancers**, **API gateways**, or **WAFs**; use **private networking**. Allow public rules only on approved interface types.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_any_port" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports.metadata.json index 2403143175..3d85848a54 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to high risk ports.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to high-risk TCP ports", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/Unauthorized Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "critical", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to ports 25(SMTP), 110(POP3), 135(RCP), 143(IMAP), 445(CIFS), 3000(Go, Node.js, and Ruby web developemnt frameworks), 4333(ahsp), 5000(Python web development frameworks), 5500(fcp-addr-srvr1), 8080(proxy), 8088(legacy HTTP port).", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are assessed for inbound rules that allow Internet sources (`0.0.0.0/0` or `::/0`) to **high-risk TCP ports**: `25, 110, 135, 143, 445, 3000, 4333, 5000, 5500, 8080, 8088`.\n\nFindings highlight groups exposing any of these ports to the public network.", + "Risk": "Public exposure of these ports enables:\n- **RCE** via SMB/RPC and weak admin consoles (`445`, `135`, `3000`, `5000`, `8080`)\n- **Credential theft/data leakage** via mail protocols (`25`, `110`, `143`)\n- **Spam relay** on `25`\nImpacts: **confidentiality**, **integrity**, and **availability** through exploitation and mass scanning.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000234274-5-3-ensure-no-security-groups-allow-ingress-from-0-0-0-0-0-to-remote-server-administration-ports-aut" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: restrict high-risk TCP port from Internet\nResources:\n ExampleSecurityGroup:\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict high-risk TCP port\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 8080\n ToPort: 8080\n CidrIp: 10.0.0.0/8 # CRITICAL: do not use 0.0.0.0/0 or ::/0; restrict source to non-Internet to pass the check\n```", + "Other": "1. In the AWS console, go to EC2 > Network & Security > Security Groups\n2. Select the security group in the finding and click Inbound rules > Edit inbound rules\n3. For each high-risk TCP port (25, 110, 135, 143, 445, 3000, 4333, 5000, 5500, 8080, 8088) with Source 0.0.0.0/0 or ::/0, delete the rule or change Source to a specific trusted CIDR (for example, your VPC CIDR)\n4. Save rules", + "Terraform": "```hcl\n# Terraform: restrict high-risk TCP port from Internet\nresource \"aws_security_group\" \"example\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 8080\n to_port = 8080\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: do not use 0.0.0.0/0 or ::/0; restrict source to non-Internet to pass the check\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict these ports using **least privilege**:\n- Deny Internet ingress; allow only trusted CIDRs or private connectivity\n- Place services behind **VPN**, **bastion**, or **proxies/WAF**; prefer **private endpoints**\n- Disable unnecessary services; require auth and TLS on exposed apps\nApply **defense in depth** with security groups and network ACLs.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22.metadata.json index 04952d0ee3..cc7174631d 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to SSH port 22.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to TCP port 22 (SSH)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/Unauthorized Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to SSH port 22.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are assessed for **inbound SSH exposure** by locating ingress rules that allow `TCP 22` from the Internet (`0.0.0.0/0` or `::/0`).\n\nOnly groups in use are considered; sets already flagged for all-port exposure are not repeated.", + "Risk": "Exposed **SSH** invites Internet-scale **brute force** and **credential stuffing**. A successful login grants **remote shell**, enabling data theft (confidentiality), code or config tampering (integrity), and cryptomining or service disruption (availability), plus **lateral movement** within the environment.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-ssh-access.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 22 --cidr", - "NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/networking_1-port-security#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/networking-policies/networking_1-port-security", - "Terraform": "https://docs.prowler.com/checks/aws/networking-policies/networking_1-port-security#terraform" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 22 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict SSH from the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict SSH\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 22\n ToPort: 22\n CidrIp: 10.0.0.0/8 # Critical: allows SSH only from a private range, not 0.0.0.0/0\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the affected security group\n3. Open the Inbound rules tab\n4. Delete any rule for port 22 (SSH) with source 0.0.0.0/0 or ::/0\n5. Click Save rules", + "Terraform": "```hcl\n# Terraform: restrict SSH from the Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 22\n to_port = 22\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: do not use 0.0.0.0/0 or ::/0\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** to SSH:\n- Disallow `0.0.0.0/0` and `::/0`; allow only trusted IPs or VPN ranges\n- Prefer **private access** via bastion hosts or AWS Systems Manager Session Manager\n- Enforce **key-based auth**, disable passwords, rotate keys\n- Add **network segmentation** and monitoring for **defense in depth**", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389.metadata.json index 07eadb0c79..9224f9d92b 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to port 3389.", + "CheckTitle": "Security group does not allow ingress from the Internet to TCP port 3389 (RDP)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to port 3389.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** restrict **inbound RDP** on `TCP 3389` to trusted sources, avoiding Internet-wide (`0.0.0.0/0`, `::/0`) exposure.", + "Risk": "**Internet-exposed RDP** enables brute force and credential stuffing and increases the chance of **remote code execution** via RDP flaws.\n\nAdversaries can gain interactive access, exfiltrate data (**confidentiality**), tamper with systems (**integrity**), and trigger ransomware or service disruption (**availability**).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-rdp-access.html" + ], "Remediation": { "Code": { - "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 3389 --cidr", - "NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/networking_2#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/networking-policies/networking_2", - "Terraform": "https://docs.prowler.com/checks/aws/networking-policies/networking_2#terraform" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 3389 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict RDP (3389) from the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict RDP\n VpcId: vpc-\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 3389\n ToPort: 3389\n CidrIp: 10.0.0.0/8 # FIX: not 0.0.0.0/0; limits RDP to internal range, closing Internet access\n```", + "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. In the Inbound rules tab, click Edit inbound rules\n4. Find any rule with Type RDP (TCP 3389) and Source 0.0.0.0/0 or ::/0\n5. Delete the rule or change Source to a specific trusted CIDR (e.g., your office IP)\n6. Click Save rules", + "Terraform": "```hcl\n# Restrict RDP (3389) from the Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 3389\n to_port = 3389\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # FIX: not 0.0.0.0/0; restricts RDP to internal range\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege**: disallow `0.0.0.0/0` and `::/0` to `3389`; permit only specific IPs or private networks.\n\nPrefer **Session Manager**, VPN, or a hardened bastion with MFA and just-in-time access. Use private subnets and add **defense in depth** with network controls and monitoring.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888.metadata.json index 89cd005ac8..9c7e694fb6 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Cassandra ports 7199 or 9160 or 8888.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to Cassandra TCP ports 7199, 9160, or 8888", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Cassandra ports 7199 or 9160 or 8888.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are evaluated for inbound rules that allow the Internet (`0.0.0.0/0` or `::/0`) to reach **Cassandra ports** `7199`, `9160`, or `8888`.\n\nFocuses on `tcp` rules that expose these ports to public sources.", + "Risk": "Exposed **Cassandra interfaces** (`7199` JMX, `9160` Thrift, `8888` tools) enable:\n- Unauthorized reads of data/metrics (confidentiality)\n- Schema and cluster changes (integrity)\n- Remote operations causing outages (availability)\n\nPublic reachability also increases brute-force and exploit attempts.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000127020-ensure-security-groups-do-not-allow-unrestricted-ingress-access-to-cassandra-ports-7199-or-9160-or-88" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions IpProtocol=tcp,FromPort=7199,ToPort=7199,IpRanges='[{CidrIp=0.0.0.0/0}]',Ipv6Ranges='[{CidrIpv6=::/0}]' IpProtocol=tcp,FromPort=9160,ToPort=9160,IpRanges='[{CidrIp=0.0.0.0/0}]',Ipv6Ranges='[{CidrIpv6=::/0}]' IpProtocol=tcp,FromPort=8888,ToPort=8888,IpRanges='[{CidrIp=0.0.0.0/0}]',Ipv6Ranges='[{CidrIpv6=::/0}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict Cassandra ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Cassandra ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 7199\n ToPort: 7199\n CidrIp: 10.0.0.0/16 # Critical: not 0.0.0.0/0; restricts IPv4 source\n - IpProtocol: tcp\n FromPort: 9160\n ToPort: 9160\n CidrIp: 10.0.0.0/16 # Critical: not 0.0.0.0/0; restricts IPv4 source\n - IpProtocol: tcp\n FromPort: 8888\n ToPort: 8888\n CidrIp: 10.0.0.0/16 # Critical: not 0.0.0.0/0; restricts IPv4 source\n```", + "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the instance\n3. In Inbound rules, delete any rule allowing TCP 7199, 9160, or 8888 from 0.0.0.0/0 or ::/0\n4. If needed, add new rules for these ports limited to specific trusted CIDR(s)\n5. Save rules", + "Terraform": "```hcl\n# Restrict Cassandra ports from Internet\nresource \"aws_security_group\" \"example\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 7199\n to_port = 7199\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"] # Critical: not 0.0.0.0/0; restricts IPv4 source\n }\n ingress {\n from_port = 9160\n to_port = 9160\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"] # Critical: not 0.0.0.0/0; restricts IPv4 source\n }\n ingress {\n from_port = 8888\n to_port = 8888\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/16\"] # Critical: not 0.0.0.0/0; restricts IPv4 source\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict **ingress** on `7199`, `9160`, `8888` to trusted sources:\n- Enforce **least privilege** allow-lists; avoid `0.0.0.0/0` and `::/0`\n- Place nodes in private subnets; use VPN or a bastion for admin\n- Prefer strong auth and *mTLS*; bind management to internal interfaces\n- Apply **defense in depth** with segmentation (north-south and east-west)", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_cassandra_7199_9160_8888" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601.metadata.json index 3e950f09fb..e4d73468f5 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601.metadata.json @@ -1,29 +1,35 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Elasticsearch/Kibana ports.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to Elasticsearch/Kibana TCP ports 9200, 9300, and 5601", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Elasticsearch/Kibana ports.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** restrict public ingress to Elasticsearch/Kibana ports `9200`, `9300`, and `5601`, denying sources `0.0.0.0/0` and `::/0`", + "Risk": "Open Elasticsearch/Kibana ports to the Internet erode CIA:\n- Confidentiality: unauthorized queries and data exfiltration\n- Integrity: index tampering/deletion, cluster control via `9300`\n- Availability: API abuse, exploit-based outages, and Kibana `5601` brute-force", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233821-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-elasticsearch-and-kibana-ports-tcp-9200-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":9200,\"ToPort\":9200,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":9300,\"ToPort\":9300,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":5601,\"ToPort\":5601,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict Elasticsearch/Kibana ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Block Internet access to 9200, 9300, 5601\n VpcId: vpc-\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 9200\n ToPort: 9200\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0 or ::/0; restricts 9200 to trusted CIDR\n - IpProtocol: tcp\n FromPort: 9300\n ToPort: 9300\n CidrIp: 10.0.0.0/8 # CRITICAL: restricts 9300 from Internet\n - IpProtocol: tcp\n FromPort: 5601\n ToPort: 5601\n CidrIp: 10.0.0.0/8 # CRITICAL: restricts 5601 (Kibana) from Internet\n```", + "Other": "1. Open the AWS Console and go to VPC > Security Groups\n2. Select the affected security group\n3. Choose Inbound rules > Edit inbound rules\n4. For ports 9200, 9300, and 5601, remove any rule with Source 0.0.0.0/0 or ::/0\n5. If access is required, add rules for only trusted CIDR(s) instead\n6. Save rules", + "Terraform": "```hcl\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n # CRITICAL: Do not use 0.0.0.0/0 or ::/0; restrict these ports to trusted CIDRs\n ingress {\n from_port = 9200\n to_port = 9200\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restricts 9200 from Internet\n }\n ingress {\n from_port = 9300\n to_port = 9300\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restricts 9300 from Internet\n }\n ingress {\n from_port = 5601\n to_port = 5601\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: restricts 5601 (Kibana) from Internet\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Limit ingress to `9200`, `9300`, and `5601` to trusted CIDRs or private connectivity; never allow `0.0.0.0/0` or `::/0`. Prefer **private access** via VPN, bastion, or private endpoints. Apply **least privilege**, network segmentation, and **defense in depth** (NACLs/WAF). Require strong auth and TLS on Elasticsearch/Kibana.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_elasticsearch_kibana_9200_9300_5601" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21.metadata.json index a16019e01e..7e96f23a1c 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21.metadata.json @@ -1,32 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to FTP ports 20 or 21.", - "CheckAliases": [ - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_ftp_port_20_21" - ], + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to FTP ports 20 or 21", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to FTP ports 20 or 21.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "EC2 security groups are evaluated for Internet-exposed **FTP**: any inbound rule allowing `tcp` ports `20` or `21` from `0.0.0.0/0` or `::/0`.", + "Risk": "Exposed FTP weakens CIA:\n- Confidentiality: cleartext credentials/files enable interception and brute force.\n- Integrity: unauthorized uploads or tampering enable malware staging.\n- Availability: mass scans and login attempts can exhaust resources and disrupt services.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-ftp-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":20,\"ToPort\":20,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":21,\"ToPort\":21,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict FTP (20,21) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict FTP from Internet\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 20\n ToPort: 20\n CidrIp: 10.0.0.0/8 # Critical: not 0.0.0.0/0; limits IPv4 to a private range\n - IpProtocol: tcp\n FromPort: 21\n ToPort: 21\n CidrIp: 10.0.0.0/8 # Critical: not 0.0.0.0/0; limits IPv4 to a private range\n```", + "Other": "1. Open the AWS Console and go to EC2 > Security Groups\n2. Select the security group attached to the affected resource\n3. In Inbound rules, find any rules for TCP ports 20 or 21 with Source 0.0.0.0/0 or ::/0\n4. Delete those rules (or edit them to a specific trusted CIDR only)\n5. Save rules", + "Terraform": "```hcl\n# Terraform: restrict FTP (20,21) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 20\n to_port = 20\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: not 0.0.0.0/0; restricts IPv4\n }\n\n ingress {\n from_port = 21\n to_port = 21\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: not 0.0.0.0/0; restricts IPv4\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** and **defense in depth**:\n- Remove `0.0.0.0/0` and `::/0` to `20`/`21`; allow only trusted IPs or private access (VPN/peering).\n- Prefer **SFTP/FTPS** or HTTPS; disable anonymous FTP.\n- Segment transfer hosts, monitor access, and enforce rate limits and strong authentication.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_ftp_20_21" } }, "Categories": [ @@ -34,5 +36,8 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_ftp_port_20_21" + ] } diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092.metadata.json index 88f6cf56e3..6f39a49b10 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Kafka port 9092.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to TCP port 9092 (Kafka)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Kafka port 9092.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are evaluated for ingress rules that expose **Kafka** on `TCP 9092` to the Internet via `0.0.0.0/0` or `::/0`", + "Risk": "Public Kafka `9092` access allows arbitrary clients to connect, enabling topic enumeration, data exfiltration, and producer/consumer impersonation (**C/I**). Brokers can be flooded or exploited, disrupting clusters (**A**). Exposure gives attackers a foothold for lateral movement inside the VPC.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233725-ensure-no-security-groups-allow-ingress-from-0-0-0-0-0-or-0-to-kafka-port-9092-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":9092,\"ToPort\":9092,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict Kafka (9092) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Kafka 9092\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 9092\n ToPort: 9092\n CidrIp: 10.0.0.0/8 # Critical: not 0.0.0.0/0; restricts access to private range to avoid Internet exposure\n```", + "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the group attached to the resource\n3. Inbound rules > Edit inbound rules\n4. Find any rule for TCP port 9092 with Source 0.0.0.0/0 or ::/0\n5. Delete the rule or change Source to a specific trusted CIDR or security group\n6. Save rules", + "Terraform": "```hcl\n# Restrict Kafka (9092) from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 9092\n to_port = 9092\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: not 0.0.0.0/0; restricts access to avoid Internet exposure\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege**: restrict `9092` to required subnets or IPs; avoid `0.0.0.0/0` and `::/0`. Place brokers on private networks and use peering or VPN for access. Enforce **mutual TLS/SASL** and topic ACLs, and add **defense in depth** with segmentation and NACLs.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_kafka_9092" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211.metadata.json index 46c7c45c25..bc29a4e7e0 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Memcached port 11211.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to Memcached TCP port 11211", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Memcached port 11211.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are evaluated for inbound rules that permit Internet-sourced access to `TCP 11211` (Memcached) from `0.0.0.0/0` or `::/0`.", + "Risk": "Exposed **Memcached** enables unauthenticated access, impacting CIA:\n- **Confidentiality**: read cached data (sessions, secrets)\n- **Integrity**: modify or poison entries\n- **Availability**: flush or overload cache, degrading apps\n\nOpen `11211` is widely scanned, enabling unauthorized access and lateral movement.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000127021-ensure-security-groups-do-not-allow-unrestricted-ingress-access-to-memcached-port-11211" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: restrict Memcached (11211) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Memcached access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 11211\n ToPort: 11211\n CidrIp: 10.0.0.0/8 # FIX: Not 0.0.0.0/0; limits access so 11211 isn't open to the Internet\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the affected security group and open the Inbound rules tab\n3. Click Edit inbound rules\n4. Delete any rule allowing TCP 11211 from 0.0.0.0/0 or ::/0, or change its Source to a specific trusted CIDR\n5. Click Save rules", + "Terraform": "```hcl\n# Restrict Memcached (11211) from Internet\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 11211\n to_port = 11211\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # FIX: avoid 0.0.0.0/0 to prevent Internet exposure\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** and **segmentation**:\n- Restrict `TCP 11211` to trusted CIDRs or security groups\n- Keep Memcached on private subnets; avoid public IPs\n- Add **defense in depth** with NACLs/firewalls; disable unused protocols\n- Use private connectivity (VPN/peering) and monitor access", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_memcached_11211" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018.metadata.json index 92f1946076..c6cd7eec5e 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018.metadata.json @@ -1,32 +1,35 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to MongoDB ports 27017 and 27018.", - "CheckAliases": [ - "ec2_securitygroup_allow_ingress_from_internet_to_port_mongodb_27017_27018" - ], + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to MongoDB TCP ports 27017 and 27018", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to MongoDB ports 27017 and 27018.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are inspected for inbound rules that expose **MongoDB** on `TCP 27017-27018` to the Internet via `0.0.0.0/0` or `::/0`.\n\nIt identifies groups where these ports are reachable from any address.", + "Risk": "Public **MongoDB** ports invite unauthenticated probing, brute force, and misuse of weak configs. Attackers can read/alter data, drop collections, or deploy ransomware, compromising **confidentiality** and **integrity**.\n\nExposure also enables enumeration and lateral movement, threatening **availability**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000127019-ensure-security-groups-do-not-allow-unrestricted-ingress-access-to-mongodb-ports-27017-and-27018" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":27017,\"ToPort\":27018,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict MongoDB ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict MongoDB ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 27017\n ToPort: 27018\n CidrIp: 10.0.0.0/8 # FIX: not 0.0.0.0/0 or ::/0; limits access to internal range\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to your resource\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Find rules for TCP ports 27017 or 27018 with Source 0.0.0.0/0 or ::/0\n5. Delete those rules or change Source to a specific trusted CIDR (e.g., 10.0.0.0/8)\n6. Click Save rules", + "Terraform": "```hcl\n# Restrict MongoDB ports from Internet\nresource \"aws_security_group\" \"secure\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 27017\n to_port = 27018\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # FIX: not 0.0.0.0/0 or ::/0; restricts Internet access\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** to MongoDB access:\n- Block `0.0.0.0/0` and `::/0`\n- Allow only trusted IPs or private networks\n- Prefer private connectivity and SG-to-SG references\n- Enforce authentication and TLS\n- Segment east-west traffic and monitor access for **defense in depth**", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mongodb_27017_27018" } }, "Categories": [ @@ -34,5 +37,8 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "ec2_securitygroup_allow_ingress_from_internet_to_port_mongodb_27017_27018" + ] } diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306.metadata.json index 20f5e359aa..b99db5dfc6 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to MySQL port 3306.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to MySQL port 3306", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroups", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to MySQL port 3306.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are assessed for **inbound exposure** of **MySQL** on `TCP 3306` from `0.0.0.0/0` or `::/0`.\n\nThe finding reflects whether this port is reachable from any IPv4 or IPv6 address.", + "Risk": "**Public MySQL** access lets anyone reach the service, enabling credential brute force and vulnerability exploitation. This threatens:\n- **Confidentiality**: data exfiltration\n- **Integrity**: unauthorized writes or schema changes\n- **Availability**: DoS from abuse or scans", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-mysql-access.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: restrict MySQL (3306) from Internet\nResources:\n ExampleSecurityGroup:\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Limit MySQL access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 3306\n ToPort: 3306\n CidrIp: 10.0.0.0/8 # Critical: not 0.0.0.0/0 or ::/0; limits MySQL to internal range\n```", + "Other": "1. In AWS Console, go to EC2 > Security Groups\n2. Select the security group in use by the instance\n3. In Inbound rules, click Edit inbound rules\n4. Remove any rule for TCP port 3306 with source 0.0.0.0/0 or ::/0\n5. Add a rule for TCP 3306 only from a trusted source (e.g., specific IP/CIDR or a security group)\n6. Click Save rules", + "Terraform": "```hcl\n# Restrict MySQL (3306) from Internet\nresource \"aws_security_group\" \"example_resource_name\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 3306\n to_port = 3306\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: not 0.0.0.0/0 or ::/0; restricts MySQL access\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege**: restrict `3306` to specific sources or peer security groups only. Keep databases in private subnets and use **VPN**, **bastion**, or application proxies for admin access. Enable **defense in depth** with TLS and strong auth. Never allow `0.0.0.0/0` or `::/0` ingress.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_mysql_3306" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483.metadata.json index 971a7528ea..12ff148f45 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483.metadata.json @@ -1,29 +1,34 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Oracle ports 1521 or 2483.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to Oracle TCP ports 1521 or 2483", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Oracle ports 1521 or 2483.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are evaluated for inbound rules that permit public sources (`0.0.0.0/0` or `::/0`) to `TCP 1521` or `TCP 2483`-Oracle listener ports.\n\nThe focus is on rules that make these ports reachable from the Internet over IPv4 or IPv6.", + "Risk": "Public Oracle listener exposure enables attackers to:\n- **Brute force** credentials and enumerate services\n- Exploit **listener flaws** for remote access\n- Run unauthorized queries causing **data exfiltration**\n- Launch **DoS** on the listener\n\nThis jeopardizes database confidentiality, integrity, and availability.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000236318-ensure-no-security-groups-allow-ingress-from-0-0-0-0-0-or-0-to-oracle-ports-1521-or-2483-" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: restrict Oracle ports 1521 and 2483 from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Oracle ports from Internet\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 1521\n ToPort: 1521\n CidrIp: 10.0.0.0/8 # Critical: do not use 0.0.0.0/0; restrict source to internal range to block Internet\n - IpProtocol: tcp\n FromPort: 2483\n ToPort: 2483\n CidrIp: 10.0.0.0/8 # Critical: restrict Oracle port 2483 from Internet\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the security group attached to the resource\n3. Open the Inbound rules tab and click Edit inbound rules\n4. For rules on TCP ports 1521 or 2483 with Source 0.0.0.0/0 or ::/0, delete them or change Source to a specific trusted CIDR (e.g., your internal range)\n5. Click Save rules", + "Terraform": "```hcl\n# Restrict Oracle ports 1521 and 2483 from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 1521\n to_port = 1521\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: do not use 0.0.0.0/0; restrict to internal range\n }\n\n ingress {\n from_port = 2483\n to_port = 2483\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: restrict Oracle port 2483 from Internet\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** and **defense in depth**: disallow public ingress to `TCP 1521` and `TCP 2483`.\n\nRestrict access to trusted CIDRs or peer security groups, keep databases on private networks, and require **VPN**, **bastion**, or **proxy** access. Enforce **TLS** and segment east-west and north-south traffic.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_oracle_1521_2483" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432.metadata.json index bc5f0fcae9..d2c9f44c1c 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Postgres port 5432.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to Postgres TCP port 5432", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Postgres port 5432.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are evaluated for inbound rules that expose **Postgres** on `TCP 5432` to the Internet. Rules permitting `0.0.0.0/0` or `::/0` to this port, or policies that open all ports publicly, are identified.", + "Risk": "Exposing `5432` to the Internet enables credential stuffing and Postgres exploits, risking data disclosure (**confidentiality**), unauthorized changes (**integrity**), and service disruption via brute force or DoS (**availability**).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-postgresql-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 5432 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict Postgres (5432) from the Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: \"\"\n VpcId: \"\"\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 5432\n ToPort: 5432\n CidrIp: 10.0.0.0/8 # Critical: not 0.0.0.0/0 or ::/0; limits access so 5432 is not open to the Internet\n```", + "Other": "1. In the AWS Console, go to VPC > Security Groups\n2. Select the affected security group\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Locate any rule for PostgreSQL (port 5432) with Source 0.0.0.0/0 or ::/0\n5. Delete the rule or change Source to a specific CIDR or security group\n6. Click Save rules", + "Terraform": "```hcl\n# Security group with Postgres (5432) not exposed to the Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 5432\n to_port = 5432\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: avoid 0.0.0.0/0 or ::/0 to prevent Internet exposure\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** on security groups: remove `0.0.0.0/0` and `::/0` for `5432`, allow only trusted CIDRs or private peers. Prefer **private access** (VPC-only) via VPN, bastion, or proxy. Add **defense in depth** with SG references and network ACLs, and enforce TLS and strong authentication.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379.metadata.json index e245d68b5a..808aa9f8bf 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Redis port 6379.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to Redis TCP port 6379", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Redis port 6379.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** permitting Internet sources (`0.0.0.0/0` or `::/0`) to `TCP 6379` are identified, indicating Redis is reachable from public networks", + "Risk": "Public Redis access undermines **confidentiality, integrity, and availability**:\n- Read keys and secrets\n- Modify or flush data and configs\n- Exhaust memory for DoS\nAttackers can brute-force `AUTH`, exploit replication or modules for code execution, and pivot within the VPC.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233806-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-6379-redis-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 6379 --cidr 0.0.0.0/0", + "NativeIaC": "```yaml\n# CloudFormation: restrict Redis (6379) from Internet\nResources:\n SecureSecurityGroup:\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Redis access\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 6379\n ToPort: 6379\n CidrIp: 10.0.0.0/8 # Critical: do NOT use 0.0.0.0/0 or ::/0; restrict to trusted CIDR\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the affected security group\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Find any rule allowing TCP port 6379 with Source 0.0.0.0/0 or ::/0\n5. Delete that rule (or change Source to a trusted CIDR or security group)\n6. Click Save rules", + "Terraform": "```hcl\n# Security group with Redis limited to trusted sources\nresource \"aws_security_group\" \"secure\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 6379\n to_port = 6379\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # Critical: do NOT use 0.0.0.0/0 or ::/0; restrict to trusted CIDR\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Restrict Redis to **private connectivity** and apply **least privilege**:\n- Allow `6379` only from required app hosts, security groups, or CIDRs\n- Prefer VPC/private networks or VPN over public IPs\n- Enforce Redis `AUTH` and TLS, bind to private interfaces\n- Use segmentation and monitoring for **defense in depth**", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_redis_6379" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434.metadata.json index a669ee7367..1804700d2f 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Windows SQL Server ports 1433 or 1434.", + "CheckTitle": "Security group does not allow ingress from 0.0.0.0/0 or ::/0 to Microsoft SQL Server ports 1433 and 1434", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", - "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Windows SQL Server ports 1433 or 1434.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** with inbound rules that allow Internet sources (`0.0.0.0/0`, `::/0`) to reach **Microsoft SQL Server** on `TCP 1433` or `TCP 1434`", + "Risk": "**Internet-exposed SQL ports** enable credential brute force, service enumeration, and remote exploitation. Compromise can lead to unauthorized queries, data exfiltration or tampering, and outages via destructive commands, degrading **confidentiality**, **integrity**, and **availability**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000223371-ensure-no-security-groups-allow-ingress-from-0-0-0-0-0-or-0-to-windows-sql-server-ports-1433-or-14" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":1433,\"ToPort\":1433,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]},{\"IpProtocol\":\"tcp\",\"FromPort\":1434,\"ToPort\":1434,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict SQL Server ports from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict SQL ports\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 1433\n ToPort: 1433\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; limits exposure to internal range\n - IpProtocol: tcp\n FromPort: 1434\n ToPort: 1434\n CidrIp: 10.0.0.0/8 # CRITICAL: not 0.0.0.0/0; blocks Internet access\n```", + "Other": "1. Open the AWS Console > EC2 > Security Groups\n2. Select the target security group and open Inbound rules\n3. Find rules allowing TCP 1433 or 1434 from 0.0.0.0/0 or ::/0\n4. Delete those rules (or change Source to a specific CIDR or security group)\n5. Click Save rules", + "Terraform": "```hcl\n# Restrict SQL Server ports from Internet\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 1433\n to_port = 1433\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; restricts Internet access\n }\n\n ingress {\n from_port = 1434\n to_port = 1434\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: not 0.0.0.0/0; blocks Internet exposure\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** on network access:\n- Restrict SQL ingress to trusted IPs or via VPN/bastion\n- Place databases in private subnets; allow only app-tier sources\n- Avoid `0.0.0.0/0` and `::/0`\n- Use **defense in depth** with network ACLs/firewalls\n- Monitor auth failures and rate-limit repeated attempts", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_sql_server_1433_1434" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23.metadata.json index e7df205246..7fd49436b0 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23.metadata.json @@ -1,29 +1,37 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23", - "CheckTitle": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Telnet port 23.", + "CheckTitle": "Security group does not allow ingress from the Internet to TCP port 23 (Telnet)", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to Telnet port 23.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are evaluated for rules that allow **inbound Telnet** on `TCP 23` from the Internet (`0.0.0.0/0` or `::/0`).", + "Risk": "Public **Telnet** exposes cleartext credentials and remote shell access.\n- Brute-force and credential interception enable account takeover\n- Command execution enables data theft and lateral movement\n\nThis threatens confidentiality and integrity and can degrade availability through misuse.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://support.icompaas.com/support/solutions/articles/62000233790-ensure-no-ec2-instances-allow-ingress-from-the-internet-to-tcp-port-23-telnet-", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/unrestricted-telnet-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws ec2 revoke-security-group-ingress --group-id --ip-permissions '[{\"IpProtocol\":\"tcp\",\"FromPort\":23,\"ToPort\":23,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}],\"Ipv6Ranges\":[{\"CidrIpv6\":\"::/0\"}]}]'", + "NativeIaC": "```yaml\n# CloudFormation: restrict Telnet (port 23) from Internet\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: Restrict Telnet\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 23\n ToPort: 23\n CidrIp: 10.0.0.0/8 # CRITICAL: Restricts Telnet (23) to internal range; removes Internet-wide (0.0.0.0/0) access\n```", + "Other": "1. In the AWS console, go to VPC > Security Groups\n2. Select the affected security group and open Inbound rules\n3. Click Edit inbound rules\n4. Find any rule allowing TCP port 23 (Telnet) from 0.0.0.0/0 or ::/0\n5. Delete the rule or change Source to a specific trusted CIDR\n6. Save rules", + "Terraform": "```hcl\nresource \"aws_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress {\n from_port = 23\n to_port = 23\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"] # CRITICAL: Restricts Telnet (23); do not use 0.0.0.0/0 or ::/0\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Remove rules permitting Internet access to `TCP 23` from `0.0.0.0/0` or `::/0`. Disable **Telnet** on hosts. Prefer **SSH** or **SSM** and apply **least privilege** network rules. Restrict admin access to trusted IPs, VPN, or private endpoints, and use **defense in depth** with NACLs and logging.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_telnet_23" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_wide_open_public_ipv4/ec2_securitygroup_allow_wide_open_public_ipv4.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_wide_open_public_ipv4/ec2_securitygroup_allow_wide_open_public_ipv4.metadata.json index 87c89a3b18..d8cc0f08c6 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_wide_open_public_ipv4/ec2_securitygroup_allow_wide_open_public_ipv4.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_wide_open_public_ipv4/ec2_securitygroup_allow_wide_open_public_ipv4.metadata.json @@ -1,29 +1,36 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_allow_wide_open_public_ipv4", - "CheckTitle": "Ensure no security groups allow ingress and egress from wide-open IP address with a mask between 0 and 24.", + "CheckTitle": "Security group has no ingress or egress rules with public IPv4 CIDR ranges from /1 to /23", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Initial Access/Unauthorized Access" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no security groups allow ingress and egress from wide-open IP address with a mask between 0 and 24.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** with rules that permit non-RFC1918 IPv4 ranges wider than `/24` are identified across both **ingress** and **egress**.\n\nThe focus is on public CIDRs (`/1`-`/23`) that broadly expose sources or destinations, not on private networks.", + "Risk": "Over-broad public CIDRs expand exposure and enable:\n- **Confidentiality** loss via unauthorized access and exfiltration\n- **Integrity** compromise by exploiting exposed services\n- **Availability** impact from scanning and abuse\n\nOpen egress further allows **C2** beacons and bulk data exfiltration to untrusted IPs.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000229600-ensure-vpc-security-groups-not-wide-open-public-ipv4-cidr-ranges-non-rfc1918-", + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: ensure no public IPv4 CIDR wider than /24\nResources:\n \"\":\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: \"sg\"\n VpcId: \"\"\n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 443\n ToPort: 443\n CidrIp: 203.0.113.0/24 # Critical: /24 (or private RFC1918) avoids wide-open public /1-/23\n```", + "Other": "1. In the AWS Console, go to VPC > Security Groups\n2. Select the security group with the finding\n3. Click Edit inbound rules (and Edit outbound rules if needed)\n4. For any rule with a public IPv4 CIDR mask /1-/23, delete it or change the CIDR to a private RFC1918 range or to /24 or more specific (e.g., 203.0.113.0/24)\n5. Save rules", + "Terraform": "```hcl\n# Terraform: ensure no public IPv4 CIDR wider than /24\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress {\n from_port = 443\n to_port = 443\n protocol = \"tcp\"\n cidr_blocks = [\"203.0.113.0/24\"] # Critical: /24 (or private RFC1918) avoids wide-open public /1-/23\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** on security groups:\n- Allow only known IPs (prefer `/32` or tight CIDRs)\n- Use **private connectivity** (VPN, Direct Connect, private endpoints)\n- Restrict and log **egress**; deny by default\n- Segment with security group references and **network ACLs** for **defense-in-depth**", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_wide_open_public_ipv4" } }, "Categories": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json index 3510a573cd..e790e88698 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json @@ -1,32 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_default_restrict_traffic", - "CheckTitle": "Ensure the default security group of every VPC restricts all traffic.", + "CheckTitle": "VPC default security group has no inbound or outbound rules", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure the default security group of every VPC restricts all traffic.", - "Risk": "Even having a perimeter firewall, having security groups open allows any user or malware with vpc access to scan for well known and sensitive ports and gain access to instance.", + "Description": "**Default VPC security group** should have **no inbound or outbound rules**. This evaluates whether the group allows any traffic-ingress, egress, or self-referencing-instead of remaining empty.", + "Risk": "Permissive rules in the **default security group** mean instances that inherit it can communicate widely. This enables **lateral movement**, **port scanning**, and **data exfiltration**; unrestricted egress aids **C2**. Confidentiality and integrity are reduced, and the blast radius of a compromise grows.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/eks/latest/userguide/sec-group-reqs.html", + "https://docs.aws.amazon.com/config/latest/developerguide/vpc-default-security-group-closed.html" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/networking-policies/networking_4#aws-console", - "Terraform": "https://docs.prowler.com/checks/aws/networking-policies/networking_4#terraform" + "Other": "1. Open the AWS Console and go to VPC > Security > Security groups\n2. Select the security group named \"default\" for the affected VPC\n3. In Inbound rules, click Edit inbound rules, delete all rules, and Save\n4. In Outbound rules, click Edit outbound rules, delete all rules, and Save\n5. Repeat for each VPC that has this finding", + "Terraform": "```hcl\nresource \"aws_default_security_group\" \"\" {\n vpc_id = \"\"\n\n ingress = [] # Critical: removes all inbound rules from the default SG\n egress = [] # Critical: removes all outbound rules from the default SG\n}\n```" }, "Recommendation": { - "Text": "Apply Zero Trust approach. Implement a process to scan and remediate unrestricted or overly permissive security groups. Recommended best practices is to narrow the definition for the minimum ports required.", - "Url": "https://docs.aws.amazon.com/eks/latest/userguide/sec-group-reqs.html" + "Text": "Enforce **least privilege**: keep the default group empty by removing all ingress and egress rules. Use dedicated security groups per workload with explicit sources, destinations, and ports. Regularly review for broad CIDRs like `0.0.0.0/0` and apply **defense in depth** via automation and policy guardrails.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_default_restrict_traffic" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_from_launch_wizard/ec2_securitygroup_from_launch_wizard.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_from_launch_wizard/ec2_securitygroup_from_launch_wizard.metadata.json index f5ad2a7559..9244ade62f 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_from_launch_wizard/ec2_securitygroup_from_launch_wizard.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_from_launch_wizard/ec2_securitygroup_from_launch_wizard.metadata.json @@ -1,29 +1,33 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_from_launch_wizard", - "CheckTitle": "Security Groups created by EC2 Launch Wizard.", + "CheckTitle": "Security group not created using the EC2 Launch Wizard", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Security Groups created by EC2 Launch Wizard.", - "Risk": "Security Groups Created on the AWS Console using the EC2 wizard may allow port 22 from 0.0.0.0/0.", + "Description": "**EC2 security groups** whose names include `launch-wizard` are identified as created by the **EC2 Launch Wizard**, distinguishing auto-generated groups from curated, baseline-controlled groups.", + "Risk": "Wizard-generated groups often include **overly permissive rules** (e.g., `0.0.0.0/0` to admin ports), expanding exposure. Attackers can run **port scans** and **brute-force** to gain entry, then **lateral movement** and **data exfiltration**, impacting **confidentiality** and **integrity**; broad egress aids command-and-control.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/security-group-prefixed-with-launch-wizard.html", + "https://docs.aws.amazon.com/eks/latest/userguide/sec-group-reqs.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/security-group-prefixed-with-launch-wizard.html", - "Terraform": "" + "CLI": "aws ec2 delete-security-group --group-id ", + "NativeIaC": "```yaml\n# CloudFormation: create a security group not named by Launch Wizard\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: SG not created by Launch Wizard\n VpcId: \n GroupName: # Critical: name does NOT contain \"launch-wizard\" to avoid FAIL\n```", + "Other": "1. In the AWS console, go to EC2 > Network & Security > Security Groups\n2. In the search box, filter by Name contains \"launch-wizard\"\n3. For each matching group, open the References tab and remove it from any ENIs/instances by replacing it with a different security group\n4. Select the launch-wizard security group and choose Actions > Delete security group > Delete\n5. Verify no security groups remain with names containing \"launch-wizard\"", + "Terraform": "```hcl\n# Create a security group not named by Launch Wizard\nresource \"aws_security_group\" \"\" {\n name = \"\" # Critical: name does NOT contain \"launch-wizard\" to avoid FAIL\n vpc_id = \"\"\n}\n```" }, "Recommendation": { - "Text": "Apply Zero Trust approach. Implement a process to scan and remediate security groups created by the EC2 Wizard. Recommended best practices is to use an authorized security group.", - "Url": "https://docs.aws.amazon.com/eks/latest/userguide/sec-group-reqs.html" + "Text": "Replace or harden these groups. Apply **least privilege**: restrict inbound to required sources, avoid public admin ports, and minimize egress. Use approved baseline security groups, enforce change control with IaC and guardrails, prefer private administration (bastion or Session Manager), and remove unused rules.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_from_launch_wizard" } }, "Categories": [], diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json index b27394371b..d2b3768dca 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json @@ -1,32 +1,38 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_not_used", - "CheckTitle": "Ensure there are no Security Groups not being used.", + "CheckTitle": "Non-default EC2 security group is in use", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Ensure there are no Security Groups not being used.", - "Risk": "Having clear definition and scope for Security Groups creates a better administration environment.", + "Description": "EC2 security groups, except `default`, are assessed for **unused** status: zero attached network interfaces, no AWS Lambda associations, and no references from other security groups.", + "Risk": "Orphaned security groups may later be attached with **overly permissive rules** without review, enabling unintended inbound or lateral access that compromises **confidentiality** and **integrity**. They also create **configuration drift**, increasing the chance of misapplied access controls.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/default-security-group-unrestricted.html", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security-groups.html" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aws ec2 delete-security-group --group-id ", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the non-default security group that shows Used by = 0 (no network interfaces or resources)\n3. Click Actions > Delete security group > Delete", + "Terraform": "```hcl\n# Destroy the unused non-default security group\nresource \"aws_security_group\" \"\" {\n count = 0 # Critical: setting count=0 removes/destroys this unused SG\n}\n```" }, "Recommendation": { - "Text": "List all the security groups and then use the cli to check if they are attached to an instance.", - "Url": "https://aws.amazon.com/premiumsupport/knowledge-center/ec2-find-security-group-resources/" + "Text": "Apply **least privilege** and strong lifecycle management: delete or quarantine **unused security groups**, enforce ownership tags and retention policies, review regularly, and manage changes via IaC with approvals. Restrict who can attach groups and use guardrails to prevent reuse of stale or permissive groups.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_not_used" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_with_many_ingress_egress_rules/ec2_securitygroup_with_many_ingress_egress_rules.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_with_many_ingress_egress_rules/ec2_securitygroup_with_many_ingress_egress_rules.metadata.json index 99d3b93e03..75be326e95 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_with_many_ingress_egress_rules/ec2_securitygroup_with_many_ingress_egress_rules.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_with_many_ingress_egress_rules/ec2_securitygroup_with_many_ingress_egress_rules.metadata.json @@ -1,32 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_securitygroup_with_many_ingress_egress_rules", - "CheckTitle": "Find security groups with more than 50 ingress or egress rules.", + "CheckTitle": "Security group has 50 or fewer inbound rules and 50 or fewer outbound rules", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" ], "ServiceName": "ec2", - "SubServiceName": "securitygroup", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - "Severity": "high", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "Find security groups with more than 50 ingress or egress rules.", - "Risk": "If Security groups are not properly configured the attack surface is increased.", + "Description": "**EC2 security groups** are evaluated for excessive rule counts, flagging groups where `ingress` or `egress` entries exceed the configured threshold (default `50`). This targets groups with unusually large rule sets that complicate access control.", + "Risk": "**Rule sprawl** weakens **least privilege**: large rule sets can hide overly permissive entries, exposing services to the Internet or unintended peers. This enables unauthorized access, data exfiltration, and lateral movement, impacting **confidentiality** and **integrity**, and can threaten **availability** via abuse of exposed services.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/security-group-rules-counts.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: Security group with limited number of rules\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: \"\"\n VpcId: \n SecurityGroupIngress:\n - IpProtocol: tcp\n FromPort: 443\n ToPort: 443\n CidrIp: 10.0.0.0/8 # Critical: keep total inbound rules 50; this example defines only 1 rule to ensure compliance\n```", + "Other": "1. In the AWS console, go to EC2 > Security Groups\n2. Select the security group that FAILED\n3. In Inbound rules, click Edit inbound rules\n4. Delete rules until the inbound rule count is 50 or fewer, then Save\n5. In Outbound rules, click Edit outbound rules\n6. Delete rules until the outbound rule count is 50 or fewer, then Save", + "Terraform": "```hcl\n# Terraform: Security group with limited number of rules\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n\n ingress { # Critical: keep total ingress/egress rules 50; single rule ensures PASS\n from_port = 443\n to_port = 443\n protocol = \"tcp\"\n cidr_blocks = [\"10.0.0.0/8\"]\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Zero Trust approach. Narrow ingress traffic as much as possible. Consider north-south as well as east-west traffic.", - "Url": "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + "Text": "Apply **least privilege** and **segmentation**:\n- Limit rules to required ports, protocols, and sources\n- Split workloads into dedicated security groups per role\n- Prefer SG-to-SG references over broad CIDRs\n- Regularly review, deduplicate, and remove stale rules\n- Layer controls (NACLs, private endpoints) for **defense in depth**", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_with_many_ingress_egress_rules" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/ec2/ec2_transitgateway_auto_accept_vpc_attachments/ec2_transitgateway_auto_accept_vpc_attachments.metadata.json b/prowler/providers/aws/services/ec2/ec2_transitgateway_auto_accept_vpc_attachments/ec2_transitgateway_auto_accept_vpc_attachments.metadata.json index 130d569913..97b0b11eea 100644 --- a/prowler/providers/aws/services/ec2/ec2_transitgateway_auto_accept_vpc_attachments/ec2_transitgateway_auto_accept_vpc_attachments.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_transitgateway_auto_accept_vpc_attachments/ec2_transitgateway_auto_accept_vpc_attachments.metadata.json @@ -1,32 +1,40 @@ { "Provider": "aws", "CheckID": "ec2_transitgateway_auto_accept_vpc_attachments", - "CheckTitle": "Amazon EC2 Transit Gateways should not automatically accept VPC attachment requests", + "CheckTitle": "Amazon EC2 Transit Gateway does not automatically accept shared VPC attachments", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Lateral Movement" ], "ServiceName": "ec2", - "SubServiceName": "transit-gateway", - "ResourceIdTemplate": "arn:aws:ec2:region:account-id:transit-gateway/tgw-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsEc2TransitGateway", "ResourceGroup": "network", - "Description": "Ensure EC2 transit gateways are not automatically accepting shared VPC attachments. We get a fail if a transit gateway is configured to automatically accept shared VPC attachment requests.", - "Risk": "Turning on AutoAcceptSharedAttachments allows a transit gateway to automatically accept any cross-account VPC attachment requests without verification. This increases the risk of unauthorized VPC attachments, compromising network security.", - "RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/ec2-transit-gateway-auto-vpc-attach-disabled.html", + "Description": "**EC2 Transit Gateways** with `AutoAcceptSharedAttachments=enable` automatically approve cross-account **VPC attachments**.\n\nThe evaluation identifies transit gateways configured to auto-accept shared attachments.", + "Risk": "Auto-accepting cross-account attachments can link untrusted VPCs to your routing domain, impacting:\n- **Confidentiality**: unintended visibility and data exfiltration\n- **Integrity**: route injection or traffic tampering\n- **Availability**: misrouting/blackholing and lateral movement", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/config/latest/developerguide/ec2-transit-gateway-auto-vpc-attach-disabled.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-23", + "https://docs.aws.amazon.com/vpc/latest/tgw/tgw-transit-gateways.html#tgw-modifying" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/ec2-controls.html#ec2-23", - "Terraform": "" + "CLI": "aws ec2 modify-transit-gateway --transit-gateway-id --options AutoAcceptSharedAttachments=disable", + "NativeIaC": "```yaml\n# CloudFormation: Disable auto-accept for shared attachments on a Transit Gateway\nResources:\n :\n Type: AWS::EC2::TransitGateway\n Properties:\n Options:\n AutoAcceptSharedAttachments: disable # Critical: turns off automatic acceptance of shared VPC attachments\n```", + "Other": "1. In the AWS Console, go to VPC > Transit Gateways\n2. Select the transit gateway and click Actions > Modify transit gateway\n3. Under Cross-account sharing options, uncheck Auto-accept shared attachments\n4. Click Save changes", + "Terraform": "```hcl\n# Terraform: Disable auto-accept for shared attachments on a Transit Gateway\nresource \"aws_ec2_transit_gateway\" \"\" {\n auto_accept_shared_attachments = \"disable\" # Critical: prevents automatic acceptance of cross-account VPC attachments\n}\n```" }, "Recommendation": { - "Text": "Turn off AutoAcceptSharedAttachments to ensure that only authorized VPC attachment requests are accepted", - "Url": "https://docs.aws.amazon.com/vpc/latest/tgw/tgw-transit-gateways.html#tgw-modifying" + "Text": "Disable `AutoAcceptSharedAttachments` and require **explicit approval** for every attachment.\n\nApply **least privilege** and **separation of duties** for approvers, limit shares to trusted accounts, and use **defense in depth** with segmentation and logging to audit the attachment lifecycle.", + "Url": "https://hub.prowler.com/check/ec2_transitgateway_auto_accept_vpc_attachments" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json b/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json index 4d29a2fa75..0eaabe27d3 100644 --- a/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json +++ b/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json @@ -40,5 +40,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding." } diff --git a/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.py b/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.py index 5fee9d0775..504102173a 100644 --- a/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.py +++ b/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.py @@ -8,6 +8,9 @@ from prowler.providers.aws.services.iam.lib.policy import is_policy_public class eventbridge_bus_cross_account_access(Check): def execute(self): findings = [] + trusted_account_ids = eventbridge_client.audit_config.get( + "trusted_account_ids", [] + ) for bus in eventbridge_client.buses.values(): if bus.policy is None: continue @@ -20,6 +23,7 @@ class eventbridge_bus_cross_account_access(Check): bus.policy, eventbridge_client.audited_account, is_cross_account_allowed=False, + trusted_account_ids=trusted_account_ids, ): report.status = "FAIL" report.status_extended = ( diff --git a/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.metadata.json b/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.metadata.json index 65805e45cb..29027d5f4a 100644 --- a/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.metadata.json +++ b/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.metadata.json @@ -39,5 +39,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding." } diff --git a/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.py b/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.py index c3a2a29377..897cee95a7 100644 --- a/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.py +++ b/prowler/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.iam.lib.policy import is_policy_public class eventbridge_schema_registry_cross_account_access(Check): def execute(self): findings = [] + trusted_account_ids = schema_client.audit_config.get("trusted_account_ids", []) for registry in schema_client.registries.values(): if registry.policy is None: continue @@ -16,6 +17,7 @@ class eventbridge_schema_registry_cross_account_access(Check): registry.policy, schema_client.audited_account, is_cross_account_allowed=False, + trusted_account_ids=trusted_account_ids, ): report.status = "FAIL" report.status_extended = f"EventBridge schema registry {registry.name} allows cross-account access." diff --git a/prowler/providers/aws/services/iam/lib/policy.py b/prowler/providers/aws/services/iam/lib/policy.py index b54809f4d6..8442fefa66 100644 --- a/prowler/providers/aws/services/iam/lib/policy.py +++ b/prowler/providers/aws/services/iam/lib/policy.py @@ -387,6 +387,7 @@ def is_policy_public( is_cross_account_allowed=True, not_allowed_actions: list = [], check_cross_service_confused_deputy=False, + trusted_account_ids: list = None, ) -> bool: """ Check if the policy allows public access to the resource. @@ -397,10 +398,19 @@ def is_policy_public( is_cross_account_allowed (bool): If the policy can allow cross-account access, default: True (https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html#cross-service-confused-deputy-prevention) not_allowed_actions (list): List of actions that are not allowed, default: []. If not_allowed_actions is empty, the function will not consider the actions in the policy. check_cross_service_confused_deputy (bool): If the policy is checked for cross-service confused deputy, default: False + trusted_account_ids (list): A list of trusted accound ids to reduce false positives on cross-account checks Returns: bool: True if the policy allows public access, False otherwise """ is_public = False + + if trusted_account_ids is None: + trusted_account_ids = [] + + trusted_accounts = set(trusted_account_ids) + if source_account: + trusted_accounts.add(source_account) + if policy: for statement in policy.get("Statement", []): # Only check allow statements @@ -414,13 +424,19 @@ def is_policy_public( isinstance(principal.get("AWS"), str) and source_account and not is_cross_account_allowed - and source_account not in principal.get("AWS", "") + and not any( + trusted_account in principal.get("AWS", "") + for trusted_account in trusted_accounts + ) ) or ( isinstance(principal.get("AWS"), list) and source_account and not is_cross_account_allowed - and not any( - source_account in principal_aws + and not all( + any( + trusted_account in principal_aws + for trusted_account in trusted_accounts + ) for principal_aws in principal["AWS"] ) ): diff --git a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/__init__.py b/prowler/providers/aws/services/rds/rds_instance_extended_support/__init__.py similarity index 100% rename from prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/__init__.py rename to prowler/providers/aws/services/rds/rds_instance_extended_support/__init__.py diff --git a/prowler/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support.metadata.json b/prowler/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support.metadata.json new file mode 100644 index 0000000000..c22a81a675 --- /dev/null +++ b/prowler/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "rds_instance_extended_support", + "CheckTitle": "RDS instance is not enrolled in RDS Extended Support", + "CheckType": [ + "Software and Configuration Checks/Patch Management", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "rds", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsRdsDbInstance", + "ResourceGroup": "database", + "Description": "**RDS DB instances** are evaluated for enrollment in Amazon RDS Extended Support. The check fails if `EngineLifecycleSupportis` set to `open-source-rds-extended-support`, indicating the instance will incur additional charges after standard support ends.", + "Risk": "DB instances enrolled in RDS Extended Support can incur additional charges after the end of standard support for the running database major version. Remaining on older major versions can also delay necessary upgrades, increasing operational and security risk.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support-viewing.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support-charges.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support-creating-db-instance.html" + ], + "Remediation": { + "Code": { + "CLI": "aws rds modify-db-instance --db-instance-identifier --engine-version --allow-major-version-upgrade --apply-immediately\n# For new DB instances created via automation, prevent enrollment by setting the lifecycle option:\naws rds create-db-instance ... --engine-lifecycle-support open-source-rds-extended-support-disabled", + "NativeIaC": "```yaml\n# CloudFormation: upgrade RDS engine version for an existing instance\nResources:\n :\n Type: AWS::RDS::DBInstance\n Properties:\n DBInstanceIdentifier: \n Engine: \n DBInstanceClass: db.t3.micro\n EngineVersion: # CRITICAL: move to a supported engine version\n AllowMajorVersionUpgrade: true # CRITICAL: required if upgrading major version\n ApplyImmediately: true # CRITICAL: apply change now to pass the check\n```", + "Other": "If your automation (CloudFormation/Terraform/SDK) creates or restores DB instances, set EngineLifecycleSupport/LifeCycleSupport to open-source-rds-extended-support-disabled where supported, and ensure your upgrade process keeps engines within standard support.", + "Terraform": "```hcl\n# Upgrade RDS engine version\nresource \"aws_db_instance\" \"\" {\n identifier = \"\"\n engine = \"\"\n instance_class = \"db.t3.micro\"\n allocated_storage = 20\n\n engine_version = \"\" # CRITICAL: use a supported version\n allow_major_version_upgrade = true # CRITICAL: needed for major upgrades\n apply_immediately = true # CRITICAL: apply now to pass the check\n}\n```" + }, + "Recommendation": { + "Text": "Upgrade enrolled DB instances to an engine version covered under standard support to stop Extended Support charges. For new DB instances and restores created via automation, explicitly set the engine lifecycle support option to avoid unintended enrollment in RDS Extended Support when that is your policy.", + "Url": "https://hub.prowler.com/check/rds_instance_extended_support" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support.py b/prowler/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support.py new file mode 100644 index 0000000000..6caee8b808 --- /dev/null +++ b/prowler/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support.py @@ -0,0 +1,37 @@ +""" +Prowler check: rds_instance_extended_support + +This check fails when an RDS DB instance is enrolled in Amazon RDS Extended Support. +Enrollment is exposed via the "EngineLifecycleSupport" attribute returned by DescribeDBInstances. +""" + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.rds.rds_client import rds_client + + +class rds_instance_extended_support(Check): + def execute(self): + findings = [] + + for db_instance in rds_client.db_instances.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=db_instance) + + # EngineLifecycleSupport can be absent when Extended Support is not applicable. + lifecycle_support = getattr(db_instance, "engine_lifecycle_support", None) + + if lifecycle_support == "open-source-rds-extended-support": + report.status = "FAIL" + report.status_extended = ( + f"RDS instance {db_instance.id} ({db_instance.engine} {db_instance.engine_version}) " + f"is enrolled in RDS Extended Support (EngineLifecycleSupport={lifecycle_support})." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"RDS instance {db_instance.id} ({db_instance.engine} {db_instance.engine_version}) " + "is not enrolled in RDS Extended Support." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/rds/rds_service.py b/prowler/providers/aws/services/rds/rds_service.py index 4a1022daaa..7828978653 100644 --- a/prowler/providers/aws/services/rds/rds_service.py +++ b/prowler/providers/aws/services/rds/rds_service.py @@ -59,6 +59,9 @@ class RDS(AWSService): endpoint=instance.get("Endpoint", {}), engine=instance["Engine"], engine_version=instance["EngineVersion"], + engine_lifecycle_support=instance.get( + "EngineLifecycleSupport" + ), status=instance["DBInstanceStatus"], public=instance.get("PubliclyAccessible", False), encrypted=instance["StorageEncrypted"], @@ -531,6 +534,7 @@ class DBInstance(BaseModel): endpoint: dict engine: str engine_version: str + engine_lifecycle_support: Optional[str] = None status: str public: bool encrypted: bool diff --git a/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.metadata.json b/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.metadata.json index c92912b2cd..cb1209b645 100644 --- a/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.metadata.json +++ b/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.metadata.json @@ -39,5 +39,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "This check supports the `trusted_account_ids` configuration in config.yaml to allow specific cross-account access without triggering a finding." } diff --git a/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.py b/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.py index 3178a08aa1..3df3941d85 100644 --- a/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.py +++ b/prowler/providers/aws/services/s3/s3_bucket_cross_account_access/s3_bucket_cross_account_access.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.s3.s3_client import s3_client class s3_bucket_cross_account_access(Check): def execute(self): findings = [] + trusted_account_ids = s3_client.audit_config.get("trusted_account_ids", []) for bucket in s3_client.buckets.values(): if bucket.policy is None: continue @@ -19,7 +20,10 @@ class s3_bucket_cross_account_access(Check): f"S3 Bucket {bucket.name} does not have a bucket policy." ) elif is_policy_public( - bucket.policy, s3_client.audited_account, is_cross_account_allowed=False + bucket.policy, + s3_client.audited_account, + is_cross_account_allowed=False, + trusted_account_ids=trusted_account_ids, ): report.status = "FAIL" report.status_extended = f"S3 Bucket {bucket.name} has a bucket policy allowing cross account access." diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py index b91fd51f56..a4fc4b9b9b 100644 --- a/prowler/providers/azure/lib/service/service.py +++ b/prowler/providers/azure/lib/service/service.py @@ -1,6 +1,10 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed + from prowler.lib.logger import logger from prowler.providers.azure.azure_provider import AzureProvider +MAX_WORKERS = 10 + class AzureService: def __init__( @@ -20,6 +24,25 @@ class AzureService: self.audit_config = provider.audit_config self.fixer_config = provider.fixer_config + self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + def __threading_call__(self, call, iterator): + """Execute a function across multiple items using threading.""" + items = list(iterator) if not isinstance(iterator, list) else iterator + + futures = {self.thread_pool.submit(call, item): item for item in items} + results = [] + + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception: + pass + + return results + def __set_clients__(self, identity, session, service, region_config): clients = {} try: diff --git a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json index d84af0afdf..df372b54e5 100644 --- a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json +++ b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "aisearch_service_not_publicly_accessible", - "CheckTitle": "Restrict public network access to the AI Search Service", + "CheckTitle": "AI Search service has public network access disabled", "CheckType": [], "ServiceName": "aisearch", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureSearchService", + "ResourceType": "microsoft.search/searchservices", "ResourceGroup": "database", - "Description": "Ensure that public network access to the Search Service is restricted.", - "Risk": "Public accessibility exposes the Search Service to potential attacks, unauthorized usage, and data breaches. Restricting access minimizes the surface area for attacks and ensures that only authorized networks can access the search service.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/search/service-configure-firewall#configure-network-access-in-azure-portal", + "Description": "**Azure AI Search service** limits its data-plane endpoint by disabling **public network access**. This evaluation checks whether the service only permits connections via **private endpoints** or narrowly scoped, explicitly allowed sources.", + "Risk": "Internet-reachable search endpoints impact CIA:\n- Confidentiality: unauthorized queries reveal indexed data/metadata\n- Integrity: stolen admin/query keys allow index changes or deletions\n- Availability: abuse and scanning drive throttling and outages", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/azure-cognitive-search-security-baseline", + "https://www.azadvertizer.net/azpolicyadvertizer/9cee519f-d9c1-4fd9-9f79-24ec3449ed30.html", + "https://learn.microsoft.com/en-us/azure/search/service-configure-firewall#configure-network-access-in-azure-portal" + ], "Remediation": { "Code": { - "CLI": "az search service update --resource-group --name --public-access disabled", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az search service update --name --resource-group --public-access disabled", + "NativeIaC": "```bicep\n// Disable public network access for an Azure AI Search service\nresource search 'Microsoft.Search/searchServices@2023-11-01' = {\n name: ''\n location: ''\n sku: { name: 'basic' }\n properties: {\n publicNetworkAccess: 'disabled' // CRITICAL: Disables public access so the service is not reachable from the internet\n }\n}\n```", + "Other": "1. In the Azure portal, open your AI Search service\n2. Go to Settings > Networking\n3. Under Public network access, select Disabled\n4. Click Save\n5. Wait a few minutes and re-run the check", + "Terraform": "```hcl\n# Disable public network access for Azure AI Search\nresource \"azurerm_search_service\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"basic\"\n\n public_network_access_enabled = false # CRITICAL: Disables public access to pass the check\n}\n```" }, "Recommendation": { - "Text": "Ensure that the necessary virtual network configurations or IP rules are in place to allow access from required services once public access is restricted. Review the network access settings regularly to maintain a secure environment. To restrict public network access to your Search Service: 1. Navigate to your Search Service y in the Azure Portal. 2. Under 'Settings'->'Networking', configure the 'Public network access' settings to 'Disabled'. 3. Set up virtual network service endpoints or private endpoints as needed for secure access. 4. Review and adjust IP access rules as necessary.", - "Url": "https://learn.microsoft.com/en-us/azure/search/service-configure-firewall#configure-network-access-in-azure-portal" + "Text": "Set `Public network access: Disabled`. Prefer **Private Link** and restrict any residual exposure to specific sources only. Use **least privilege** with Microsoft Entra ID RBAC instead of keys. Apply **defense in depth** with IP rules/trusted services, enable logs, and review access lists regularly.", + "Url": "https://hub.prowler.com/check/aisearch_service_not_publicly_accessible" } }, "Categories": [ + "internet-exposed", "gen-ai" ], "DependsOn": [], diff --git a/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.metadata.json index bfd91d3a14..1b3fb3e3d8 100644 --- a/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.metadata.json +++ b/prowler/providers/azure/services/aks/aks_cluster_rbac_enabled/aks_cluster_rbac_enabled.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "aks_cluster_rbac_enabled", - "CheckTitle": "Ensure AKS RBAC is enabled", + "CheckTitle": "AKS cluster has RBAC enabled", "CheckType": [], "ServiceName": "aks", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Microsoft.ContainerService/ManagedClusters", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", "ResourceGroup": "container", - "Description": "Azure Kubernetes Service (AKS) can be configured to use Azure Active Directory (AD) for user authentication. In this configuration, you sign in to an AKS cluster using an Azure AD authentication token. You can also configure Kubernetes role-based access control (Kubernetes RBAC) to limit access to cluster resources based a user's identity or group membership.", - "Risk": "Kubernetes RBAC and AKS help you secure your cluster access and provide only the minimum required permissions to developers and operators.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/aks/azure-ad-rbac?tabs=portal", + "Description": "**AKS clusters** with **Kubernetes RBAC** enforce authorization through roles and bindings mapped to identities and groups.\n\nThis evaluates whether the cluster has RBAC enabled to control access to namespaces and cluster-wide resources.", + "Risk": "Without **Kubernetes RBAC**, authorization becomes overly broad, weakening **least privilege**. Compromised credentials could read secrets, alter workloads, or delete services, impacting **confidentiality**, **integrity**, and **availability**, and enabling lateral movement across the cluster.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AKS/enable-role-based-access-control-for-kubernetes-service.html#", + "https://learn.microsoft.com/en-us/azure/aks/azure-ad-rbac?tabs=portal" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AKS/enable-role-based-access-control-for-kubernetes-service.html#", - "Terraform": "https://docs.prowler.com/checks/azure/azure-kubernetes-policies/bc_azr_kubernetes_2#terraform" + "NativeIaC": "```bicep\n// Enable Kubernetes RBAC on AKS\nresource 'Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: ''\n properties: {\n enableRBAC: true // Critical: turns on Kubernetes RBAC to pass the check\n }\n}\n```", + "Other": "1. In Azure portal, go to Kubernetes services > Create (or edit your deployment template)\n2. On the Authentication tab, set Kubernetes RBAC to Enabled\n3. Review + Create to deploy (re-create the cluster if the setting can't be changed on an existing one)", + "Terraform": "```hcl\n# AKS with Kubernetes RBAC enabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"default\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n\n role_based_access_control_enabled = true # Critical: enables Kubernetes RBAC to pass the check\n}\n```" }, "Recommendation": { - "Text": "", - "Url": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-privileged-access#pa-7-follow-just-enough-administration-least-privilege-principle" + "Text": "Enable **Kubernetes RBAC** and design permissions with **least privilege**: scope roles to namespaces, grant access via groups, apply deny-by-default, and separate duties for admins and operators.\n\nIntegrate with **Microsoft Entra ID** and review/audit role bindings to maintain **defense in depth**.", + "Url": "https://hub.prowler.com/check/aks_cluster_rbac_enabled" } }, - "Categories": [], + "Categories": [ + "cluster-security", + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.metadata.json b/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.metadata.json index 38c5472bbd..c766ae8a70 100644 --- a/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.metadata.json +++ b/prowler/providers/azure/services/aks/aks_clusters_created_with_private_nodes/aks_clusters_created_with_private_nodes.metadata.json @@ -1,30 +1,39 @@ { "Provider": "azure", "CheckID": "aks_clusters_created_with_private_nodes", - "CheckTitle": "Ensure clusters are created with Private Nodes", + "CheckTitle": "AKS cluster nodes do not have public IP addresses", "CheckType": [], "ServiceName": "aks", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.ContainerService/ManagedClusters", + "ResourceType": "microsoft.containerservice/managedclusters", "ResourceGroup": "container", - "Description": "Disable public IP addresses for cluster nodes, so that they only have private IP addresses. Private Nodes are nodes with no public IP addresses.", - "Risk": "Disabling public IP addresses on cluster nodes restricts access to only internal networks, forcing attackers to obtain local network access before attempting to compromise the underlying Kubernetes hosts.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/aks/private-clusters", + "Description": "**AKS agent pools** use only private addressing, with node public IP assignment disabled (`enableNodePublicIP=false`). Clusters where any pool assigns a public IP to nodes are identified.", + "Risk": "**Public node IPs** expose worker VMs to Internet scanning and exploit attempts against OS or kubelet services. Successful compromise can lead to stolen secrets and images (**confidentiality**), workload tampering (**integrity**), and node/resource exhaustion via DDoS or cryptomining (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AKS/private-cluster-nodes.html", + "https://learn.microsoft.com/en-us/azure/aks/access-private-cluster", + "https://learn.microsoft.com/en-us/azure/aks/private-clusters" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```bicep\n// AKS cluster with nodes not assigned public IPs\nresource mc '@Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: ''\n identity: {\n type: 'SystemAssigned'\n }\n properties: {\n dnsPrefix: ''\n agentPoolProfiles: [\n {\n name: 'nodepool1'\n count: 1\n vmSize: 'Standard_DS2_v2'\n enableNodePublicIP: false // CRITICAL: ensures nodes have no public IPs\n }\n ]\n }\n}\n```", + "Other": "1. In the Azure portal, go to Kubernetes services > > Node pools\n2. For any node pool showing Node public IP: Enabled, click Add node pool\n3. Create the new pool with the same size/count; set Node public IP to Disabled (or uncheck Enable node public IP)\n4. Wait until the new pool is Ready\n5. If replacing a system pool, set the new pool to Mode: System (or Set as default system pool)\n6. Delete the old node pool(s) that had Node public IP enabled\n7. Verify all node pools show Node public IP: Disabled", + "Terraform": "```hcl\n# AKS cluster with node public IPs disabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"default\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n enable_node_public_ip = false # CRITICAL: ensures nodes have no public IPs\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n}\n```" }, "Recommendation": { - "Text": "", - "Url": "https://learn.microsoft.com/en-us/azure/aks/access-private-cluster" + "Text": "Disable **public node IPs** on all agent pools (`enableNodePublicIP=false`) and route egress via a **NAT gateway** or **Azure Firewall**. Apply **least privilege** with NSGs blocking Internet ingress, use **private endpoints** and **bastion/VPN** for admin access, and adopt **defense in depth** with continuous hardening and monitoring.", + "Url": "https://hub.prowler.com/check/aks_clusters_created_with_private_nodes" } }, - "Categories": [], + "Categories": [ + "internet-exposed", + "trust-boundaries", + "cluster-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.metadata.json b/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.metadata.json index 908660055f..57c8f83600 100644 --- a/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.metadata.json +++ b/prowler/providers/azure/services/aks/aks_clusters_public_access_disabled/aks_clusters_public_access_disabled.metadata.json @@ -1,30 +1,40 @@ { "Provider": "azure", "CheckID": "aks_clusters_public_access_disabled", - "CheckTitle": "Ensure clusters are created with Private Endpoint Enabled and Public Access Disabled", + "CheckTitle": "AKS cluster has a private endpoint and node public access is disabled", "CheckType": [], "ServiceName": "aks", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.ContainerService/ManagedClusters", + "ResourceType": "microsoft.containerservice/managedclusters", "ResourceGroup": "container", - "Description": "Disable access to the Kubernetes API from outside the node network if it is not required.", - "Risk": "In a private cluster, the master node has two endpoints, a private and public endpoint. The private endpoint is the internal IP address of the master, behind an internal load balancer in the master's wirtual network. Nodes communicate with the master using the private endpoint. The public endpoint enables the Kubernetes API to be accessed from outside the master's virtual network. Although Kubernetes API requires an authorized token to perform sensitive actions, a vulnerability could potentially expose the Kubernetes publically with unrestricted access. Additionally, an attacker may be able to identify the current cluster and Kubernetes API version and determine whether it is vulnerable to an attack. Unless required, disabling public endpoint will help prevent such threats, and require the attacker to be on the master's virtual network to perform any attack on the Kubernetes API.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/aks/private-clusters?tabs=azure-portal", + "Description": "**AKS clusters** expose a **private control plane FQDN** and agent pools have `enable_node_public_ip=false`.\n\nThe evaluation focuses on the presence of a private FQDN and the absence of public IPs on nodes.", + "Risk": "Exposed node IPs or a publicly reachable API increase attack surface, impacting **confidentiality** and **integrity**.\n- Internet scans reach kubelet, NodePort, or management agents\n- Exploits enable RCE and **lateral movement**\n- Metadata/secret theft leads to **credential compromise**\n\n**Availability** is at risk from DDoS on exposed endpoints.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/private-clusters?tabs=azure-portal", + "https://learn.microsoft.com/en-us/azure/aks/access-private-cluster?tabs=azure-cli", + "https://support.icompaas.com/support/solutions/articles/62000234657-ensure-clusters-are-created-with-private-endpoint-enabled-and-public-access-disabled", + "https://docs.trendmicro.com/en-us/documentation/article/trend-vision-one-cis-aks17-542" + ], "Remediation": { "Code": { - "CLI": "az aks update -n -g --disable-public-fqdn", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "", + "NativeIaC": "```bicep\n// AKS cluster with private endpoint and nodes without public IPs\nresource aks 'Microsoft.ContainerService/managedClusters@2023-11-01' = {\n name: ''\n location: ''\n identity: {\n type: 'SystemAssigned'\n }\n properties: {\n dnsPrefix: 'dns'\n apiServerAccessProfile: {\n enablePrivateCluster: true // Critical: enables private cluster (private endpoint/FQDN)\n }\n agentPoolProfiles: [\n {\n name: 'nodepool1'\n count: 1\n vmSize: 'Standard_DS2_v2'\n enableNodePublicIP: false // Critical: disables public IPs on nodes\n }\n ]\n }\n}\n```", + "Other": "1. In the Azure portal, go to Azure Kubernetes Service and select your cluster\n2. Go to Networking > API server access\n3. Set Private cluster to Enabled and click Save\n4. Go to Node pools, open each pool, select Networking, set Public IP per node to Disabled, and Save\n5. Repeat step 4 for all node pools", + "Terraform": "```hcl\n# AKS cluster with private endpoint and nodes without public IPs\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"dns\"\n\n private_cluster_enabled = true # Critical: enables private cluster (private endpoint/FQDN)\n\n default_node_pool {\n name = \"nodepool1\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n enable_node_public_ip = false # Critical: disables public IPs on nodes\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n}\n```" }, "Recommendation": { - "Text": "To use a private endpoint, create a new private endpoint in your virtual network then create a link between your virtual network and a new private DNS zone", - "Url": "https://learn.microsoft.com/en-us/azure/aks/access-private-cluster?tabs=azure-cli" + "Text": "Adopt **private clusters** and eliminate node public IPs:\n- Set `enable_node_public_ip=false` on all pools\n- Use **Private Link** or peered VNets with VPN/ExpressRoute for admin access\n- If public API is unavoidable, restrict with IP ranges and strong auth\n- Enforce NSGs/firewalls and **least privilege** for layered defense", + "Url": "https://hub.prowler.com/check/aks_clusters_public_access_disabled" } }, - "Categories": [], + "Categories": [ + "internet-exposed", + "trust-boundaries", + "cluster-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.metadata.json index ee327a58db..edcc4c503f 100644 --- a/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.metadata.json +++ b/prowler/providers/azure/services/aks/aks_network_policy_enabled/aks_network_policy_enabled.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "aks_network_policy_enabled", - "CheckTitle": "Ensure Network Policy is Enabled and set as appropriate", + "CheckTitle": "AKS cluster has network policy enabled", "CheckType": [], "ServiceName": "aks", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.ContainerService/managedClusters", + "ResourceType": "microsoft.containerservice/managedclusters", "ResourceGroup": "container", - "Description": "When you run modern, microservices-based applications in Kubernetes, you often want to control which components can communicate with each other. The principle of least privilege should be applied to how traffic can flow between pods in an Azure Kubernetes Service (AKS) cluster. Let's say you likely want to block traffic directly to back-end applications. The Network Policy feature in Kubernetes lets you define rules for ingress and egress traffic between pods in a cluster.", - "Risk": "All pods in an AKS cluster can send and receive traffic without limitations, by default. To improve security, you can define rules that control the flow of traffic. Back-end applications are often only exposed to required front-end services, for example. Or, database components are only accessible to the application tiers that connect to them. Network Policy is a Kubernetes specification that defines access policies for communication between Pods. Using Network Policies, you define an ordered set of rules to send and receive traffic and apply them to a collection of pods that match one or more label selectors. These network policy rules are defined as YAML manifests. Network policies can be included as part of a wider manifest that also creates a deployment or service.", - "RelatedUrl": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-network-security#ns-2-connect-private-networks-together", + "Description": "**AKS clusters** enforce **Kubernetes network policies** so that pod-to-pod traffic is governed by explicit ingress and egress rules. The finding evaluates whether a cluster has network policy enforcement enabled to support fine-grained, label-based segmentation between workloads.", + "Risk": "Without network policy, pods can talk to any pod:\n- Easy lateral movement after a pod compromise\n- Unrestricted access to backend services and data\n- Covert exfiltration/C2 via East-West traffic\n\nThis harms **confidentiality** and **integrity** and amplifies the blast radius of runtime exploits.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/use-network-policies", + "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v2-network-security#ns-2-connect-private-networks-together", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AKS/enable-network-policy-support.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/azure/azure-kubernetes-policies/bc_azr_kubernetes_4#terraform" + "CLI": "az aks update --resource-group --name --network-policy calico", + "NativeIaC": "```bicep\n// Enable network policy on AKS\nparam location string = resourceGroup().location\n\nresource aks 'Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: location\n dnsPrefix: ''\n identity: { type: 'SystemAssigned' }\n agentPoolProfiles: [\n {\n name: 'systempool'\n vmSize: 'Standard_DS2_v2'\n count: 1\n }\n ]\n networkProfile: {\n networkPlugin: 'azure'\n networkPolicy: 'calico' // Critical: enables AKS network policy enforcement\n }\n}\n```", + "Other": "1. In Azure Portal, go to Kubernetes services and select your cluster\n2. Open Networking (or Settings > Networking)\n3. Set Network policy to Azure or Calico\n4. Click Save to apply", + "Terraform": "```hcl\n# Enable network policy on AKS\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"system\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n\n network_profile {\n network_plugin = \"azure\"\n network_policy = \"calico\" # Critical: enables AKS network policy\n }\n}\n```" }, "Recommendation": { - "Text": "", - "Url": "https://learn.microsoft.com/en-us/azure/aks/use-network-policies" + "Text": "Enable **network policy enforcement** and apply **least privilege** segmentation.\n- Start with a `deny-all` baseline, allow only required flows\n- Define both ingress and egress policies\n- Use consistent labels/namespaces\n- Layer with **defense in depth** (RBAC, node isolation, private networking) for zero-trust East-West control.", + "Url": "https://hub.prowler.com/check/aks_network_policy_enabled" } }, - "Categories": [], + "Categories": [ + "trust-boundaries", + "cluster-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Network Policy requires the Network Policy add-on. This add-on is included automatically when a cluster with Network Policy is created, but for an existing cluster, needs to be added prior to enabling Network Policy. Enabling/Disabling Network Policy causes a rolling update of all cluster nodes, similar to performing a cluster upgrade. This operation is long-running and will block other operations on the cluster (including delete) until it has run to completion. If Network Policy is used, a cluster must have at least 2 nodes of type n1-standard-1 or higher. The recommended minimum size cluster to run Network Policy enforcement is 3 n1-standard-1 instances. Enabling Network Policy enforcement consumes additional resources in nodes. Specifically, it increases the memory footprint of the kube-system process by approximately 128MB, and requires approximately 300 millicores of CPU." diff --git a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json index 128dcf5dda..4f2660d479 100644 --- a/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json +++ b/prowler/providers/azure/services/apim/apim_threat_detection_llm_jacking/apim_threat_detection_llm_jacking.metadata.json @@ -1,33 +1,36 @@ { "Provider": "azure", "CheckID": "apim_threat_detection_llm_jacking", - "CheckTitle": "Ensure Azure API Management is protected against LLM Jacking attacks", + "CheckTitle": "No potential LLM Jacking attacks detected across all Azure API Management instances", "CheckType": [], "ServiceName": "apim", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Azure API Management Instance", + "Severity": "critical", + "ResourceType": "microsoft.apimanagement/service", "ResourceGroup": "api_gateway", - "Description": "This check analyzes Azure API Management diagnostic logs in Log Analytics to detect potential LLM Jacking attacks by monitoring the frequency of LLM-related operations (ImageGenerations_Create, ChatCompletions_Create, Completions_Create) from individual IP addresses within a configurable time window.", - "Risk": "LLM Jacking attacks can lead to unauthorized access to AI models, potential data exfiltration, increased costs, and abuse of AI services. Attackers may use these endpoints to generate content, bypass rate limits, or access premium AI capabilities without proper authorization.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/api-management/monitor-api-management", + "Description": "**API Management** diagnostic logs in Log Analytics are analyzed for **LLM-related operations**. Requests are grouped by caller IP, the number of distinct monitored actions (e.g., `ChatCompletions_Create`, `ImageGenerations_Create`) within a configurable `minutes` window is measured, and that ratio is compared to a `threshold` to surface anomalous multi-action patterns.", + "Risk": "Concentrated LLM activity from one IP indicates **automation or leaked credentials**.\n- **Availability/cost**: rapid token burn and quota exhaustion\n- **Confidentiality**: exposure of prompts/completions and model details\n- **Integrity**: abuse of deployment/model actions enabling unauthorized changes or mass content generation", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/api-management/monitor-api-management" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```bicep\n// Blocks a specific IP at the global (service) policy level for APIM\nparam apimName string\nparam blockedIp string\n\nresource apim 'Microsoft.ApiManagement/service@2023-05-01-preview' existing = {\n name: apimName\n}\n\nresource apimPolicy 'Microsoft.ApiManagement/service/policies@2023-05-01-preview' = {\n parent: apim\n name: 'policy'\n properties: {\n value: '\n \n \n \n ' // Critical: Policy XML that blocks the offending IP\n format: 'xml' // Critical: Apply policy as XML\n }\n}\n```", + "Other": "1. In the Azure portal, open your API Management instance\n2. Go to APIs > All APIs\n3. Click Policies (Inbound processing)\n4. Add a when block to block the offending IP:\n - Condition: @(context.Request.IpAddress == \"\")\n - Action: return-response with status 403 Forbidden\n5. Save the policy\n6. Re-run the scan after the detection window elapses to confirm PASS", + "Terraform": "```hcl\n# Global APIM policy that blocks a specific IP\nresource \"azurerm_api_management_policy\" \"\" {\n api_management_id = \"\"\n\n # Critical: Policy XML that blocks the offending IP by returning 403\n xml_content = <\n \n \n \n \n \\\")\">\n \n \n \n \n \n \n \n \n \n\nXML\n}\n```" }, "Recommendation": { - "Text": "To protect against LLM Jacking attacks: 1. Enable diagnostic logging for APIM instances and send logs to Log Analytics workspace 2. Configure appropriate thresholds for LLM operation frequency monitoring 3. Set up alerts for suspicious activity patterns 4. Implement rate limiting and IP allowlisting for sensitive AI endpoints 5. Regularly review and analyze APIM access logs for anomalies", - "Url": "https://learn.microsoft.com/en-us/azure/api-management/monitor-api-management" + "Text": "Adopt **defense in depth** for LLM APIs:\n- Enforce **least privilege**; isolate management from inference\n- Prefer **managed identities** over keys; rotate secrets\n- Apply **quotas**, rate limiting, and IP allowlisting; use private access\n- Alert on anomalous action diversity; review logs\n\n*Tune `threshold` and `minutes` for your environment.*", + "Url": "https://hub.prowler.com/check/apim_threat_detection_llm_jacking" } }, "Categories": [ - "threat-detection", - "monitoring", - "logging" + "gen-ai", + "logging", + "threat-detection" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.metadata.json b/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.metadata.json index e71a123db5..cc0fb19df6 100644 --- a/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.metadata.json +++ b/prowler/providers/azure/services/app/app_client_certificates_on/app_client_certificates_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "app_client_certificates_on", - "CheckTitle": "Ensure the web app has 'Client Certificates (Incoming client certificates)' set to 'On'", + "CheckTitle": "Web app requires incoming client certificates", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.Web/sites/config", + "ResourceType": "microsoft.web/sites/config", "ResourceGroup": "serverless", - "Description": "Client certificates allow for the app to request a certificate for incoming requests. Only clients that have a valid certificate will be able to reach the app.", - "Risk": "The TLS mutual authentication technique in enterprise environments ensures the authenticity of clients to the server. If incoming client certificates are enabled, then only an authenticated client who has valid certificates can access the app.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth?tabs=azurecli", + "Description": "**Azure App Service apps** enforce **mutual TLS** when `client certificate mode` is set to `Required`, meaning every inbound request must present a valid client certificate that the app can validate.", + "Risk": "Without **mTLS**, clients aren't cryptographically authenticated at the transport layer. Adversaries can reach endpoints using spoofed sources or stolen tokens, leading to unauthorized data access (confidentiality), request tampering (integrity), and automated abuse that degrades service (availability).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-identity-management#im-4-authenticate-server-and-services", + "https://learn.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth" + ], "Remediation": { "Code": { - "CLI": "az webapp update --resource-group --name --set clientCertEnabled=true", - "NativeIaC": "", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/azure/azure-networking-policies/bc_azr_networking_7#terraform" + "CLI": "az webapp update --resource-group --name --set clientCertEnabled=true clientCertMode=Required", + "NativeIaC": "```bicep\n// Require client certificates for the web app\nresource appService 'Microsoft.Web/sites@2022-03-01' = {\n name: ''\n location: ''\n properties: {\n serverFarmId: ''\n clientCertEnabled: true // Critical: enables mutual TLS\n clientCertMode: 'Required' // Critical: enforces client certs (passes the check)\n }\n}\n```", + "Other": "1. Open Azure Portal and go to App Services\n2. Select your web app\n3. Go to Configuration > General settings\n4. Under Client certificate mode, select Required\n5. Click Save", + "Terraform": "```hcl\n# Require client certificates for the App Service (use azurerm_linux_web_app or azurerm_windows_web_app)\nresource \"azurerm_linux_web_app\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n service_plan_id = \"\"\n\n client_certificate_enabled = true # Critical: enables mutual TLS\n client_certificate_mode = \"Required\" # Critical: enforces client certs (passes the check)\n\n site_config {}\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to App Services 3. Click on each App 4. Under the Settings section, Click on Configuration, then General settings 5. Set the option Client certificate mode located under Incoming client certificates to Require", - "Url": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-identity-management#im-4-authenticate-server-and-services" + "Text": "Set `client certificate mode` to `Required` and validate client certs in application logic (issuer, validity, revocation).\n\nEnforce HTTPS only, avoid broad exclusion paths, and manage certs via a trusted CA with rotation and revocation. Apply **least privilege** and **zero trust**, layering with private access or IP restrictions *as needed*.", + "Url": "https://hub.prowler.com/check/app_client_certificates_on" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Utilizing and maintaining client certificates will require additional work to obtain and manage replacement and key rotation." diff --git a/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.metadata.json b/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.metadata.json index a03a9106cd..6c8078964c 100644 --- a/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.metadata.json +++ b/prowler/providers/azure/services/app/app_ensure_auth_is_set_up/app_ensure_auth_is_set_up.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_ensure_auth_is_set_up", - "CheckTitle": "Ensure App Service Authentication is set up for apps in Azure App Service", + "CheckTitle": "App Service app has App Service Authentication enabled", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Azure App Service Authentication is a feature that can prevent anonymous HTTP requests from reaching a Web Application or authenticate those with tokens before they reach the app. If an anonymous request is received from a browser, App Service will redirect to a logon page. To handle the logon process, a choice from a set of identity providers can be made, or a custom authentication mechanism can be implemented.", - "Risk": "By Enabling App Service Authentication, every incoming HTTP request passes through it before being handled by the application code. It also handles authentication of users with the specified provider (Azure Active Directory, Facebook, Google, Microsoft Account, and Twitter), validation, storing and refreshing of tokens, managing the authenticated sessions and injecting identity information into request headers.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization", + "Description": "**Azure App Service** can enforce built-in **Authentication/Authorization** (Easy Auth) so requests are authenticated by a provider before reaching app code.\n\nThis evaluates whether platform auth is enabled for the app and an identity provider is configured.", + "Risk": "Without platform **authentication**, apps may accept **anonymous requests**, enabling unauthorized access to APIs and data. Attackers can enumerate endpoints and bypass weak app checks, risking data exposure (C), unauthorized changes (I), and automated abuse impacting availability (A).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-app-service-authentication.html", + "https://learn.microsoft.com/en-us/azure/app-service/scenario-secure-app-authentication-app-service", + "https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization" + ], "Remediation": { "Code": { "CLI": "az webapp auth update --resource-group --name --enabled true", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-app-service-authentication.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/bc_azr_general_2#terraform" + "NativeIaC": "```bicep\n// Enable App Service Authentication (Easy Auth) for an existing Web App\nresource auth 'Microsoft.Web/sites/config@2022-03-01' = {\n name: '/authsettingsV2'\n properties: {\n platformEnabled: true // CRITICAL: Turns on built-in authentication for the app\n }\n}\n```", + "Other": "1. Sign in to the Azure Portal and go to App Services\n2. Select and open Authentication\n3. Click Add identity provider, choose Microsoft, and click Add\n4. Save changes\n\nThis enables App Service Authentication for the app", + "Terraform": "```hcl\n# Enable App Service Authentication for an App Service (use azurerm_linux_web_app or azurerm_windows_web_app)\nresource \"azurerm_linux_web_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {}\n\n auth_settings_v2 {\n auth_enabled = true # CRITICAL: Enables built-in authentication (Easy Auth)\n }\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to App Services 3. Click on each App 4. Under Setting section, click on Authentication 5. If no identity providers are set up, then click Add identity provider 6. Choose other parameters as per your requirements and click on Add", - "Url": "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#website-contributor" + "Text": "Enable App Service **Authentication/Authorization** and set `Require authentication` for unauthenticated requests. Use **Microsoft Entra** or a trusted IdP, restrict tenants/audiences, enforce HTTPS, and apply **least privilege** with role/claim checks and Conditional Access for defense-in-depth. Avoid `Allow anonymous requests`.", + "Url": "https://hub.prowler.com/check/app_ensure_auth_is_set_up" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "This is only required for App Services which require authentication. Enabling on site like a marketing or support website will prevent unauthenticated access which would be undesirable. Adding Authentication requirement will increase cost of App Service and require additional security components to facilitate the authentication" diff --git a/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.metadata.json b/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.metadata.json index 8ad8062ad5..8aa9a18699 100644 --- a/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.metadata.json +++ b/prowler/providers/azure/services/app/app_ensure_http_is_redirected_to_https/app_ensure_http_is_redirected_to_https.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_ensure_http_is_redirected_to_https", - "CheckTitle": "Ensure Web App Redirects All HTTP traffic to HTTPS in Azure App Service", + "CheckTitle": "App Service web app redirects HTTP to HTTPS", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Microsoft.Web/sites/config", + "Severity": "high", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Azure Web Apps allows sites to run under both HTTP and HTTPS by default. Web apps can be accessed by anyone using non-secure HTTP links by default. Non-secure HTTP requests can be restricted and all HTTP requests redirected to the secure HTTPS port. It is recommended to enforce HTTPS-only traffic.", - "Risk": "Enabling HTTPS-only traffic will redirect all non-secure HTTP requests to HTTPS ports. HTTPS uses the TLS/SSL protocol to provide a secure connection which is both encrypted and authenticated. It is therefore important to support HTTPS for the security benefits.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-bindings#enforce-https", + "Description": "**Azure App Service web apps** redirect `HTTP` traffic to `HTTPS` when the `HTTPS Only` setting is enabled. This evaluation identifies apps that do not force secure transport by checking whether plaintext requests are automatically redirected to encrypted endpoints.", + "Risk": "Leaving **HTTP accessible** enables **man-in-the-middle** interception, credential and cookie theft, and response tampering. This undermines **confidentiality** and **integrity**, and can lead to session hijacking or downgrade attacks that bypass TLS.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-bindings#enforce-https", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-https-only-traffic.html#", + "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-3-encrypt-sensitive-data-in-transit" + ], "Remediation": { "Code": { "CLI": "az webapp update --resource-group --name --set httpsOnly=true", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-https-only-traffic.html#", - "Terraform": "https://docs.prowler.com/checks/azure/azure-networking-policies/bc_azr_networking_5#terraform" + "NativeIaC": "```bicep\n// Enable HTTPS-only redirect on an existing App Service\nresource app 'Microsoft.Web/sites@2022-09-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n httpsOnly: true // Critical: forces redirect from HTTP to HTTPS\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and go to App Services\n2. Select your web app\n3. Go to TLS/SSL settings and set HTTPS Only to On\n4. Click Save", + "Terraform": "```hcl\n# Enforce HTTPS-only on an App Service\nresource \"azurerm_windows_web_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n https_only = true # Critical: redirects HTTP to HTTPS\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to App Services 3. Click on each App 4. Under Setting section, Click on Configuration 5. In the General Settings section, set the HTTPS Only to On 6. Click Save", - "Url": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-3-encrypt-sensitive-data-in-transit" + "Text": "Enforce **HTTPS-only** for all apps.\n- Use trusted certificates and require `TLS 1.2` or later\n- Enable **HSTS** to prevent downgrade/mixed-content\n- Redirect legacy `http` links to `https`\n- Minimize HTTP exposure via WAF/CDN or private access\nApply **defense in depth** to protect data in transit.", + "Url": "https://hub.prowler.com/check/app_ensure_http_is_redirected_to_https" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "When it is enabled, every incoming HTTP request is redirected to the HTTPS port. This means an extra level of security will be added to the HTTP requests made to the app." diff --git a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json index 36298d6b54..857a3c5ee7 100644 --- a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json +++ b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_ensure_java_version_is_latest", - "CheckTitle": "Ensure that 'Java version' is the latest, if used to run the Web App", + "CheckTitle": "App Service web app uses the latest supported Java version or 17 by default", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Periodically, newer versions are released for Java software either due to security flaws or to include additional functionality. Using the latest Java version for web apps is recommended in order to take advantage of security fixes, if any, and/or new functionalities of the newer version.", - "Risk": "Newer versions may contain security enhancements and additional functionality. Using the latest software version is recommended in order to take advantage of enhancements and new capabilities. With each software installation, organizations need to determine if a given update meets their requirements. They must also verify the compatibility and support provided for any additional software against the update revision that is selected.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings", + "Description": "**Azure App Service web apps** that run **Java** are assessed to ensure their configured runtime uses the **latest supported major version** (LTS) for the environment, across Linux and Windows.\n\n*Only apps with Java enabled are considered.*", + "Risk": "Using an **outdated Java runtime** enables known exploits like **remote code execution**, unsafe **deserialization**, and **cryptographic flaws**, risking data theft and tampering (**confidentiality, integrity**) and outages or takeover (**availability**). Unsupported versions also delay critical security patches.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-java.html", + "https://learn.microsoft.com/en-us/azure/app-service/configure-language-java?pivots=platform-linux#choosing-a-java-runtime-version", + "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-java.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-java-version-is-the-latest-if-used-to-run-the-web-app#terraform" + "CLI": "az webapp config set --resource-group --name --linux-fx-version \"JAVA|17-java17\"", + "NativeIaC": "```bicep\n// Set Java 17 for a Linux App Service\nresource app 'Microsoft.Web/sites@2022-09-01' existing = {\n name: ''\n}\n\nresource appConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '${app.name}/web'\n properties: {\n linuxFxVersion: 'JAVA|17-java17' // Critical: ensures runtime includes 'java17' so the check passes\n }\n}\n```", + "Other": "1. In the Azure portal, go to App Services and open your web app\n2. Select Settings > Configuration > General settings\n3. For Linux apps: under Stack settings, choose Java and set Java version to 17 (or choose Tomcat/JBoss with Java version 17)\n4. For Windows apps: under Stack settings, set Java version to 17\n5. Click Save and restart if prompted", + "Terraform": "```hcl\n# Linux Web App configured to use Java 17\nresource \"azurerm_linux_web_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {\n application_stack {\n java_version = \"17\" # Critical: sets Java to 17 to pass the check\n }\n }\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to App Services 3. Click on each App 4. Under Settings section, click on Configuration 5. Click on the General settings pane and ensure that for a Stack of Java the Major Version and Minor Version reflect the latest stable and supported release, and that the Java web server version is set to the auto-update option. NOTE: No action is required if Java version is set to Off, as Java is not used by your web app.", - "Url": "https://learn.microsoft.com/en-us/azure/app-service/configure-language-java?pivots=platform-linux#choosing-a-java-runtime-version" + "Text": "Adopt the **latest supported LTS Java** (`java `) and standardize on that major line.\n- Enable automatic minor/patch updates\n- Validate upgrades in a staging environment before production\n- Retire deprecated runtimes and track vendor EOL\n\nApply **change management** and **defense in depth** to reduce exposure.", + "Url": "https://hub.prowler.com/check/app_ensure_java_version_is_latest" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "If your app is written using version-dependent features or libraries, they may not be available on the latest version. If you wish to upgrade, research the impact thoroughly. Upgrading may have unforeseen consequences that could result in downtime." diff --git a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json index 97ac010617..41f90cac3d 100644 --- a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json +++ b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_ensure_php_version_is_latest", - "CheckTitle": "Ensure That 'PHP version' is the Latest, If Used to Run the Web App", + "CheckTitle": "App Service web app uses the latest supported PHP version or 8.2 by default", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Periodically newer versions are released for PHP software either due to security flaws or to include additional functionality. Using the latest PHP version for web apps is recommended in order to take advantage of security fixes, if any, and/or additional functionalities of the newer version.", - "Risk": "Newer versions may contain security enhancements and additional functionality. Using the latest software version is recommended in order to take advantage of enhancements and new capabilities. With each software installation, organizations need to determine if a given update meets their requirements. They must also verify the compatibility and support provided for any additional software against the update revision that is selected.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings", + "Description": "**Azure App Service web apps** running **PHP** are evaluated to ensure the runtime is configured to the **latest supported** release. The finding compares the app's PHP stack (from `linuxFxVersion` or `php_version`) with the newest available version.", + "Risk": "Using **outdated PHP** enables exploitation of known flaws, including **remote code execution**, causing secret disclosure (confidentiality), unauthorized changes (integrity), and crashes or downtime (availability). Deprecated versions lack patches, widening exposure and instability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-php.html", + "https://learn.microsoft.com/en-us/azure/app-service/configure-language-php?pivots=platform-linux#set-php-version", + "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings" + ], "Remediation": { "Code": { - "CLI": "az webapp config set --resource-group --name [--linux-fx-version ][--php-version ]", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-php.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-php-version-is-the-latest-if-used-to-run-the-web-app#terraform" + "CLI": "az webapp config set --resource-group --name --linux-fx-version \"PHP|8.2\"", + "NativeIaC": "```bicep\n// Update App Service runtime to latest PHP\nresource appConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '/web'\n properties: {\n linuxFxVersion: 'PHP|8.2' // Critical: sets the app runtime to PHP 8.2 (latest) to pass the check\n }\n}\n```", + "Other": "1. In Azure Portal, go to App Services and select your app\n2. Navigate to Settings > Configuration > General settings\n3. Under Stack settings, select PHP and set Version to 8.2\n4. Click Save and confirm the restart", + "Terraform": "```hcl\n# Set latest PHP version on Linux Web App\nresource \"azurerm_linux_web_app\" \"app\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {\n application_stack {\n php_version = \"8.2\" # Critical: sets PHP to latest to pass the check\n }\n }\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home open the Portal Menu in the top left 2. Go to App Services 3. Click on each App 4. Under Settings section, click on Configuration 5. Click on the General settings pane, ensure that for a Stack of PHP the Major Version and Minor Version reflect the latest stable and supported release. NOTE: No action is required If PHP version is set to Off or is set with an empty value as PHP is not used by your web app", - "Url": "https://learn.microsoft.com/en-us/azure/app-service/configure-language-php?pivots=platform-linux#set-php-version" + "Text": "Standardize on the **latest supported PHP** and avoid EoL releases. Update promptly after security advisories, validate in staging, and automate version governance across apps. Prefer supported Linux runtimes, limit optional extensions, and apply **defense in depth** and **least privilege** to reduce blast radius.", + "Url": "https://hub.prowler.com/check/app_ensure_php_version_is_latest" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "If your app is written using version-dependent features or libraries, they may not be available on the latest version. If you wish to upgrade, research the impact thoroughly. Upgrading may have unforeseen consequences that could result in downtime" diff --git a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json index c1ee66c3fa..112d572bf9 100644 --- a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json +++ b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_ensure_python_version_is_latest", - "CheckTitle": "Ensure that 'Python version' is the Latest Stable Version, if Used to Run the Web App", + "CheckTitle": "App Service web app uses the latest supported Python version or 3.12 by default", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Periodically, newer versions are released for Python software either due to security flaws or to include additional functionality. Using the latest full Python version for web apps is recommended in order to take advantage of security fixes, if any, and/or additional functionalities of the newer version.", - "Risk": "Newer versions may contain security enhancements and additional functionality. Using the latest software version is recommended in order to take advantage of enhancements and new capabilities. With each software installation, organizations need to determine if a given update meets their requirements. They must also verify the compatibility and support provided for any additional software against the update revision that is selected. Using the latest full version will keep your stack secure to vulnerabilities and exploits.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings", + "Description": "**Azure App Service web apps** using **Python** are assessed to confirm the runtime is the **latest supported version** (e.g., `3.12`). The evaluation reads the app's stack configuration to detect Python usage and compares the configured runtime against the defined latest baseline.", + "Risk": "Outdated **Python runtimes** weaken security and reliability:\n- Compromise confidentiality via known interpreter/SSL flaws\n- Undermine integrity through RCE and package exploitation\n- Reduce availability when deprecated versions lose patches and break under load", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-python.html", + "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings", + "https://learn.microsoft.com/en-us/azure/app-service/configure-language-python#configure-python-version" + ], "Remediation": { "Code": { - "CLI": "az webapp config set --resource-group --name [--linux-fx-version 'PYTHON|3.12']", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-python.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-python-version-is-the-latest-if-used-to-run-the-web-app" + "CLI": "az webapp config set --resource-group --name --linux-fx-version \"PYTHON|3.12\"", + "NativeIaC": "```bicep\n// Set the Web App runtime to the latest Python version\nresource app 'Microsoft.Web/sites@2022-03-01' existing = {\n name: ''\n}\n\nresource config 'Microsoft.Web/sites/config@2022-03-01' = {\n name: '${app.name}/web'\n properties: {\n linuxFxVersion: 'PYTHON|3.12' // Critical: sets Python runtime to 3.12 to pass the check\n }\n}\n```", + "Other": "1. In the Azure portal, go to App Services and select your app\n2. Go to Settings > Configuration > General settings\n3. Under Stack settings, set Python version to 3.12\n4. Click Save and confirm the restart", + "Terraform": "```hcl\n# Configure the Web App to use the latest Python version\nresource \"azurerm_linux_web_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {\n application_stack {\n python_version = \"3.12\" # Critical: sets Python runtime to 3.12 to pass the check\n }\n }\n}\n```" }, "Recommendation": { - "Text": "From Azure Portal 1. From Azure Home open the Portal Menu in the top left 2. Go to App Services 3. Click on each App 4. Under Settings section, click on Configuration 5. Click on the General settings pane and ensure that the Major Version and the Minor Version is set to the latest stable version available (Python 3.11, at the time of writing) NOTE: No action is required if Python version is set to Off, as Python is not used by your web app.", - "Url": "https://learn.microsoft.com/en-us/azure/app-service/configure-language-python#configure-python-version" + "Text": "Adopt the **latest supported Python minor** for App Service and maintain a consistent upgrade policy. Track vendor EOL, test in staging, and roll out via CI/CD.\n\nApply **defense in depth**: minimize privileges and enforce strong TLS to reduce exposure during updates.", + "Url": "https://hub.prowler.com/check/app_ensure_python_version_is_latest" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "If your app is written using version-dependent features or libraries, they may not be available on the latest version. If you wish to upgrade, research the impact thoroughly. Upgrading may have unforeseen consequences that could result in downtime." diff --git a/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.metadata.json b/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.metadata.json index 1a941d66b5..9ad5a3987e 100644 --- a/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.metadata.json +++ b/prowler/providers/azure/services/app/app_ensure_using_http20/app_ensure_using_http20.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_ensure_using_http20", - "CheckTitle": "Ensure that 'HTTP Version' is the Latest, if Used to Run the Web App", + "CheckTitle": "App Service web app has HTTP/2.0 enabled", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Periodically, newer versions are released for HTTP either due to security flaws or to include additional functionality. Using the latest HTTP version for web apps to take advantage of security fixes, if any, and/or new functionalities of the newer version.", - "Risk": "Newer versions may contain security enhancements and additional functionality. Using the latest version is recommended in order to take advantage of enhancements and new capabilities. With each software installation, organizations need to determine if a given update meets their requirements. They must also verify the compatibility and support provided for any additional software against the update revision that is selected. HTTP 2.0 has additional performance improvements on the head-of-line blocking problem of old HTTP version, header compression, and prioritization of requests. HTTP 2.0 no longer supports HTTP 1.1's chunked transfer encoding mechanism, as it provides its own, more efficient, mechanisms for data streaming.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings", + "Description": "**Azure App Service web apps** are evaluated for **HTTP/2 support** via the `http20_enabled` configuration, indicating whether the site serves traffic using the HTTP/2 protocol", + "Risk": "Without **HTTP/2**, apps remain on **HTTP/1.1**, increasing connection overhead and head-of-line blocking, which can reduce **availability** under load. Inefficient use of TLS sessions raises **DoS susceptibility** and degrades user experience, impacting service reliability", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://azure.microsoft.com/en-us/blog/announcing-http-2-support-in-azure-app-service/", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-http-2-for-app-service-web-applications.html", + "https://learn.microsoft.com/en-us/azure/app-service/configure-common?tabs=portal#general-settings" + ], "Remediation": { "Code": { "CLI": "az webapp config set --resource-group --name --http20-enabled true", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-http-2-for-app-service-web-applications.html", - "Terraform": "" + "NativeIaC": "```bicep\n// Enable HTTP/2.0 on an existing App Service web app\nresource webConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '/web'\n properties: {\n http20Enabled: true // Critical: enables HTTP/2.0 for the app\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and go to App Services\n2. Select your web app\n3. Navigate to Settings > Configuration > General settings\n4. Set HTTP version to 2.0\n5. Click Save", + "Terraform": "```hcl\n# Enable HTTP/2.0 for an App Service (use azurerm_linux_web_app or azurerm_windows_web_app)\nresource \"azurerm_linux_web_app\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n service_plan_id = \"\"\n\n site_config {\n http2_enabled = true # Critical: enables HTTP/2.0\n }\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to App Services 3. Click on each App 4. Under Setting section, Click on Configuration 5. Set HTTP version to 2.0 under General settings", - "Url": "https://azure.microsoft.com/en-us/blog/announcing-http-2-support-in-azure-app-service/" + "Text": "Enable **HTTP/2** (`http20_enabled=true`) to use a modern, efficient transport.\n\n- Enforce `HTTPS Only` and a strong minimum `TLS` version for defense-in-depth\n- Validate app/library compatibility before rollout\n- Monitor performance and errors post-change; deploy gradually", + "Url": "https://hub.prowler.com/check/app_ensure_using_http20" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-posture-vulnerability-management#pv-7-rapidly-and-automatically-remediate-software-vulnerabilities" diff --git a/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.metadata.json b/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.metadata.json index 9dea34a22c..229d41c549 100644 --- a/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.metadata.json +++ b/prowler/providers/azure/services/app/app_ftp_deployment_disabled/app_ftp_deployment_disabled.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "app_ftp_deployment_disabled", - "CheckTitle": "Ensure FTP deployments are Disabled", + "CheckTitle": "App Service web app has FTP disabled or FTPS-only enforced", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Microsoft.Web/sites/config", + "Severity": "high", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "By default, Azure Functions, Web, and API Services can be deployed over FTP. If FTP is required for an essential deployment workflow, FTPS should be required for FTP login for all App Service Apps and Functions.", - "Risk": "Azure FTP deployment endpoints are public. An attacker listening to traffic on a wifi network used by a remote employee or a corporate network could see login traffic in clear-text which would then grant them full control of the code base of the app or service. This finding is more severe if User Credentials for deployment are set at the subscription level rather than using the default Application Credentials which are unique per App.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/deploy-ftp?tabs=portal", + "Description": "**Azure App Service web apps** are evaluated for **FTP exposure** via the `ftpsState` setting. Values `FtpsOnly` or `Disabled` indicate FTP is not allowed; `AllAllowed` means both FTP and FTPS are accepted.", + "Risk": "Allowing **FTP (unencrypted)** exposes credentials on public endpoints, enabling **credential theft** and **session hijacking**.\n\nCompromise grants write access to code and content, enabling **malicious deployments**, backdoors, and data leakage, degrading **integrity** and **confidentiality**-with greater blast radius if shared, user-scope publishing credentials are used.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/app-service/deploy-ftp?tabs=portal", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/ftp-access-disabled.html", + "https://learn.microsoft.com/en-gb/answers/questions/1323820/can-i-create-an-azure-policy-that-disables-both-ft", + "https://icompaas.freshdesk.com/support/solutions/articles/62000234759-ensure-ftp-state-is-set-to-ftps-only-or-disabled-" + ], "Remediation": { "Code": { - "CLI": "az webapp config set --resource-group --name --ftps-state [disabled|FtpsOnly]", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/ftp-access-disabled.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-ftp-deployments-are-disabled#terraform" + "CLI": "az webapp config set --resource-group --name --ftps-state FtpsOnly", + "NativeIaC": "```bicep\n// Configure an existing App Service to enforce FTPS-only\nresource webConfig 'Microsoft.Web/sites/config@2022-03-01' = {\n name: '/web'\n properties: {\n ftpsState: 'FtpsOnly' // CRITICAL: Sets FTP state to FTPS-only, avoiding insecure 'AllAllowed'\n }\n}\n```", + "Other": "1. In Azure Portal, go to App Services and select your app\n2. Go to Settings > Configuration > General settings\n3. Set FTP state to FTPS only (or Disabled)\n4. Click Save", + "Terraform": "```hcl\n# Enforce FTPS-only on an App Service\nresource \"azurerm_windows_web_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {\n ftps_state = \"FtpsOnly\" # CRITICAL: Enforces FTPS-only (not AllAllowed)\n }\n}\n```" }, "Recommendation": { - "Text": "1. Go to the Azure Portal 2. Select App Services 3. Click on an app 4. Select Settings and then Configuration 5. Under General Settings, for the Platform Settings, the FTP state should be set to Disabled or FTPS Only", - "Url": "" + "Text": "Disable FTP or enforce **FTPS** (`ftpsState: FtpsOnly` or `Disabled`).\n\nPrefer **CI/CD** over manual FTP and apply **least privilege** with app-scoped credentials. Rotate publishing secrets, enforce modern TLS, and restrict access via private networking. *If FTP is unavoidable*, require FTPS and monitor publishing logs.", + "Url": "https://hub.prowler.com/check/app_ftp_deployment_disabled" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Any deployment workflows that rely on FTP or FTPs rather than the WebDeploy or HTTPs endpoints may be affected." diff --git a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.metadata.json b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.metadata.json index d764bfb31a..1c12372936 100644 --- a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.metadata.json +++ b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "app_function_access_keys_configured", - "CheckTitle": "Ensure that Azure Functions are using access keys for enhanced security", + "CheckTitle": "Function app has function keys configured", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Azure Functions provide a way to secure HTTP function endpoints during development and production. Using access keys adds an extra layer of protection, ensuring that only authorized users or systems can access the functions. This is particularly important when dealing with public apps or sensitive data.", - "Risk": "Unprotected function endpoints may be vulnerable to unauthorized access, leading to potential data breaches or malicious activity.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger?tabs=python-v2%2Cisolated-process%2Cnodejs-v4%2Cfunctionsv2&pivots=programming-language-csharp#authorization-keys", + "Description": "**Azure Function apps** are evaluated for configured **function access keys** on HTTP endpoints.\n\nThe finding distinguishes functions with at least one access key defined from those without any keys configured.", + "Risk": "Missing **access keys** weakens authentication, enabling unsolicited calls to function endpoints. This risks:\n- loss of **confidentiality** via data exposure\n- compromised **integrity** by triggering unintended actions\n- reduced **availability** from abuse, throttling, and cost spikes", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-functions/security-concepts?tabs=v4#function-access-keys", + "https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-anonymous-access.html" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "az functionapp function keys set --resource-group --name --function-name --key-name default --key-value ", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-anonymous-access.html", + "Other": "1. Sign in to the Azure portal and go to your Function App\n2. Select Functions, then click the specific function\n3. Open Function keys (or API keys)\n4. Click Add (New function key), set Name (e.g., default) and value (or generate)\n5. Save to create the key", "Terraform": "" }, "Recommendation": { - "Text": "Use access keys to secure Azure Functions. You can create and manage keys in the Azure portal or using the Azure CLI. For more information, see the official documentation.", - "Url": "https://learn.microsoft.com/en-us/azure/azure-functions/security-concepts?tabs=v4#function-access-keys" + "Text": "Enforce **function keys** for non-public endpoints and apply **least privilege**:\n- avoid `anonymous` when not required\n- rotate keys; don't share the `admin` key\n- enable **App Service Authentication** or **API Management** for identity-aware access\n- restrict inbound networks and monitor logs\n- store and rotate secrets in **Key Vault**", + "Url": "https://hub.prowler.com/check/app_function_access_keys_configured" } }, - "Categories": [], + "Categories": [ + "identity-access", + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "For additional security, consider using managed identities and key vaults along with access keys. This provides granular control over resource access and improves auditability." diff --git a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.metadata.json b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.metadata.json index 26d50f0cc6..0c75807763 100644 --- a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.metadata.json +++ b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_function_application_insights_enabled", - "CheckTitle": "Ensure Function App has Application Insights configured", + "CheckTitle": "Function App has Application Insights configured", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Microsoft.Web/sites", + "Severity": "medium", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Application Insights is a powerful tool for monitoring the performance and health of Azure Function Apps. It provides valuable insights into exceptions, performance issues, and usage patterns, enabling timely detection and resolution of issues.", - "Risk": "Without Application Insights, you may miss critical errors, performance degradation, or abnormal behavior in your Function App, potentially impacting availability and user experience.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview", + "Description": "**Azure Function apps** are configured to send telemetry to **Application Insights** when application settings include `APPLICATIONINSIGHTS_CONNECTION_STRING` or `APPINSIGHTS_INSTRUMENTATIONKEY`.", + "Risk": "Without this telemetry, **visibility** into exceptions, dependencies, and performance is lost, reducing **availability** and delaying response. Gaps in traces mask anomalous traffic and failures, enabling prolonged outages and undermining **integrity** of processing (e.g., undetected retries or timeouts).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/app/monitor-functions", + "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/function-app-insights-on.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/function-app-insights-on.html", - "Terraform": "" + "CLI": "az functionapp config appsettings set --resource-group --name --settings APPLICATIONINSIGHTS_CONNECTION_STRING=", + "NativeIaC": "```bicep\n// Add Application Insights connection string to an existing Function App\nresource functionApp 'Microsoft.Web/sites@2022-09-01' existing = {\n name: ''\n}\n\nresource appSettings 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '${functionApp.name}/appsettings'\n properties: {\n APPLICATIONINSIGHTS_CONNECTION_STRING: '' // Critical: setting this enables Application Insights for the Function App\n }\n}\n```", + "Other": "1. In Azure Portal, go to Function App > Configuration > Application settings\n2. Click + New application setting\n3. Name: APPLICATIONINSIGHTS_CONNECTION_STRING\n4. Value: paste the connection string from your Application Insights resource (Overview > Connection string)\n5. Click OK, then Save\n6. If prompted, click Continue to apply the changes", + "Terraform": "```hcl\n# Add Application Insights connection string to an existing Function App via ARM deployment\nresource \"azurerm_resource_group_template_deployment\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n deployment_mode = \"Incremental\"\n\n template_content = jsonencode({\n \"$schema\" = \"https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#\",\n \"contentVersion\" = \"1.0.0.0\",\n \"resources\" = [\n {\n \"type\" = \"Microsoft.Web/sites/config\",\n \"apiVersion\" = \"2022-09-01\",\n \"name\" = \"/appsettings\",\n \"properties\" = {\n \"APPLICATIONINSIGHTS_CONNECTION_STRING\" = \"\" // Critical: setting this enables Application Insights for the Function App\n }\n }\n ]\n })\n}\n```" }, "Recommendation": { - "Text": "Enable Application Insights for your Azure Function App to monitor its performance and health.", - "Url": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/monitor-functions" + "Text": "Enable **Application Insights** for each Function App using a `APPLICATIONINSIGHTS_CONNECTION_STRING` and standardize telemetry. Apply **defense in depth**: use distributed tracing, alert on errors/latency, and enforce least-privilege access and retention on logs to prevent blind spots and speed recovery.", + "Url": "https://hub.prowler.com/check/app_function_application_insights_enabled" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.metadata.json b/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.metadata.json index a07cbcc521..fc0ae0564f 100644 --- a/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.metadata.json +++ b/prowler/providers/azure/services/app/app_function_ftps_deployment_disabled/app_function_ftps_deployment_disabled.metadata.json @@ -1,31 +1,36 @@ { "Provider": "azure", "CheckID": "app_function_ftps_deployment_disabled", - "CheckTitle": "Ensure that FTP and FTPS deployments are disabled for Azure Functions to prevent unauthorized access and data breaches.", + "CheckTitle": "Function app has FTP and FTPS deployments disabled", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Azure FTP deployment endpoints are unencrypted and public, making them vulnerable to attacks. Disabling FTP and FTPS deployments enhances security by preventing unauthorized access to login credentials and sensitive codebases.", - "Risk": "If left enabled, attackers can intercept network traffic and gain full control of the app or service, leading to potential data breaches and unauthorized modifications.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/app-service/deploy-ftp", + "Description": "**Azure Function apps** are evaluated for the `ftps_state` setting that controls **FTP/FTPS deployment endpoints**. Values `AllAllowed` or `FtpsOnly` indicate deployment over FTP/FTPS is enabled, while `Disabled` indicates both endpoints are turned off.", + "Risk": "Enabled **FTP/FTPS deployment** undermines confidentiality and integrity. FTP exposes credentials in cleartext; FTPS still presents a public basic-auth endpoint susceptible to brute force and credential reuse. Compromise enables **unauthorized code pushes**, leading to RCE, data leakage, and service disruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-functions/functions-deployment-technologies?tabs=windows#trigger-syncing", + "https://docs.microsoft.com/en-us/azure/app-service/deploy-ftp" + ], "Remediation": { "Code": { "CLI": "az webapp config set --resource-group --name --ftps-state Disabled", - "NativeIaC": "", - "Other": "", - "Terraform": "", - "Arm": "" + "NativeIaC": "```bicep\n// Disable FTP and FTPS on an existing Function App\nresource functionApp 'Microsoft.Web/sites@2022-09-01' existing = {\n name: ''\n}\n\nresource webConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: 'web'\n parent: functionApp\n properties: {\n ftpsState: 'Disabled' // CRITICAL: Disables both FTP and FTPS deployments\n }\n}\n```", + "Other": "1. In the Azure portal, go to your Function App\n2. Select Configuration > General settings\n3. Under Platform settings, set FTP state to Disabled\n4. Click Save", + "Terraform": "```hcl\n# Disable FTP and FTPS on a Function App\nresource \"azurerm_linux_function_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n storage_account_name = \"\"\n storage_account_access_key = \"\"\n functions_extension_version = \"~4\"\n\n site_config {\n ftps_state = \"Disabled\" # CRITICAL: Disables both FTP and FTPS deployments\n }\n}\n```" }, "Recommendation": { - "Text": "It is recommended to disable FTP and FTPS deployments for Azure Functions to mitigate security risks. Instead, consider using more secure deployment methods such as Docker contianer or enabling continuous deployment with GitHub Actions.", - "Url": "https://learn.microsoft.com/en-us/azure/azure-functions/functions-deployment-technologies?tabs=windows#trigger-syncing" + "Text": "Disable **FTP and FTPS deployment** on Function apps (`ftps_state: Disabled`). Adopt **defense in depth**: deploy via **CI/CD** with packaged artifacts (zip or containers), enforce **least privilege** publishing access, and limit exposure of build and deployment endpoints. *If unavoidable, use FTPS-only with TLS 1.2 and rotate credentials promptly.*", + "Url": "https://hub.prowler.com/check/app_function_ftps_deployment_disabled" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "This check ensures that Azure Functions are deployed securely, reducing the attack surface and protecting sensitive information." diff --git a/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.metadata.json b/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.metadata.json index 69a669e222..84860009a1 100644 --- a/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.metadata.json +++ b/prowler/providers/azure/services/app/app_function_identity_is_configured/app_function_identity_is_configured.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_function_identity_is_configured", - "CheckTitle": "Ensure Azure function has system or user assigned managed identity configured", + "CheckTitle": "Function app has a system-assigned or user-assigned managed identity enabled", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Azure Functions should have managed identities configured for enhanced security and access control.", - "Risk": "Not using managed identities can lead to less secure authentication and authorization practices, potentially exposing sensitive data.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview", + "Description": "**Azure Function Apps** are evaluated for an enabled **managed identity** (`SystemAssigned` or `UserAssigned`) configured on the app.\n\nThe finding indicates whether an identity is present to support token-based access to other Azure resources.", + "Risk": "Without **managed identities**, apps rely on stored secrets/keys, risking:\n- Confidentiality loss from leaked credentials\n- Integrity tampering via unauthorized writes\n- Availability outages from secret expiry/rotation\n\nCompromised keys enable unauthorized access and lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-system-assigned-identity.html", + "https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity", + "https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview" + ], "Remediation": { "Code": { - "CLI": "az functionapp identity assign --name --resource-group --identities [system]", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-system-assigned-identity.html", - "Terraform": "" + "CLI": "az functionapp identity assign --resource-group --name ", + "NativeIaC": "```bicep\n// Enable managed identity on an existing Function App\nparam location string = resourceGroup().location\n\nresource functionApp 'Microsoft.Web/sites@2022-03-01' = {\n name: ''\n location: location\n identity: {\n type: 'SystemAssigned' // CRITICAL: Enables a system-assigned managed identity so the check passes\n }\n}\n```", + "Other": "1. In Azure Portal, go to your Function App\n2. Under Settings, select Identity\n3. On the System assigned tab, set Status to On\n4. Click Save", + "Terraform": "```hcl\n# Enable managed identity on an existing Function App via PATCH\nresource \"azapi_update_resource\" \"\" {\n type = \"Microsoft.Web/sites@2022-03-01\"\n resource_id = \"\"\n body = jsonencode({\n identity = {\n type = \"SystemAssigned\" # CRITICAL: Enables a system-assigned managed identity so the check passes\n }\n })\n}\n```" }, "Recommendation": { - "Text": "It is recommended to enable managed identities for Azure Functions to enhance security and access control. This allows the function app to easily access other Azure resources securely and with the appropriate permissions.", - "Url": "https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity" + "Text": "Enable a **managed identity** on each Function App (`SystemAssigned` per app, `UserAssigned` for shared/long-lived needs). Replace secrets with token-based access and grant only required RBAC roles (**least privilege**). Remove keys from settings, apply **separation of duties**, and monitor access as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/app_function_identity_is_configured" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.metadata.json b/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.metadata.json index 89649e64d8..33fd5065e8 100644 --- a/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.metadata.json +++ b/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_function_identity_without_admin_privileges", - "CheckTitle": "Ensure that your Azure functions are not configured with an identity with admin privileges", + "CheckTitle": "Function app managed identity is not assigned Owner, Contributor, User Access Administrator, or Role Based Access Control Administrator roles", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "It is important to ensure that Azure functions are not configured with administrative privileges to maintain the principle of least privilege and reduce the attack surface. By limiting the privileges of Azure functions, potential security risks and data leaks can be mitigated.", - "Risk": "If Azure functions are configured with administrative privileges, it increases the risk of unauthorized access, privilege escalation, and data breaches. Attackers can exploit these privileges to gain access to sensitive data and compromise the entire system.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview", + "Description": "**Azure Function apps** with managed identities are evaluated for assignments to broad **administrative roles**: **Owner**, **Contributor**, **User Access Administrator**, **RBAC Administrator**.\n\nThe finding highlights functions whose identity carries elevated permissions beyond normal runtime needs.", + "Risk": "Admin rights on a function's identity expose the control plane.\n- Confidentiality: read secrets and data\n- Integrity: alter configs, grant roles, deploy changes\n- Availability: stop or delete resources\nA runtime compromise can enable **lateral movement** and **privilege escalation** across the environment.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-admin-permissions.html", + "https://docs.microsoft.com/en-us/azure/architecture/framework/security/design-identity-authorization#use-the-principle-of-least-privilege", + "https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "az role assignment delete --assignee --scope ", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-admin-permissions.html", + "Other": "1. In the Azure portal, open the scope where the role is assigned (e.g., Subscription, Resource group, or the Function App resource)\n2. Go to Access control (IAM) > Role assignments\n3. In the Principal filter, search for the Function App's managed identity ()\n4. For each assignment with role Owner, Contributor, User Access Administrator, or Role Based Access Control Administrator, click Remove\n5. Repeat steps 1-4 at all relevant scopes (subscription, resource group, and Function App) until no such admin roles remain for this identity", "Terraform": "" }, "Recommendation": { - "Text": "To remediate this issue, ensure that Azure functions are not configured with an identity that has administrative privileges. Instead, use the principle of least privilege to grant only the necessary permissions to Azure functions. For more information, refer to the official documentation: Use the principle of least privilege.", - "Url": "https://docs.microsoft.com/en-us/azure/architecture/framework/security/design-identity-authorization#use-the-principle-of-least-privilege" + "Text": "Apply **least privilege**: grant only narrowly scoped, data-plane permissions needed by the function; avoid broad roles like `Owner` or `Contributor`.\nUse **separation of duties** and **just-in-time** elevation for rare admin tasks.\nRegularly review role assignments and restrict scope to the smallest necessary boundary.", + "Url": "https://hub.prowler.com/check/app_function_identity_without_admin_privileges" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "This check helps prevent privilege escalation attacks and ensures that Azure functions operate with the necessary permissions, reducing the impact of potential security breaches." diff --git a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.metadata.json b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.metadata.json index 0f7e91dd19..2011ee5961 100644 --- a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.metadata.json +++ b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_function_latest_runtime_version", - "CheckTitle": "Ensure Azure Functions are using the latest supported runtime", + "CheckTitle": "Function app uses the latest supported runtime version (~4)", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Microsoft.Web/sites", + "Severity": "medium", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Keeping Azure Functions up to date with the latest supported runtime version is crucial for security and performance. Updates often include security patches and enhancements, helping to protect against known vulnerabilities and potential exploits. Additionally, newer runtime versions may offer improved functionality and optimized resource utilization.", - "Risk": "Using outdated runtime versions may introduce security risks and performance degradation. Outdated runtimes may have unpatched vulnerabilities, making them susceptible to attacks.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions", + "Description": "**Azure Function apps** are assessed for the **runtime version** set via `FUNCTIONS_EXTENSION_VERSION`. The finding identifies apps not configured to use the current supported major version `~4`.", + "Risk": "Outdated Functions runtimes erode CIA:\n- **Confidentiality**: known flaws enable unauthorized data access.\n- **Integrity**: RCE or binding bugs allow code tampering.\n- **Availability**: missing fixes cause crashes and scale faults.\n\nEnd-of-support versions (e.g., 2.x/3.x) lack security patches, increasing exploitability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions", + "https://learn.microsoft.com/en-us/azure/azure-functions/migrate-version-3-version-4?tabs=net8%2Cazure-cli%2Cwindows&pivots=programming-language-python", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-runtime-version.html" + ], "Remediation": { "Code": { "CLI": "az functionapp config appsettings set --name --resource-group --settings FUNCTIONS_EXTENSION_VERSION=~4", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-runtime-version.html", - "Terraform": "" + "NativeIaC": "```bicep\n// Set Azure Functions runtime to v4 for an existing Function App\nresource functionApp 'Microsoft.Web/sites@2022-09-01' existing = {\n name: ''\n}\n\nresource appSettings 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '${functionApp.name}/appsettings'\n properties: {\n FUNCTIONS_EXTENSION_VERSION: '~4' // Critical: ensures the Function App uses runtime ~4\n }\n}\n```", + "Other": "1. In the Azure portal, go to Function App \n2. Select Configuration > Application settings\n3. Add or edit the setting:\n - Name: FUNCTIONS_EXTENSION_VERSION\n - Value: ~4\n4. Click Save and confirm the restart\n5. Verify the setting shows FUNCTIONS_EXTENSION_VERSION = ~4", + "Terraform": "```hcl\n# Minimal Function App with runtime set to v4 (~4) - use azurerm_linux_function_app or azurerm_windows_function_app\nresource \"azurerm_resource_group\" \"example\" {\n name = \"\"\n location = \"eastus\"\n}\n\nresource \"azurerm_storage_account\" \"example\" {\n name = \"\"\n resource_group_name = azurerm_resource_group.example.name\n location = azurerm_resource_group.example.location\n account_tier = \"Standard\"\n account_replication_type = \"LRS\"\n}\n\nresource \"azurerm_service_plan\" \"example\" {\n name = \"\"\n resource_group_name = azurerm_resource_group.example.name\n location = azurerm_resource_group.example.location\n os_type = \"Linux\"\n sku_name = \"Y1\"\n}\n\nresource \"azurerm_linux_function_app\" \"example\" {\n name = \"\"\n location = azurerm_resource_group.example.location\n resource_group_name = azurerm_resource_group.example.name\n service_plan_id = azurerm_service_plan.example.id\n storage_account_name = azurerm_storage_account.example.name\n storage_account_access_key = azurerm_storage_account.example.primary_access_key\n\n site_config {}\n\n app_settings = {\n FUNCTIONS_EXTENSION_VERSION = \"~4\" # Critical: ensures the Function App uses runtime ~4\n }\n}\n```" }, "Recommendation": { - "Text": "", - "Url": "https://learn.microsoft.com/en-us/azure/azure-functions/migrate-version-3-version-4?tabs=net8%2Cazure-cli%2Cwindows&pivots=programming-language-python" + "Text": "Standardize on supported runtime `~4` and align language/extension versions.\n- Enforce upgrades in CI/CD and use staging to validate before rollout.\n- Apply **least privilege** for app identities and secrets.\n- Prefer automated patching and periodic reviews to avoid drift; avoid downgrades or indefinite minor pinning.", + "Url": "https://hub.prowler.com/check/app_function_latest_runtime_version" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Stay informed about the latest security updates and patch releases for Azure Functions to maintain a secure and up-to-date environment." diff --git a/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.metadata.json b/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.metadata.json index 33240c01b4..10a2352752 100644 --- a/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.metadata.json +++ b/prowler/providers/azure/services/app/app_function_not_publicly_accessible/app_function_not_publicly_accessible.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_function_not_publicly_accessible", - "CheckTitle": "Ensure Azure Functions are not publicly accessible", + "CheckTitle": "Function app is not publicly accessible", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Azure Functions should not be exposed to the public internet. Restricting access helps protect applications from potential threats and reduces the attack surface.", - "Risk": "Exposing Azure Functions to the public internet increases the risk of unauthorized access, data breaches, and other security threats.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/azure-functions/functions-networking-options", + "Description": "**Azure Function apps** are assessed for whether they are reachable from the public Internet. The evaluation considers the app's `publicNetworkAccess` state and the presence of access restrictions or private endpoints to limit inbound traffic.", + "Risk": "Public exposure allows unauthorized invocation, risking data disclosure and tampering (**confidentiality** and **integrity**). Attackers can brute-force tokens or abuse misconfigurations for remote execution. Unrestricted calls also enable abuse and DoS, driving cost and harming **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/app-service/overview-access-restrictions", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-exposed.html", + "https://learn.microsoft.com/en-us/azure/azure-functions/functions-networking-options" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-exposed.html", - "Terraform": "" + "CLI": "az functionapp update --resource-group --name --set publicNetworkAccess=Disabled", + "NativeIaC": "```bicep\n// Disable public access by denying all unmatched traffic\nresource functionApp 'Microsoft.Web/sites@2022-09-01' existing = {\n name: ''\n}\n\nresource siteConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '${functionApp.name}/web'\n properties: {\n ipSecurityRestrictionsDefaultAction: 'Deny' // Critical: blocks public access via default endpoint\n }\n}\n```", + "Other": "1. In the Azure portal, go to your Function App\n2. Select Networking\n3. Under Public access, set Public network access to Disabled\n4. Click Save", + "Terraform": "```hcl\n# Disable public network access for the Function App\nresource \"azurerm_linux_function_app\" \"\" {\n name = \"\"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n service_plan_id = azurerm_service_plan.main.id\n storage_account_name = azurerm_storage_account.main.name\n storage_account_access_key = azurerm_storage_account.main.primary_access_key\n\n public_network_access_enabled = false # Critical: disables public endpoint access\n}\n```" }, "Recommendation": { - "Text": "Review the Azure Functions security guidelines and ensure that access restrictions are in place. Use Azure Private Link and Key Vault for enhanced security.", - "Url": "https://learn.microsoft.com/en-us/azure/app-service/overview-access-restrictions" + "Text": "Apply network isolation and least privilege:\n- Set `publicNetworkAccess=Disabled`\n- Use access restrictions for trusted IPs/VNets or **Private Endpoints**\n- Require strong auth (e.g., **Microsoft Entra ID**) over shared keys\n- Front with **API Management/WAF**\n- Keep secrets in **Key Vault** and monitor access logs", + "Url": "https://hub.prowler.com/check/app_function_not_publicly_accessible" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.metadata.json b/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.metadata.json index 2c5078623b..ca5a295b6b 100644 --- a/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.metadata.json +++ b/prowler/providers/azure/services/app/app_function_vnet_integration_enabled/app_function_vnet_integration_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_function_vnet_integration_enabled", - "CheckTitle": "Ensure Virtual Network Integration is Enabled for Azure Functions", + "CheckTitle": "Function app has Virtual Network integration enabled", "CheckType": [], "ServiceName": "app", - "SubServiceName": "function", + "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Microsoft.Web/sites", + "Severity": "medium", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Enabling Virtual Network Integration for Azure Functions provides an additional layer of security by restricting access to selected virtual network subnets. This helps to protect your Function Apps from unauthorized access and potential threats.", - "Risk": "Without Virtual Network Integration, your Function Apps may be exposed to the public internet, increasing the risk of unauthorized access and potential security breaches.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/azure-functions/functions-networking-options#virtual-network-integration", + "Description": "**Azure Function apps** configured with **Virtual Network integration** uses a chosen subnet so outbound traffic is routed via the VNet and can reach private or service-endpoint-secured resources.\n\nThe finding reflects whether a function app is associated with a subnet resource ID.", + "Risk": "Without VNet integration, function apps send egress directly to the public Internet and cannot reach private endpoints.\n\nThis weakens confidentiality and integrity by bypassing NSG/UDR controls, enables data exfiltration from compromised code, and may force exposing backends publicly, increasing attack surface.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-vnet-integration-on.html", + "https://docs.microsoft.com/en-us/azure/azure-functions/functions-networking-options#enable-virtual-network-integration", + "https://docs.microsoft.com/en-us/azure/azure-functions/functions-networking-options#virtual-network-integration" + ], "Remediation": { "Code": { - "CLI": "az functionapp vnet-integration update --name --resource-group --vnet --subnet ", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Functions/azure-function-vnet-integration-on.html", - "Terraform": "" + "CLI": "az functionapp vnet-integration add --name --resource-group --vnet --subnet ", + "NativeIaC": "```bicep\n// Enable VNet integration for an existing Function App\nresource vnetConn 'Microsoft.Web/sites/virtualNetworkConnections@2022-03-01' = {\n name: '/' // /\n properties: {\n subnetResourceId: '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/' // CRITICAL: attaches the Function App to this subnet\n isSwift: true // CRITICAL: enables regional VNet (Swift) integration\n }\n}\n```", + "Other": "1. In the Azure portal, go to your Function App\n2. Select Networking > VNet Integration\n3. Click Add VNet\n4. Choose the target Virtual network and Subnet\n5. Click OK/Save to apply\n", + "Terraform": "```hcl\n# Enable VNet integration for an existing Function App\nresource \"azurerm_app_service_virtual_network_swift_connection\" \"\" {\n app_service_id = \"/subscriptions//resourceGroups//providers/Microsoft.Web/sites/\" # CRITICAL: target Function App resource ID\n subnet_id = \"/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/\" # CRITICAL: subnet to integrate with\n}\n```" }, "Recommendation": { - "Text": "It is recommended to enable Virtual Network Integration for Azure Functions to enhance security and protect against unauthorized access.", - "Url": "https://docs.microsoft.com/en-us/azure/azure-functions/functions-networking-options#enable-virtual-network-integration" + "Text": "Enable **Virtual Network integration** and attach function apps to a dedicated subnet to enforce **least privilege network access**.\n\nRoute egress through the VNet (e.g., `Route All`), apply **NSGs/UDRs**, and use **private endpoints** or service endpoints for dependencies. Restrict outbound traffic by default as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/app_function_vnet_integration_enabled" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.metadata.json b/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.metadata.json index 6f0e5374e7..81c09a696d 100644 --- a/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.metadata.json +++ b/prowler/providers/azure/services/app/app_http_logs_enabled/app_http_logs_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "app_http_logs_enabled", - "CheckTitle": "Ensure that logging for Azure AppService 'HTTP logs' is enabled", + "CheckTitle": "App Service web app has HTTP logs enabled in diagnostic settings", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Microsoft.Web/sites/config", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Enable AppServiceHTTPLogs diagnostic log category for Azure App Service instances to ensure all http requests are captured and centrally logged.", - "Risk": "Capturing web requests can be important supporting information for security analysts performing monitoring and incident response activities. Once logging, these logs can be ingested into SIEM or other central aggregation point for the organization.", - "RelatedUrl": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "Description": "**Azure App Service web apps** diagnostic settings include **HTTP request logging** when the `AppServiceHTTPLogs` category (or the `allLogs` group) is enabled to capture web access events.", + "Risk": "Without **HTTP access logs**, visibility into requests is lost, hindering **detection** of brute force, probing, and injection attempts. This weakens **forensics** and reduces **confidentiality** and **integrity** by masking data access paths and blocking reliable incident timelines.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "https://docs.microsoft.com/en-us/azure/app-service/troubleshoot-diagnostic-logs" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/azure/azure-logging-policies/ensure-that-app-service-enables-http-logging#terraform" + "CLI": "az monitor diagnostic-settings create --name --resource /subscriptions//resourceGroups//providers/Microsoft.Web/sites/ --workspace --logs '[{\"category\":\"AppServiceHTTPLogs\",\"enabled\":true}]'", + "NativeIaC": "```bicep\n// Enable HTTP Logs for an existing App Service via Azure Monitor diagnostic setting\nresource app 'Microsoft.Web/sites@2022-03-01' existing = {\n name: ''\n}\n\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: ''\n scope: app\n properties: {\n workspaceId: '' // Destination Log Analytics workspace\n logs: [\n {\n category: 'AppServiceHTTPLogs' // Critical: enable the HTTP Logs category\n enabled: true // Critical: turns HTTP Logs on\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, go to your App Service > Monitoring > Diagnostic settings\n2. Click + Add diagnostic setting\n3. Under Logs, check AppServiceHTTPLogs (or select the allLogs category group)\n4. Choose a destination (Log Analytics workspace, Storage account, or Event Hub)\n5. Click Save", + "Terraform": "```hcl\n# Enable HTTP Logs for App Service via Azure Monitor diagnostic setting\nresource \"azurerm_monitor_diagnostic_setting\" \"\" {\n name = \"\"\n target_resource_id = \"/subscriptions//resourceGroups//providers/Microsoft.Web/sites/\"\n log_analytics_workspace_id = \"\" # Destination Log Analytics workspace\n\n log { # Critical: enables the HTTP Logs category\n category = \"AppServiceHTTPLogs\"\n enabled = true\n }\n}\n```" }, "Recommendation": { - "Text": "1. Go to App Services For each App Service: 2. Go to Diagnostic Settings 3. Click Add Diagnostic Setting 4. Check the checkbox next to 'HTTP logs' 5. Configure a destination based on your specific logging consumption capability (for example Stream to an event hub and then consuming with SIEM integration for Event Hub logging).", - "Url": "https://docs.microsoft.com/en-us/azure/app-service/troubleshoot-diagnostic-logs" + "Text": "Enable **diagnostic settings** with `AppServiceHTTPLogs` (or `allLogs`) and route logs to a centralized store. Enforce **least privilege**, retention, and tamper-resistant storage. Integrate with a **SIEM** for analytics and alerting, and periodically verify logging coverage across all apps and regions.", + "Url": "https://hub.prowler.com/check/app_http_logs_enabled" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Log consumption and processing will incur additional cost." diff --git a/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json b/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json index 6c55b76134..4b74e1288b 100644 --- a/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json +++ b/prowler/providers/azure/services/app/app_minimum_tls_version_12/app_minimum_tls_version_12.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "app_minimum_tls_version_12", - "CheckTitle": "Ensure Web App is using the latest version of TLS encryption", + "CheckTitle": "App Service web app has minimum TLS version set to 1.2 or 1.3", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Microsoft.Web/sites/config", + "Severity": "medium", + "ResourceType": "microsoft.web/sites/config", "ResourceGroup": "serverless", - "Description": "The TLS (Transport Layer Security) protocol secures transmission of data over the internet using standard encryption technology. Encryption should be set with the latest version of TLS. App service allows TLS 1.2 by default, which is the recommended TLS level by industry standards such as PCI DSS.", - "Risk": "App service currently allows the web app to set TLS versions 1.0, 1.1 and 1.2. It is highly recommended to use the latest TLS 1.2 version for web app secure connections.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-bindings#enforce-tls-versions", + "Description": "**Azure App Service web apps** are assessed for the configured minimum TLS version for HTTPS. The expected baseline is `1.2` or `1.3`; settings that permit lower versions indicate acceptance of legacy TLS during client negotiation.", + "Risk": "Allowing `TLS 1.0/1.1` enables protocol downgrades and weak cipher negotiation, exposing HTTPS traffic to **MITM** interception, credential theft, and tampering. This undermines the **confidentiality** and **integrity** of sessions and data in transit, and can enable account takeover via stolen tokens.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/+azure/app-service/overview-tls", + "https://learn.microsoft.com/en-us/azure/app-service/configure-ssl-bindings#enforce-tls-versions", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-tls-encryption-in-use.html", + "https://icompaas.freshdesk.com/support/solutions/articles/62000234773-ensure-that-minimum-tls-version-is-set-to-tls-v1-2-or-higher" + ], "Remediation": { "Code": { "CLI": "az webapp config set --resource-group --name --min-tls-version 1.2", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/latest-version-of-tls-encryption-in-use.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-networking-policies/bc_azr_networking_6#terraform" + "NativeIaC": "```bicep\n// Update existing App Service to enforce minimum TLS 1.2\nresource app 'Microsoft.Web/sites@2023-01-01' existing = {\n name: ''\n}\n\nresource appConfig 'Microsoft.Web/sites/config@2023-01-01' = {\n name: '${app.name}/web'\n properties: {\n minTlsVersion: '1.2' // CRITICAL: Enforces minimum TLS version 1.2 to pass the check\n }\n}\n```", + "Other": "1. Sign in to Azure Portal and go to App Services\n2. Select your app\n3. Go to Settings > Configuration > General settings\n4. Set Minimum TLS Version to 1.2 (or 1.3 if available)\n5. Click Save", + "Terraform": "```hcl\n# Enforce minimum TLS 1.2 on an Azure Linux Web App\nresource \"azurerm_linux_web_app\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {\n minimum_tls_version = \"1.2\" # CRITICAL: Enforces minimum TLS 1.2 to pass the check\n }\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to App Services 3. Click on each App 4. Under Setting section, Click on TLS/SSL settings 5. Under the Bindings pane, ensure that Minimum TLS Version set to 1.2 under Protocol Settings", - "Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-3-encrypt-sensitive-data-in-transit" + "Text": "Enforce a minimum of `TLS 1.2` (prefer `1.3`) and disable `1.0/1.1`. Require **HTTPS-only**, enable HSTS, and align with modern cipher suites. Test client compatibility and phase out legacy agents. Document narrow exceptions with compensating controls to uphold **defense in depth** and prevent downgrades.", + "Url": "https://hub.prowler.com/check/app_minimum_tls_version_12" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, TLS Version feature will be set to 1.2 when a new app is created using the command-line tool or Azure Portal console." diff --git a/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.metadata.json b/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.metadata.json index bb1b053d11..e6a46e112e 100644 --- a/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.metadata.json +++ b/prowler/providers/azure/services/app/app_register_with_identity/app_register_with_identity.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "app_register_with_identity", - "CheckTitle": "Ensure that Register with Azure Active Directory is enabled on App Service", + "CheckTitle": "App Service web app has a managed identity configured", "CheckType": [], "ServiceName": "app", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.Web/sites", + "ResourceType": "microsoft.web/sites", "ResourceGroup": "serverless", - "Description": "Managed service identity in App Service provides more security by eliminating secrets from the app, such as credentials in the connection strings. When registering with Azure Active Directory in App Service, the app will connect to other Azure services securely without the need for usernames and passwords.", - "Risk": "App Service provides a highly scalable, self-patching web hosting service in Azure. It also provides a managed identity for apps, which is a turn-key solution for securing access to Azure SQL Database and other Azure services.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-provider-aad?tabs=workforce-tenant", + "Description": "**Azure App Service web apps** are configured with a **managed identity** (`identity`: `SystemAssigned` or `UserAssigned`) for token-based access to Azure resources without embedded credentials", + "Risk": "**Missing managed identity** drives reliance on stored secrets. Leaked credentials enable **unauthorized access** to SQL, Storage, or Key Vault, leading to **data exfiltration**, tampering, and lateral movement. Secret expiry or revocation can break connectivity, degrading **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-integrate-azure-managed-service-identity", + "https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-provider-aad?tabs=workforce-tenant", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-registration-with-microsoft-entra-id.html" + ], "Remediation": { "Code": { "CLI": "az webapp identity assign --resource-group --name ", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AppService/enable-registration-with-microsoft-entra-id.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-iam-policies/bc_azr_iam_1#terraform" + "NativeIaC": "```bicep\n// Enable system-assigned managed identity on an existing App Service app\nresource app 'Microsoft.Web/sites@2022-09-01' = {\n name: ''\n location: resourceGroup().location\n identity: {\n type: 'SystemAssigned' // Critical: enables a managed identity for the app\n }\n}\n```", + "Other": "1. Sign in to the Azure portal\n2. Go to App Services and select your app\n3. Under Settings, select Identity\n4. On the System assigned tab, set Status to On\n5. Click Save and confirm", + "Terraform": "```hcl\n# Enable system-assigned managed identity on the App Service app (use azurerm_linux_web_app or azurerm_windows_web_app)\nresource \"azurerm_linux_web_app\" \"\" {\n name = \"\"\n location = azurerm_resource_group..location\n resource_group_name = azurerm_resource_group..name\n service_plan_id = azurerm_service_plan..id\n\n site_config {}\n\n identity { # Critical: enables managed identity\n type = \"SystemAssigned\" # Creates a system-assigned identity for the app\n }\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to App Services 3. Click on each App 4. Under Setting section, Click on Identity 5. Under the System assigned pane, set Status to On", - "Url": "https://learn.microsoft.com/en-us/azure/app-service/scenario-secure-app-authentication-app-service" + "Text": "Enable a **managed identity** and use it for all service-to-service access. Apply **least privilege** on target resources and eliminate secrets from code and app settings. Remove legacy credentials, rotate residual keys, and monitor usage for **defense in depth**. *Use system-assigned per app; user-assigned for reuse or separation.*", + "Url": "https://hub.prowler.com/check/app_register_with_identity" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "By default, Managed service identity via Azure AD is disabled." diff --git a/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.metadata.json b/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.metadata.json index 24ddef6b5d..1a38ade06b 100644 --- a/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.metadata.json +++ b/prowler/providers/azure/services/appinsights/appinsights_ensure_is_configured/appinsights_ensure_is_configured.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "appinsights_ensure_is_configured", - "CheckTitle": "Ensure Application Insights are Configured.", + "CheckTitle": "Subscription has at least one Application Insights resource configured", "CheckType": [], "ServiceName": "appinsights", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Microsoft.Insights/components", + "ResourceType": "microsoft.insights/components", "ResourceGroup": "monitoring", - "Description": "Application Insights within Azure act as an Application Performance Monitoring solution providing valuable data into how well an application performs and additional information when performing incident response. The types of log data collected include application metrics, telemetry data, and application trace logging data providing organizations with detailed information about application activity and application transactions. Both data sets help organizations adopt a proactive and retroactive means to handle security and performance related metrics within their modern applications.", - "Risk": "Configuring Application Insights provides additional data not found elsewhere within Azure as part of a much larger logging and monitoring program within an organization's Information Security practice. The types and contents of these logs will act as both a potential cost saving measure (application performance) and a means to potentially confirm the source of a potential incident (trace logging). Metrics and Telemetry data provide organizations with a proactive approach to cost savings by monitoring an application's performance, while the trace logging data provides necessary details in a reactive incident response scenario by helping organizations identify the potential source of an incident within their application.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview", + "Description": "**Azure subscription** contains at least one **Application Insights** resource collecting application telemetry (metrics, traces, logs) for monitored workloads.\n\nThe check determines whether telemetry collection exists at the subscription level, indicating that application monitoring is configured.", + "Risk": "If **Application Insights** is missing, applications run with reduced **observability**, limiting detection of anomalies and attacks.\n\nThis undermines **integrity** and accountability (fewer traces), degrades **availability** by slowing troubleshooting, and increases exposure to undetected data exfiltration or injection at the app layer.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview", + "https://www.tenable.com/audits/items/CIS_Microsoft_Azure_Foundations_v2.0.0_L2.audit:8a7a608d180042689ad9d3f16aa359f1" + ], "Remediation": { "Code": { - "CLI": "az monitor app-insights component create --app --resource-group --location --kind 'web' --retention-time --workspace -- subscription ", - "NativeIaC": "", - "Other": "https://www.tenable.com/audits/items/CIS_Microsoft_Azure_Foundations_v2.0.0_L2.audit:8a7a608d180042689ad9d3f16aa359f1", - "Terraform": "" + "CLI": "az monitor app-insights component create --app --resource-group --location --application-type web --subscription ", + "NativeIaC": "```bicep\n// Create a minimal Application Insights resource\nresource appInsights 'Microsoft.Insights/components@2020-02-02' = {\n name: ''\n location: ''\n properties: {\n Application_Type: 'web' // Critical: creates the App Insights component required to pass the check\n }\n}\n```", + "Other": "1. In the Azure portal, go to Azure Monitor > Application Insights\n2. Click Create\n3. Select a Subscription and Resource group\n4. Enter a Name and choose a Region\n5. Click Review + create, then Create\n6. Verify the resource appears under Application Insights in the selected subscription", + "Terraform": "```hcl\n# Critical: This resource creates an Application Insights component to satisfy the check\nresource \"azurerm_application_insights\" \"main\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n application_type = \"web\" # Critical: ensures creation of the component\n}\n```" }, "Recommendation": { - "Text": "1. Navigate to Application Insights 2. Under the Basics tab within the PROJECT DETAILS section, select the Subscription 3. Select the Resource group 4. Within the INSTANCE DETAILS, enter a Name 5. Select a Region 6. Next to Resource Mode, select Workspace-based 7. Within the WORKSPACE DETAILS, select the Subscription for the log analytics workspace 8. Select the appropriate Log Analytics Workspace 9. Click Next:Tags > 10. Enter the appropriate Tags as Name, Value pairs. 11. Click Next:Review+Create 12. Click Create.", - "Url": "" + "Text": "Deploy **Application Insights** for all critical workloads and centralize data in a **Log Analytics workspace**. Configure actionable alerts and dashboards, enforce **least privilege** on telemetry, and set retention/export policies. Use private connectivity and appropriate sampling, and integrate with SIEM for **defense in depth**.", + "Url": "https://hub.prowler.com/check/appinsights_ensure_is_configured" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Because Application Insights relies on a Log Analytics Workspace, an organization will incur additional expenses when using this service." diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.metadata.json b/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.metadata.json index f16beee83d..6eed413886 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.metadata.json +++ b/prowler/providers/azure/services/containerregistry/containerregistry_admin_user_disabled/containerregistry_admin_user_disabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "containerregistry_admin_user_disabled", - "CheckTitle": "Ensure admin user is disabled for Azure Container Registry", + "CheckTitle": "Container Registry admin user is disabled", "CheckType": [], "ServiceName": "containerregistry", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "ContainerRegistry", + "ResourceType": "microsoft.containerregistry/registries", "ResourceGroup": "container", - "Description": "Ensure that the admin user is disabled and Role-Based Access Control (RBAC) is used instead since it could grant unrestricted access to the registry", - "Risk": "If the admin user is enabled, it may lead to unauthorized access to the container registry and its resources, which could compromise the confidentiality, integrity, and availability of the images stored within.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli#admin-account", + "Description": "**Azure Container Registry** admin account configuration, confirming the built-in **admin user** is disabled so access relies on Microsoft Entra-based **RBAC** identities and scoped roles.", + "Risk": "Using a shared, always-valid **admin credential** grants full push/pull and lacks attribution. Compromise enables unauthorized image pulls (confidentiality), malicious pushes or tag changes (integrity), and deletions or lockout (availability), enabling supply-chain attacks and lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli#admin-account" + ], "Remediation": { "Code": { "CLI": "az acr update --name --resource-group --admin-enabled false", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```bicep\n// Azure Container Registry with admin user disabled\nresource acr 'Microsoft.ContainerRegistry/registries@2025-11-01' = {\n name: ''\n location: ''\n sku: {\n name: ''\n }\n properties: {\n adminUserEnabled: false // Critical: disables the admin user to pass the check\n }\n}\n```", + "Other": "1. In Azure Portal, go to Container registries and select your registry\n2. Under Settings, open Access keys\n3. Set Admin user to Disabled\n4. Click Save", + "Terraform": "```hcl\nresource \"azurerm_container_registry\" \"example\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"\"\n\n admin_enabled = false # Critical: disables ACR admin user to pass the check\n}\n```" }, "Recommendation": { - "Text": "Disable the admin user on Azure Container Registry through the Azure Portal: 1. Navigate to your Container Registry. 2. In the settings, select 'Access keys'. 3. Ensure the 'Admin user' checkbox is not ticked. For all actions relying on registry access, switch to using Role-Based Access Control.", - "Url": "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli#admin-account" + "Text": "Disable the **admin account** and require Microsoft Entra-backed **RBAC**. Assign least-privilege roles to users, service principals, or managed identities. Prefer short-lived credentials, rotate any residual secrets, and apply defense-in-depth with network restrictions and continuous auditing of registry access.", + "Url": "https://hub.prowler.com/check/containerregistry_admin_user_disabled" } }, - "Categories": [], + "Categories": [ + "identity-access", + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "The transition away from using the admin user to RBAC will facilitate a more secure and manageable access model, minimizing the potential risk of unauthorized access to your container images." diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.metadata.json b/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.metadata.json index cc2c094333..dc218f983d 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.metadata.json +++ b/prowler/providers/azure/services/containerregistry/containerregistry_not_publicly_accessible/containerregistry_not_publicly_accessible.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "containerregistry_not_publicly_accessible", - "CheckTitle": "Restrict public network access to the Container Registry", + "CheckTitle": "Container Registry public network access is disabled", "CheckType": [], "ServiceName": "containerregistry", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "ContainerRegistry", + "ResourceType": "microsoft.containerregistry/registries", "ResourceGroup": "container", - "Description": "Ensure that public network access to the Azure Container Registry is restricted.", - "Risk": "Public accessibility exposes the Container Registry to potential attacks, unauthorized usage, and data breaches. Restricting access minimizes the surface area for attacks and ensures that only authorized networks can access the registry.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-access-selected-networks", + "Description": "**Azure Container Registry** configuration indicates whether the registry permits **unrestricted public access** based on the `Public network access` setting.", + "Risk": "**Internet-exposed ACR** expands attack paths impacting **CIA**:\n- Confidentiality: unauthorized image pulls leak code/secrets\n- Integrity: compromised creds allow tampered image pushes (supply-chain)\n- Availability: pull storms or scans exhaust quotas, causing outages and cost spikes", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ContainerRegistry/disable-public-access.html", + "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-access-selected-networks" + ], "Remediation": { "Code": { - "CLI": "az acr update --name --default-action Deny", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az acr update --name --public-network-enabled false", + "NativeIaC": "```bicep\n// Azure Container Registry with public network access disabled\nresource 'Microsoft.ContainerRegistry/registries@2025-11-01' = {\n name: ''\n location: ''\n sku: {\n name: 'Basic'\n }\n properties: {\n publicNetworkAccess: 'Disabled' // Critical: disables the public endpoint to prevent unrestricted access\n }\n}\n```", + "Other": "1. In the Azure portal, go to your Container Registry\n2. Select Settings > Networking\n3. On Public access, set Allow public network access to Disabled\n4. Click Save", + "Terraform": "```hcl\nresource \"azurerm_container_registry\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"Basic\"\n\n public_network_access_enabled = false # Critical: disables public endpoint to block unrestricted access\n}\n```" }, "Recommendation": { - "Text": "Ensure that the necessary virtual network configurations or IP rules are in place to allow access from required services once public access is restricted. Review the network access settings regularly to maintain a secure environment. To restrict public network access to your Azure Container Registry: 1. Navigate to your Container Registry in the Azure Portal. 2. Under 'Settings'->'Networking', configure the 'Public network access' settings to 'Disabled'. 3. Set up virtual network service endpoints or private endpoints as needed for secure access. 4. Review and adjust IP access rules as necessary.", - "Url": "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-access-selected-networks" + "Text": "Set `Public network access` to `Disabled` and use **Private Link** for registry access.\n\nIf public reachability is required, allow only **selected IPs**, enforce **least privilege** and token rotation, and apply **defense in depth** (egress control, network segmentation, logging of push/pull events).", + "Url": "https://hub.prowler.com/check/containerregistry_not_publicly_accessible" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "This feature is only available for Premium SKU registries." diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.metadata.json b/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.metadata.json index ad180581b6..df1999964b 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.metadata.json +++ b/prowler/providers/azure/services/containerregistry/containerregistry_uses_private_link/containerregistry_uses_private_link.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "containerregistry_uses_private_link", - "CheckTitle": "Ensure to use a private link for accessing the Azure Container Registry", + "CheckTitle": "Container Registry uses a private endpoint (Private Link)", "CheckType": [], "ServiceName": "containerregistry", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "ContainerRegistry", + "Severity": "high", + "ResourceType": "microsoft.containerregistry/registries", "ResourceGroup": "container", - "Description": "Ensure that a private link is used for accessing the Azure Container Registry to enhance security and restrict access to the registry over the public internet.", - "Risk": "Without using a private link, the Azure Container Registry may be exposed to the public internet, increasing the risk of unauthorized access and potential data breaches.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/private-link/private-link-overview", + "Description": "**Azure Container Registry** access via **Private Endpoints** (Azure Private Link). Registries with `private endpoint connections` use private IPs; others rely on the public endpoint.", + "Risk": "Publicly reachable registries expand attack surface for **credential stuffing**, token abuse, and scanning. A compromise enables unauthorized pull/push, causing image **data leakage** and **supply-chain tampering**. Public routing weakens network isolation, impacting the **confidentiality** and **integrity** of images and metadata.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/private-link/private-link-overview", + "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-vnet", + "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-private-link" + ], "Remediation": { "Code": { - "CLI": "az network private-endpoint create --connection-name --resource-group --name --private-connection-resource-id --vnet-name --subnet --group-ids registry", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az network private-endpoint create --resource-group --name --vnet-name --subnet --private-connection-resource-id --group-ids registry --connection-name ", + "NativeIaC": "```bicep\n// Create a Private Endpoint to ACR\nresource privateEndpoint 'Microsoft.Network/privateEndpoints@2025-05-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n subnet: {\n id: ''\n }\n privateLinkServiceConnections: [\n {\n name: ''\n properties: {\n privateLinkServiceId: '' // Critical: ACR resource ID to connect\n groupIds: ['registry'] // Critical: Target the 'registry' subresource to enable Private Link\n }\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, go to Container registries > select your registry\n2. Navigate to Settings > Networking > Private endpoints tab\n3. Click + Private endpoint, enter a name, select your VNet and Subnet\n4. Set Resource type to Microsoft.ContainerRegistry/registries and Target subresource to registry\n5. Click Review + create, then Create", + "Terraform": "```hcl\nresource \"azurerm_private_endpoint\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n subnet_id = \"\"\n\n private_service_connection {\n name = \"\"\n private_connection_resource_id = \"\" # Critical: ACR resource ID\n subresource_names = [\"registry\"] # Critical: Target 'registry' subresource to enable Private Link\n }\n}\n```" }, "Recommendation": { - "Text": "Create a private link for Azure Container Registry through the Azure Portal: 1. Navigate to your Container Registry. 2. In the settings, select 'Networking'. 3. Select 'Private access'. 4. Configure a private endpoint for the registry.", - "Url": "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-private-link" + "Text": "Use **Private Link** with **private endpoints** and set `Public network access: Disabled`.\n- Restrict access to trusted VNets/subnets\n- Prefer private endpoints over service endpoints\n- Enforce **least privilege** on registry actions\n- Configure private DNS for the registry FQDN\n- Monitor access logs for **defense in depth**", + "Url": "https://hub.prowler.com/check/containerregistry_uses_private_link" } }, - "Categories": [], + "Categories": [ + "internet-exposed", + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "This feature is only available for Premium SKU registries." diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.metadata.json index 5b43b22516..8e1b013940 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.metadata.json +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_firewall_use_selected_networks/cosmosdb_account_firewall_use_selected_networks.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "cosmosdb_account_firewall_use_selected_networks", - "CheckTitle": "Ensure That 'Firewalls & Networks' Is Limited to Use Selected Networks Instead of All Networks", + "CheckTitle": "Cosmos DB account firewall allows access only from selected networks", "CheckType": [], "ServiceName": "cosmosdb", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "CosmosDB", + "ResourceType": "microsoft.documentdb/databaseaccounts", "ResourceGroup": "database", - "Description": "Limiting your Cosmos DB to only communicate on whitelisted networks lowers its attack footprint.", - "Risk": "Selecting certain networks for your Cosmos DB to communicate restricts the number of networks including the internet that can interact with what is stored within the database.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/cosmos-db/how-to-configure-private-endpoints", + "Description": "**Azure Cosmos DB accounts** limit connectivity to **selected networks** using virtual network rules and/or IP allowlists rather than permitting access from all networks.\n\nThe evaluation determines whether the account's network firewall enforces this restriction.", + "Risk": "Access from all networks enlarges the attack surface. If keys or tokens are exposed or privileges are misconfigured, attackers anywhere can read or modify data, harming **confidentiality** and **integrity**.\n\nWeak segmentation also enables SSRF/pivot paths from Azure services and can impact **availability** through abuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-vnet-service-endpoint", + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-firewall", + "https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security?tabs=azure-portal" + ], "Remediation": { "Code": { - "CLI": "az cosmosdb database list / az cosmosdb show **isVirtualNetworkFilterEnabled should be set to true**", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az cosmosdb network-rule add -g -n --subnet ", + "NativeIaC": "```bicep\n// Enable selected networks only by turning on VNet filter and adding one allowed subnet\nresource cosmos 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: ''\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: ''; failoverPriority: 0 }]\n isVirtualNetworkFilterEnabled: true // CRITICAL: Enables VNet firewall (selected networks only)\n virtualNetworkRules: [\n {\n id: '' // CRITICAL: Subnet resource ID allowed to access the account\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, open your Cosmos DB account\n2. Go to Settings > Networking\n3. Select Selected networks\n4. Click Add existing virtual network, choose the VNet and Subnet, then click Enable and Add\n5. Click Save", + "Terraform": "```hcl\n# Enable Cosmos DB VNet firewall and allow a specific subnet\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n location = azurerm_resource_group..location\n resource_group_name = azurerm_resource_group..name\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n consistency_policy { consistency_level = \"Session\" }\n geo_location { location = azurerm_resource_group..location failover_priority = 0 }\n\n is_virtual_network_filter_enabled = true # CRITICAL: Enforces selected networks only\n virtual_network_rule {\n id = \"\" # CRITICAL: Subnet resource ID allowed to access the account\n }\n}\n```" }, "Recommendation": { - "Text": "1. Open the portal menu. 2. Select the Azure Cosmos DB blade. 3. Select a Cosmos DB account to audit. 4. Select Networking. 5. Under Public network access, select Selected networks. 6. Under Virtual networks, select + Add existing virtual network or + Add a new virtual network. 7. For existing networks, select subscription, virtual network, subnet and click Add. For new networks, provide a name, update the default values if required, and click Create. 8. Click Save.", - "Url": "https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security?tabs=azure-portal" + "Text": "Set network access to `Selected networks` with **least privilege**:\n- Prefer **private endpoints** or VNet service endpoints with subnet ACLs\n- Keep IP allowlists minimal; avoid `0.0.0.0`\n- *When feasible*, set `publicNetworkAccess=Disabled` with Private Link\n- Apply **defense in depth** and monitor access and firewall changes", + "Url": "https://hub.prowler.com/check/cosmosdb_account_firewall_use_selected_networks" } }, - "Categories": [], + "Categories": [ + "internet-exposed", + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Failure to whitelist the correct networks will result in a connection loss." diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.metadata.json index 6ff6a35499..ed647c44fa 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.metadata.json +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_aad_and_rbac/cosmosdb_account_use_aad_and_rbac.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "cosmosdb_account_use_aad_and_rbac", - "CheckTitle": "Use Azure Active Directory (AAD) Client Authentication and Azure RBAC where possible.", + "CheckTitle": "Cosmos DB account has local authentication disabled and uses Azure AD authentication with Azure RBAC", "CheckType": [], "ServiceName": "cosmosdb", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "CosmosDB", + "Severity": "high", + "ResourceType": "microsoft.documentdb/databaseaccounts", "ResourceGroup": "database", - "Description": "Cosmos DB can use tokens or AAD for client authentication which in turn will use Azure RBAC for authorization. Using AAD is significantly more secure because AAD handles the credentials and allows for MFA and centralized management, and the Azure RBAC better integrated with the rest of Azure.", - "Risk": "AAD client authentication is considerably more secure than token-based authentication because the tokens must be persistent at the client. AAD does not require this.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/cosmos-db/role-based-access-control", + "Description": "**Azure Cosmos DB accounts** configured to use **Microsoft Entra ID** with **Azure RBAC** by disabling key-based credentials (`disableLocalAuth=true`). Clients authenticate with identities rather than account keys.", + "Risk": "With local/key-based auth enabled, **long-lived account keys** can be leaked or shared, enabling unauthorized reads/writes and tampering. Access bypasses **MFA** and granular **RBAC**, hindering rotation/revocation and increasing persistence and lateral movement risks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-connect-role-based-access-control?pivots=azure-cli" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az resource update --resource-group --name --resource-type Microsoft.DocumentDB/databaseAccounts --set properties.disableLocalAuth=true", + "NativeIaC": "```bicep\n// Bicep: Disable local (key-based) auth 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 disableLocalAuth: true // Critical: Disables key-based auth to enforce Entra ID + Azure RBAC\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Keys\n3. Turn on Disable key-based authentication (Disable local authentication)\n4. Click Save", + "Terraform": "```hcl\n# Terraform: Disable local (key-based) auth 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 local_authentication_disabled = true # Critical: Disables key-based auth to enforce Entra ID + RBAC\n}\n```" }, "Recommendation": { - "Text": "Map all the resources that currently access to the Azure Cosmos DB account with keys or access tokens. Create an Azure Active Directory (AAD) identity for each of these resources: For Azure resources, you can create a managed identity . You may choose between system-assigned and user-assigned managed identities. For non-Azure resources, create an AAD identity. Grant each AAD identity the minimum permission it requires. When possible, we recommend you use one of the 2 built-in role definitions: Cosmos DB Built-in Data Reader or Cosmos DB Built-in Data Contributor. Validate that the new resource is functioning correctly. After new permissions are granted to identities, it may take a few hours until they propagate. When all resources are working correctly with the new identities, continue to the next step. You can use the az resource update powershell command: $cosmosdbname = 'cosmos-db-account-name' $resourcegroup = 'resource-group-name' $cosmosdb = az cosmosdb show --name $cosmosdbname --resource-group $resourcegroup | ConvertFrom-Json az resource update --ids $cosmosdb.id --set properties.disableLocalAuth=true --latest- include-preview", - "Url": "https://learn.microsoft.com/en-us/azure/cosmos-db/role-based-access-control" + "Text": "Disable local authentication by setting `disableLocalAuth=true` and require **Entra ID + Azure RBAC** for control and data access. Use **managed identities**, apply **least privilege** roles, retire shared keys, and enforce **zero trust** with conditional access and short-lived credentials.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_use_aad_and_rbac" } }, - "Categories": [], + "Categories": [ + "identity-access", + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.metadata.json index 97bfe9dd66..81ee030a95 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.metadata.json +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_use_private_endpoints/cosmosdb_account_use_private_endpoints.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "cosmosdb_account_use_private_endpoints", - "CheckTitle": "Ensure That Private Endpoints Are Used Where Possible", + "CheckTitle": "Cosmos DB account uses private endpoint connections", "CheckType": [], "ServiceName": "cosmosdb", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "CosmosDB", + "Severity": "high", + "ResourceType": "microsoft.documentdb/databaseaccounts", "ResourceGroup": "database", - "Description": "Private endpoints limit network traffic to approved sources.", - "Risk": "For sensitive data, private endpoints allow granular control of which services can communicate with Cosmos DB and ensure that this network traffic is private. You set this up on a case by case basis for each service you wish to be connected.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/cosmos-db/how-to-configure-private-endpoints", + "Description": "**Azure Cosmos DB accounts** are assessed for **private endpoint connections** that keep data-plane traffic on private IPs within authorized virtual networks.", + "Risk": "Without **private endpoints**, access may use public endpoints or broad IP rules, enabling:\n- interception and credential replay\n- unauthorized queries and data exfiltration\n- lateral movement via exposed paths\n\nThis degrades **confidentiality** and can impact **availability** under abusive traffic.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-private-endpoints?tabs=arm-bicep", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/CosmosDB/use-private-endpoints.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az network private-endpoint create --name --resource-group --vnet-name --subnet --private-connection-resource-id /subscriptions//resourceGroups//providers/Microsoft.DocumentDB/databaseAccounts/ --group-ids Sql --connection-name ", + "NativeIaC": "```bicep\n// Create a Private Endpoint to a Cosmos DB account (adds a private endpoint connection)\nresource pe 'Microsoft.Network/privateEndpoints@2025-05-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n subnet: { id: '' }\n privateLinkServiceConnections: [\n {\n name: 'conn'\n properties: {\n privateLinkServiceId: '' // CRITICAL: attaches PE to the Cosmos DB account\n groupIds: ['Sql'] // CRITICAL: targets Cosmos DB NoSQL subresource so the connection is created\n }\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, open your Cosmos DB account\n2. Go to Networking > Private access\n3. Click + Private endpoint\n4. Resource type: Microsoft.AzureCosmosDB/databaseAccounts; Resource: select your account; Target subresource: Sql\n5. Select your Virtual network and Subnet\n6. Click Review + create, then Create\n7. Verify the private endpoint connection appears under Networking > Private access", + "Terraform": "```hcl\n# Create a Private Endpoint to a Cosmos DB account (adds a private endpoint connection)\nresource \"azurerm_private_endpoint\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n subnet_id = \"\"\n\n private_service_connection {\n name = \"\"\n private_connection_resource_id = \"\" # CRITICAL: Cosmos DB account ID\n subresource_names = [\"Sql\"] # CRITICAL: targets Cosmos DB subresource to create the connection\n }\n}\n```" }, "Recommendation": { - "Text": "1. Open the portal menu. 2. Select the Azure Cosmos DB blade. 3. Select the Azure Cosmos DB account. 4. Select Networking. 5. Select Private access. 6. Click + Private Endpoint. 7. Provide a Name. 8. Click Next. 9. From the Resource type drop down, select Microsoft.AzureCosmosDB/databaseAccounts. 10. From the Resource drop down, select the Cosmos DB account. 11. Click Next. 12. Provide appropriate Virtual Network details. 13. Click Next. 14. Provide appropriate DNS details. 15. Click Next. 16. Optionally provide Tags. 17. Click Next : Review + create. 18. Click Create.", - "Url": "https://docs.microsoft.com/en-us/azure/private-link/tutorial-private-endpoint-cosmosdb-portal" + "Text": "Adopt **Azure Private Link** for Cosmos DB:\n- Create private endpoints for required subresources\n- Link a private DNS zone so clients resolve to private IPs\n- Set `PublicNetworkAccess=Disabled`; keep tight firewall rules\n- Allow only needed VNets/subnets; apply NSGs\n- Enforce least privilege and monitor access patterns", + "Url": "https://hub.prowler.com/check/cosmosdb_account_use_private_endpoints" } }, - "Categories": [], + "Categories": [ + "internet-exposed", + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Only whitelisted services will have access to communicate with the Cosmos DB." diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.metadata.json index 088e25010d..a5c4917841 100644 --- a/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.metadata.json +++ b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "databricks_workspace_cmk_encryption_enabled", - "CheckTitle": "Ensure Azure Databricks workspaces use customer-managed keys (CMK) for encryption at rest", + "CheckTitle": "Databricks workspace uses a customer-managed key (CMK) for encryption at rest", "CheckType": [], "ServiceName": "databricks", - "SubServiceName": "workspace", - "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Databricks/workspaces/{workspaceName}", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDatabricksWorkspace", + "ResourceType": "microsoft.databricks/workspaces", "ResourceGroup": "ai_ml", - "Description": "Checks whether Azure Databricks workspaces are configured to use customer-managed keys (CMK) for encryption at rest, providing greater control over data encryption and compliance.", - "Risk": "Without CMK, organizations have less control over encryption keys, which may impact regulatory compliance and increase risk of unauthorized data access.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/databricks/security/keys/customer-managed-keys", + "Description": "**Azure Databricks workspaces** are evaluated for use of **customer-managed keys** (`CMK`) on at-rest encryption, based on the workspace's managed disk encryption configuration.", + "Risk": "Without **CMK**, keys are provider-controlled, degrading **confidentiality** and incident response.\n- Slower revoke/rotate during breaches\n- Weaker **separation of duties** and audit trails\n- Larger blast radius if storage or control plane is compromised", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Databricks/enable-encryption-with-cmk.html", + "https://learn.microsoft.com/en-us/azure/databricks/security/keys/customer-managed-keys" + ], "Remediation": { "Code": { - "CLI": "az databricks workspace update --name --resource-group --prepare-encryption && databricks workspace update --name --resource-group --key-source 'Microsoft.KeyVault' --key-name --key-vault --key-version ", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az databricks workspace update --name --resource-group --key-source Microsoft.Keyvault --key-name --key-vault https://.vault.azure.net/ --key-version ", + "NativeIaC": "```bicep\nresource ws 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: ''\n sku: {\n name: 'premium'\n }\n properties: {\n encryption: {\n keySource: 'Microsoft.Keyvault' // CRITICAL: enables CMK from Key Vault\n managedDiskKeyVaultProperties: { // CRITICAL: sets CMK for managed disks (encryption at rest)\n keyVaultUri: 'https://.vault.azure.net/'\n keyName: ''\n keyVersion: ''\n }\n }\n }\n}\n```", + "Other": "1. In Azure Portal, go to your Databricks workspace\n2. Select Settings > Encryption (or Customer-managed keys)\n3. If prompted, click Prepare encryption and wait for completion\n4. Set Key source to Microsoft Key Vault\n5. Select the Key Vault key and specific key version for managed disks\n6. Save to apply customer-managed key encryption", + "Terraform": "```hcl\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n customer_managed_key_enabled = true # CRITICAL: enable CMK\n managed_disk_cmk_key_vault_key_id = \"\" # CRITICAL: key ID (Key Vault key) for managed disks\n}\n```" }, "Recommendation": { - "Text": "Enable customer-managed keys (CMK) for Databricks workspaces using Azure Key Vault to enhance control over data encryption, auditing, and compliance.", - "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Databricks/enable-encryption-with-cmk.html" + "Text": "Enable `CMK` for workspace encryption via **Key Vault** or **Managed HSM** and enforce:\n- Least privilege for key usage\n- Regular rotation and retire old versions\n- Audit logging and alerts on key ops\n- Separation of duties for key vs data roles\n- Deny-by-default policies limiting scope", + "Url": "https://hub.prowler.com/check/databricks_workspace_cmk_encryption_enabled" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Customer-managed key (CMK) encryption is only available for Databricks workspaces on the Premium tier." diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.metadata.json index 5a2e05217a..6da3efa468 100644 --- a/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.metadata.json +++ b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "databricks_workspace_vnet_injection_enabled", - "CheckTitle": "Ensure Azure Databricks workspaces are deployed in a customer-managed VNet (VNet Injection)", + "CheckTitle": "Databricks workspace is deployed in a customer-managed VNet (VNet Injection enabled)", "CheckType": [], "ServiceName": "databricks", "SubServiceName": "", - "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Databricks/workspaces/{workspaceName}", - "Severity": "medium", - "ResourceType": "AzureDatabricksWorkspace", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.databricks/workspaces", "ResourceGroup": "ai_ml", - "Description": "Checks whether Azure Databricks workspaces are deployed in a customer-managed Virtual Network (VNet Injection) instead of a Databricks-managed VNet.", - "Risk": "Using a Databricks-managed VNet limits control over network security policies, firewall configurations, and routing, increasing the risk of unauthorized access or data exfiltration.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/databricks/administration-guide/cloud-configurations/azure/vnet-inject", + "Description": "**Azure Databricks workspaces** using **VNet injection** are placed in a customer-managed VNet rather than a Databricks-managed network. This evaluates whether a workspace is linked to a customer VNet.", + "Risk": "Using a Databricks-managed VNet limits control over routing, egress, and access boundaries, degrading **confidentiality** and **integrity**.\n- Unrestricted outbound paths enable **data exfiltration**\n- Harder to enforce **private endpoints** and NSG policies\n- Increased chance of **lateral movement** into compute nodes", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/administration-guide/cloud-configurations/azure/vnet-inject", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Databricks/check-for-vnet-injection.html" + ], "Remediation": { "Code": { - "CLI": "az databricks workspace create --name --resource-group --location --managed-resource-group --enable-no-public-ip true --network-security-group-rule \"NoAzureServices\" --public-network-access Disabled --custom-virtual-network-id /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az databricks workspace create --name --resource-group --location --sku premium --vnet /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/ --public-subnet --private-subnet ", + "NativeIaC": "```bicep\n// Azure Databricks workspace with VNet injection enabled\nresource databricks 'Microsoft.Databricks/workspaces@2023-02-01-preview' = {\n name: ''\n location: ''\n sku: { name: 'premium' }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n parameters: {\n customVirtualNetworkId: { // CRITICAL: Enables VNet injection by attaching your VNet\n value: '/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/'\n }\n customPublicSubnetName: { value: '-public' } // Required: host (public) subnet name\n customPrivateSubnetName: { value: '-private' } // Required: container (private) subnet name\n }\n }\n}\n```", + "Other": "1. In the Azure Portal, go to Create a resource > Azure Databricks\n2. On Basics, enter workspace name, region, and resource group\n3. Open the Networking tab and select Your VNet (VNet injection)\n4. Choose your Virtual network and select the Host (public) and Container (private) subnets\n5. Click Review + create, then Create\n6. Migrate workloads to this workspace and delete the non-VNet workspace if no longer needed", + "Terraform": "```hcl\n# Azure Databricks workspace with VNet injection\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n custom_parameters {\n virtual_network_id = \"/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/\" # CRITICAL: Enables VNet injection by using your VNet\n public_subnet_name = \"-public\" # Required: host (public) subnet\n private_subnet_name = \"-private\" # Required: container (private) subnet\n }\n}\n```" }, "Recommendation": { - "Text": "Deploy Databricks workspaces into a customer-managed VNet to ensure better control over network security and compliance.", - "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Databricks/check-for-vnet-injection.html" + "Text": "Deploy workspaces in a customer-managed VNet and apply **defense in depth**:\n- Enforce egress control with firewalls/NAT and UDRs\n- Prefer **private endpoints** to public access\n- Apply **least privilege** NSG rules and segregate subnets\n- Use DNS controls and monitoring to detect anomalies", + "Url": "https://hub.prowler.com/check/databricks_workspace_vnet_injection_enabled" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.metadata.json b/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.metadata.json index 2bf9fc2d32..1fe0641a26 100644 --- a/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.metadata.json +++ b/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "defender_additional_email_configured_with_a_security_contact", - "CheckTitle": "Ensure 'Additional email addresses' is Configured with a Security Contact Email", + "CheckTitle": "Security contact has additional email addresses configured", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "AzureEmailNotifications", + "Severity": "low", + "ResourceType": "microsoft.resources/subscriptions", "ResourceGroup": "monitoring", - "Description": "Microsoft Defender for Cloud emails the subscription owners whenever a high-severity alert is triggered for their subscription. You should provide a security contact email address as an additional email address.", - "Risk": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details", + "Description": "**Microsoft Defender for Cloud** security contact settings include **additional email recipients** defined in the `emails` field to receive alert notifications.", + "Risk": "Relying only on subscription owners for alerts creates a **single point of failure**. Missed or delayed notifications extend attacker dwell time, enabling data exfiltration (**confidentiality**), unauthorized changes (**integrity**), and service disruption (**availability**). Absence or turnover can silently suppress alerts.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/security-contact-email.html", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications", + "https://learn.microsoft.com/en-us/azure/azure-sql/managed-instance/threat-detection-configure?view=azuresql" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/security-contact-email.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-security-contact-emails-is-set#terraform" + "CLI": "az rest --method put --url https://management.azure.com/subscriptions//providers/Microsoft.Security/securityContacts/default?api-version=2020-01-01-preview --body '{ \"properties\": { \"emails\": \"\" } }'", + "NativeIaC": "```bicep\n// Configure a security contact at subscription scope\ntargetScope = 'subscription'\n\nresource 'Microsoft.Security/securityContacts@2020-01-01-preview' = {\n name: 'default'\n properties: {\n emails: '' // Critical: set at least one email to pass the check\n }\n}\n```", + "Other": "1. Sign in to the Azure portal\n2. Go to Microsoft Defender for Cloud > Environment settings\n3. Select the target subscription\n4. Click Email notifications\n5. In Email addresses, enter at least one email (comma-separated for multiple)\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_contact\" \"\" {\n email = \"\" # Critical: ensures at least one security contact email is configured\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. Enter a valid security contact email address (or multiple addresses separated by commas) in the Additional email addresses field 7. Click Save", - "Url": "https://learn.microsoft.com/en-us/rest/api/defenderforcloud/security-contacts/list?view=rest-defenderforcloud-2020-01-01-preview&tabs=HTTP" + "Text": "Use a monitored, team-managed distribution list as the **security contact** in `emails`. Include SOC/on-call for 24/7 coverage and enable role-based notifications for redundancy. Tune severities to reduce noise while capturing high-risk events, and integrate alerts with ticketing/SIEM for **defense in depth** and rapid response.", + "Url": "https://hub.prowler.com/check/defender_additional_email_configured_with_a_security_contact" } }, - "Categories": [], + "Categories": [ + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.metadata.json b/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.metadata.json index 8f8f40d6f3..94a1c4d243 100644 --- a/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.metadata.json +++ b/prowler/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_assessments_vm_endpoint_protection_installed", - "CheckTitle": "Ensure that Endpoint Protection for all Virtual Machines is installed", + "CheckTitle": "All virtual machines in the subscription have endpoint protection installed", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.Security/assessments", + "ResourceType": "microsoft.security/assessments/governanceassignments", "ResourceGroup": "security", - "Description": "Install endpoint protection for all virtual machines.", - "Risk": "Installing endpoint protection systems (like anti-malware for Azure) provides for real-time protection capability that helps identify and remove viruses, spyware, and other malicious software. These also offer configurable alerts when known-malicious or unwanted software attempts to install itself or run on Azure systems.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/security/fundamentals/antimalware", + "Description": "**Azure virtual machines** are assessed for the presence of an **endpoint protection (antimalware)** solution and its reported health across the subscription", + "Risk": "Absent or unhealthy **endpoint protection** lets malware execute on VMs, risking:\n- Data exfiltration (confidentiality)\n- Tampering and credential theft (integrity)\n- Ransomware, cryptomining, and outages (availability)\n\nIt also enables persistence and lateral movement to other cloud resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/install-endpoint-protection.html#", + "https://learn.microsoft.com/en-us/azure/security/fundamentals/antimalware" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/install-endpoint-protection.html#", - "Terraform": "" + "NativeIaC": "```bicep\n// Install Microsoft Antimalware (endpoint protection) on a VM\nparam vmName string = ''\nparam location string = ''\n\nresource antimalware 'Microsoft.Compute/virtualMachines/extensions@2022-11-01' = {\n name: '${vmName}/IaaSAntimalware'\n location: location\n properties: {\n publisher: 'Microsoft.Azure.Security' // Critical: publisher for Antimalware extension\n type: 'IaaSAntimalware' // Critical: installs endpoint protection\n typeHandlerVersion: '1.5'\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Open Recommendations and search for \"Install endpoint protection solution on virtual machines\"\n3. Select the recommendation, click Fix\n4. Select all affected VMs and click Remediate (or Apply)\n5. Wait for remediation to complete and the recommendation status to turn Healthy", + "Terraform": "```hcl\n# Install Microsoft Antimalware (endpoint protection) on a VM\nresource \"azurerm_virtual_machine_extension\" \"\" {\n name = \"IaaSAntimalware\"\n virtual_machine_id = \"\"\n publisher = \"Microsoft.Azure.Security\" # Critical: Antimalware extension publisher\n type = \"IaaSAntimalware\" # Critical: installs endpoint protection\n type_handler_version = \"1.5\"\n}\n```" }, "Recommendation": { - "Text": "Follow Microsoft Azure documentation to install endpoint protection from the security center. Alternatively, you can employ your own endpoint protection tool for your OS.", - "Url": "" + "Text": "Enforce an **endpoint protection/EDR** baseline on every VM. Enable real-time protection, automatic updates, and alerting; use tamper protection and keep exclusions minimal. Apply **least privilege**, keep OS and agents patched, and continuously monitor coverage and health via Defender for Cloud.", + "Url": "https://hub.prowler.com/check/defender_assessments_vm_endpoint_protection_installed" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Endpoint protection will incur an additional cost to you." diff --git a/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.metadata.json b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.metadata.json index c3dfeb7bc8..9ddf8b0b67 100644 --- a/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.metadata.json +++ b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_attack_path_notifications_properly_configured", - "CheckTitle": "Ensure that email notifications for attack paths are enabled with minimal risk level", + "CheckTitle": "Security contact has attack path email notifications enabled at or above the configured minimum risk level", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "AzureEmailNotifications", + "Severity": "high", + "ResourceType": "microsoft.resources/subscriptions", "ResourceGroup": "monitoring", - "Description": "Ensure that Microsoft Defender for Cloud is configured to send email notifications for attack paths identified in the Azure subscription with an appropriate minimal risk level.", - "Risk": "If attack path notifications are not enabled, security teams may not be promptly informed about exploitable attack sequences, increasing the risk of delayed mitigation and potential breaches.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications", + "Description": "**Microsoft Defender for Cloud** attack path email notifications are configured per subscription with a defined **minimal risk level**, and the setting is present and meets the required threshold.", + "Risk": "Without alerts on **exploitable attack paths**, security teams lose visibility, enabling **lateral movement**, **privilege escalation**, and **data exfiltration** before containment, degrading confidentiality, integrity, and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/enable-attack-path-notifications.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications", - "Terraform": "" + "CLI": "az rest --method put --uri https://management.azure.com/subscriptions//providers/Microsoft.Security/securityContacts/default?api-version=2020-01-01-preview --body '{\"properties\":{\"emails\":\"admin@example.com\",\"attackPathNotifications\":{\"state\":\"On\",\"minimalRiskLevel\":\"Low\"}}}'", + "NativeIaC": "```bicep\n// Enable attack path email notifications at minimal risk level\nresource securityContact 'Microsoft.Security/securityContacts@2020-01-01-preview' = {\n name: 'default'\n properties: {\n emails: 'admin@example.com'\n attackPathNotifications: {\n state: 'On' // CRITICAL: enables attack path email notifications\n minimalRiskLevel: 'Low' // CRITICAL: sets minimal risk level to pass the check\n }\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud > Environment settings\n2. Select the target subscription\n3. Open Email notifications\n4. Enable \"Notify about attack paths with the following risk level (or higher)\"\n5. Set Risk level to Low (or your configured minimum)\n6. Click Save", + "Terraform": "```hcl\n# Enable attack path email notifications at minimal risk level\nresource \"azapi_resource\" \"\" {\n type = \"Microsoft.Security/securityContacts@2020-01-01-preview\"\n name = \"default\"\n body = jsonencode({\n properties = {\n emails = \"admin@example.com\"\n attackPathNotifications = {\n state = \"On\" # CRITICAL: enables attack path email notifications\n minimalRiskLevel = \"Low\" # CRITICAL: sets minimal risk level to pass the check\n }\n }\n })\n}\n```" }, "Recommendation": { - "Text": "Enable attack path email notifications in Microsoft Defender for Cloud to ensure that security teams are notified when potential attack paths are identified. Configure the minimal risk level as appropriate for your organization.", - "Url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications" + "Text": "Enable and maintain **attack path notifications** with a minimal risk level at or above your tolerance (e.g., `High`). Send to monitored, role-based recipients. Apply **defense in depth** by integrating alerts with central monitoring and automation for prompt triage.", + "Url": "https://hub.prowler.com/check/defender_attack_path_notifications_properly_configured" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.metadata.json b/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.metadata.json index 8314a85b53..8579f8644c 100644 --- a/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "defender_auto_provisioning_log_analytics_agent_vms_on", - "CheckTitle": "Ensure that Auto provisioning of 'Log Analytics agent for Azure VMs' is Set to 'On'", + "CheckTitle": "Defender auto-provisioning of Log Analytics agent for Azure VMs is enabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "AzureDefenderPlan", + "Severity": "high", + "ResourceType": "microsoft.resources/subscriptions", "ResourceGroup": "security", - "Description": "Ensure that Auto provisioning of 'Log Analytics agent for Azure VMs' is Set to 'On'. The Microsoft Monitoring Agent scans for various security-related configurations and events such as system updates, OS vulnerabilities, endpoint protection, and provides alerts.", - "Risk": "Missing critical security information about your Azure VMs, such as security alerts, security recommendations, and change tracking.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-data-security", + "Description": "**Microsoft Defender for Cloud** auto-provisioning of the **Log Analytics agent** to Azure VMs is configured to `On` at the subscription level", + "Risk": "Without automatic agent deployment, some VMs lack security telemetry, creating **blind spots** for vulnerabilities, missing patches, and threats.\n\nAttackers can persist or move laterally unnoticed, undermining **confidentiality** and **integrity**, while delayed detection hampers effective response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/data-security", + "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/SecurityCenter/automatic-provisioning-of-monitoring-agent.html", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/SecurityCenter/automatic-provisioning-of-monitoring-agent.html", - "Terraform": "" + "CLI": "az security auto-provisioning-setting update --name default --auto-provision On", + "NativeIaC": "```bicep\n// Enable Defender auto-provisioning of Log Analytics agent at subscription scope\ntargetScope = 'subscription'\n\nresource autoProv 'Microsoft.Security/autoProvisioningSettings@2017-08-01-preview' = {\n name: 'default'\n properties: {\n autoProvision: 'On' // Critical: turns auto-provisioning ON for the subscription\n }\n}\n```", + "Other": "1. In the Azure portal, open Microsoft Defender for Cloud\n2. Select Environment settings, then choose your subscription\n3. Open Auto provisioning\n4. Set Auto-provisioning of Log Analytics agent to On\n5. Click Save", + "Terraform": "```hcl\n# Enable Defender auto-provisioning of Log Analytics agent\nresource \"azurerm_security_center_auto_provisioning\" \"\" {\n auto_provision = \"On\" # Critical: turns auto-provisioning ON\n}\n```" }, "Recommendation": { - "Text": "Ensure comprehensive visibility into possible security vulnerabilities, including missing updates, misconfigured operating system security settings, and active threats, allowing for timely mitigation and improved overall security posture", - "Url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components" + "Text": "Set **Defender for Cloud auto-provisioning** to `On` so all VMs receive the monitoring agent consistently.\n\nApply **defense in depth** by enforcing coverage for new and existing machines, standardizing workspaces, and auditing enrollment. Use **least privilege** for data access and integrate with endpoint protection and vulnerability assessment.", + "Url": "https://hub.prowler.com/check/defender_auto_provisioning_log_analytics_agent_vms_on" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.metadata.json b/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.metadata.json index d6cfd4ac7d..4d4267f51e 100644 --- a/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "CheckTitle": "Ensure that Auto provisioning of 'Vulnerability assessment for machines' is Set to 'On'", + "CheckTitle": "All virtual machines in the subscription have a vulnerability assessment solution installed", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/assessmentssample", "ResourceGroup": "security", - "Description": "Enable automatic provisioning of vulnerability assessment for machines on both Azure and hybrid (Arc enabled) machines.", - "Risk": "Vulnerability assessment for machines scans for various security-related configurations and events such as system updates, OS vulnerabilities, and endpoint protection, then produces alerts on threat and vulnerability findings.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/defender-for-cloud/enable-data-collection?tabs=autoprovision-va", + "Description": "**Microsoft Defender for Cloud** evaluates whether **Azure VMs** and **Arc-enabled machines** have a **vulnerability assessment solution** deployed and reporting healthy coverage across the subscription.", + "Risk": "Without continuous **vulnerability assessment**, unpatched flaws persist, enabling:\n- **Remote code execution** and privilege escalation\n- **Ransomware** disrupting availability\n- **Data exfiltration** via lateral movement\n\nConfidentiality, integrity, and availability are reduced across affected machines.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/automatic-provisioning-vulnerability-assessment-machines.html", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/deploy-vulnerability-assessment-defender-vulnerability-management", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components?tabs=autoprovision-va" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/automatic-provisioning-vulnerability-assessment-machines.html", - "Terraform": "" + "CLI": "az rest --method put --url https://management.azure.com/subscriptions//providers/Microsoft.Security/serverVulnerabilityAssessmentsSettings/AzureServersSetting?api-version=2022-01-01-preview --body '{\"properties\":{\"selectedProvider\":\"MdeTvm\"},\"kind\":\"AzureServersSetting\"}'", + "NativeIaC": "```bicep\n// Enable vulnerability assessment for all machines using Microsoft Defender Vulnerability Management\n// Critical: sets the VA provider so the recommendation becomes Healthy\n@description('Deploy at subscription scope')\ntargetScope = 'subscription'\n\nresource 'Microsoft.Security/serverVulnerabilityAssessmentsSettings@2022-01-01-preview' = {\n name: 'AzureServersSetting'\n kind: 'AzureServersSetting'\n properties: {\n selectedProvider: 'MdeTvm' // Critical: enables Defender VA provider for machines\n }\n}\n```", + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud\n2. Open Environment settings and select your \n3. Go to Settings & monitoring (Auto-provisioning)\n4. Find Vulnerability assessment for machines, set to On, and select Microsoft Defender Vulnerability Management\n5. Click Save", + "Terraform": "```hcl\n# Enable vulnerability assessment for all machines using Microsoft Defender Vulnerability Management\nresource \"azapi_resource\" \"\" {\n type = \"Microsoft.Security/serverVulnerabilityAssessmentsSettings@2022-01-01-preview\"\n name = \"AzureServersSetting\"\n parent_id = \"/subscriptions/\"\n\n body = jsonencode({\n properties = {\n selectedProvider = \"MdeTvm\" # Critical: sets VA provider so all VMs get vulnerability assessment\n }\n kind = \"AzureServersSetting\"\n })\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu. 2. Select Microsoft Defender for Cloud. 3. Then Environment Settings. 4. Select a subscription. 5. Click on Settings & Monitoring. 6. Ensure that Vulnerability assessment for machines is set to On. Repeat this for any additional subscriptions.", - "Url": "" + "Text": "Enable subscription-wide **auto-provisioning** of a **vulnerability assessment** for all Azure and Arc machines and enforce it with **policy** for existing and new hosts.\n\nApply **least privilege** to deployment identities, integrate with **patch management**, and monitor findings for timely remediation.", + "Url": "https://hub.prowler.com/check/defender_auto_provisioning_vulnerabilty_assessments_machines_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Additional licensing is required and configuration of Azure Arc introduces complexity beyond this recommendation." diff --git a/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.metadata.json b/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.metadata.json index 5ccffe77b4..ef92271a01 100644 --- a/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.metadata.json +++ b/prowler/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "defender_container_images_resolved_vulnerabilities", - "CheckTitle": "Container images used by containers should have vulnerabilities resolved", + "CheckTitle": "All Azure running container images in the subscription have no unresolved vulnerabilities", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Microsoft.Security/assessments", + "Severity": "critical", + "ResourceType": "microsoft.security/assessmentssample", "ResourceGroup": "security", - "Description": "Container images used by containers should have vulnerabilities resolved. Azure Defender for Container Registries can help you identify and resolve vulnerabilities in your container images. It provides vulnerability scanning and prioritized security recommendations for your container images. You can use Azure Defender for Container Registries to scan your container images for vulnerabilities and get prioritized security recommendations to resolve them. You can also use Azure Defender for Container Registries to monitor your container registries for security threats and get prioritized security recommendations to resolve them. Azure Defender for Container Registries integrates with Azure Security Center to provide a unified view of security across your container registries and other Azure resources. Azure Defender for Container Registries is part of Azure Defender, which provides advanced threat protection for your hybrid workloads. Azure Defender uses advanced analytics and global threat intelligence to detect attacks that might otherwise go unnoticed.", - "Risk": "If vulnerabilities are not resolved, attackers can exploit them to gain unauthorized access to your containerized applications and data.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-check-health", + "Description": "**Running container images** are evaluated for unresolved **vulnerability findings** (`CVEs`) reported by Microsoft Defender for Cloud. The check reviews images currently in use across Kubernetes workloads and identifies where vulnerabilities remain unremediated.", + "Risk": "Unremediated `CVEs` in active images enable:\n- **RCE**, container escape, and node takeover affecting **integrity/availability**\n- **Data exfiltration** and secret theft compromising **confidentiality**\nAdversaries can use public exploits to pivot across clusters and pipelines, tamper images, and disrupt services.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/container-registry/scan-images-defender", + "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-check-health", + "https://learn.microsoft.com/en-MY/azure/defender-for-cloud/defender-for-containers-vulnerability-assessment-azure" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "kubectl set image deployment/ = -n ", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud > Recommendations\n2. Open \"Azure running container images should have vulnerabilities resolved\"\n3. Under Affected resources, select a running workload and view its vulnerable image findings\n4. Rebuild the image with patched packages or a newer base image and push it to your registry\n5. Go to your AKS cluster > Workloads > Deployments, edit the deployment, and update the container image to the patched tag; Save\n6. Wait for pods to roll out and Defender to rescan; the recommendation should turn Healthy after the next scan", + "Terraform": "```hcl\nresource \"kubernetes_deployment\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n selector {\n match_labels = { app = \"\" }\n }\n template {\n metadata { labels = { app = \"\" } }\n spec {\n container {\n name = \"\"\n image = \"\" # Critical: use a patched image version to remove known vulnerabilities\n }\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "", - "Url": "https://learn.microsoft.com/en-us/azure/container-registry/scan-images-defender" + "Text": "Adopt **risk-based patching** and **least privilege**:\n- Rebuild from updated bases; pin versions, avoid `latest`\n- Sign images; enforce **admission control** to block high-severity CVEs\n- Drop root, restrict capabilities, isolate networks\n- Continuously scan in CI/CD and at runtime; retire vulnerable images", + "Url": "https://hub.prowler.com/check/defender_container_images_resolved_vulnerabilities" } }, - "Categories": [], + "Categories": [ + "vulnerabilities", + "container-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.metadata.json b/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.metadata.json index 31fb13c73d..936d30b24c 100644 --- a/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.metadata.json +++ b/prowler/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled.metadata.json @@ -1,30 +1,39 @@ { "Provider": "azure", "CheckID": "defender_container_images_scan_enabled", - "CheckTitle": "Ensure Image Vulnerability Scanning using Azure Defender image scanning or a third party provider", + "CheckTitle": "Subscription has container image vulnerability scanning enabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Microsoft.Security", + "Severity": "high", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Scan images being deployed to Azure (AKS) for vulnerabilities. Vulnerability scanning for images stored in Azure Container Registry is generally available in Azure Security Center. This capability is powered by Qualys, a leading provider of information security. When you push an image to Container Registry, Security Center automatically scans it, then checks for known vulnerabilities in packages or dependencies defined in the file. When the scan completes (after about 10 minutes), Security Center provides details and a security classification for each vulnerability detected, along with guidance on how to remediate issues and protect vulnerable attack surfaces.", - "Risk": "Vulnerabilities in software packages can be exploited by hackers or malicious users to obtain unauthorized access to local cloud resources. Azure Defender and other third party products allow images to be scanned for known vulnerabilities.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-check-health", + "Description": "**Azure subscriptions** have **container image vulnerability assessment** enabled for **Azure Container Registry** via Microsoft Defender for Cloud (`ContainerRegistriesVulnerabilityAssessments`). Images in registries are evaluated for known package vulnerabilities in their packages and dependencies.", + "Risk": "Without registry scanning, **known CVEs** in images can reach runtime, enabling **RCE**, privilege escalation, and lateral movement. This undermines data confidentiality and integrity and can reduce availability through cryptomining or service disruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/container-registry/scan-images-defender", + "https://learn.microsoft.com/en-us/azure/container-registry/container-registry-check-health", + "https://learn.microsoft.com/en-us/troubleshoot/azure/azure-container-registry/image-vulnerability-assessment", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/AKS/enable-image-vulnerability-scanning.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az rest --method put --url https://management.azure.com/subscriptions//providers/Microsoft.Security/pricings/Containers?api-version=2023-01-01 --body '{\"properties\":{\"pricingTier\":\"Standard\",\"extensions\":[{\"name\":\"ContainerRegistriesVulnerabilityAssessments\",\"isEnabled\":true}]}}'", + "NativeIaC": "```bicep\n// Enable Defender for Containers image vulnerability scanning at subscription scope\ntargetScope = 'subscription'\n\nresource containersPricing 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'Containers'\n properties: {\n pricingTier: 'Standard'\n extensions: [\n {\n name: 'ContainerRegistriesVulnerabilityAssessments' // CRITICAL: enables ACR image vulnerability scanning\n isEnabled: true // CRITICAL: turns the extension ON\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, open Microsoft Defender for Cloud\n2. Go to Environment settings and select your subscription\n3. Open Settings (or Defender plans)\n4. Find Containers and set Plan to On/Standard\n5. Enable Container registries vulnerability assessments\n6. Click Save", + "Terraform": "```hcl\n# Enable Defender for Containers with container registry vulnerability scanning\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n tier = \"Standard\"\n resource_type = \"Containers\"\n \n extension {\n name = \"ContainerRegistriesVulnerabilityAssessments\" # CRITICAL: enables ACR image vulnerability scanning\n }\n}\n```" }, "Recommendation": { - "Text": "", - "Url": "https://learn.microsoft.com/en-us/azure/container-registry/scan-images-defender" + "Text": "Enable **Defender for Cloud** image assessment for registries and adopt **shift-left scanning**.\n- Block deployment of images with high-severity findings\n- Rebuild from patched base images regularly\n- Enforce **least privilege** on registry access\n- Use image signing and admission controls", + "Url": "https://hub.prowler.com/check/defender_container_images_scan_enabled" } }, - "Categories": [], + "Categories": [ + "vulnerabilities", + "container-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "When using an Azure container registry, you might occasionally encounter problems. For example, you might not be able to pull a container image because of an issue with Docker in your local environment. Or, a network issue might prevent you from connecting to the registry." diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.metadata.json index bba43975da..ca828bb103 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_app_services_is_on", - "CheckTitle": "Ensure That Microsoft Defender for App Services Is Set To 'On' ", + "CheckTitle": "Defender for App Services is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for App Services Is Set To 'On' ", - "Risk": "Turning on Microsoft Defender for App Service enables threat detection for App Service, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.", + "Description": "**Azure subscriptions** are evaluated for **Defender for App Service** coverage by inspecting the `AppServices` pricing configuration. The finding indicates whether the plan is set to `Standard`, which applies protection to App Service resources at the subscription scope.", + "Risk": "Without this coverage, malicious traffic and runtime anomalies may go unseen, enabling:\n- Confidentiality loss via data exfiltration\n- Integrity compromise through web shells or code tampering\n- Availability impact from takeover and resource abuse", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/tutorial-enable-app-service-plan", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-app-service.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-app-service.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-app-service#terraform" + "CLI": "az security pricing create -n AppServices --tier standard", + "NativeIaC": "```bicep\n// Enable Defender for App Services at subscription scope\ntargetScope = 'subscription'\n\nresource example_resource_name 'Microsoft.Security/pricings@2024-01-01' = {\n name: 'AppServices'\n properties: {\n pricingTier: 'Standard' // Critical: sets the plan to Standard (ON) for App Services\n }\n}\n```", + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > your subscription\n3. On Defender plans, toggle App Service to On\n4. Click Save", + "Terraform": "```hcl\n# Enable Defender for App Services at subscription level\nresource \"azurerm_security_center_subscription_pricing\" \"example_resource_name\" {\n tier = \"Standard\" # Critical: sets the plan to Standard (ON)\n resource_type = \"AppServices\" # Applies the setting to App Services\n}\n```" }, "Recommendation": { - "Text": "By default, Microsoft Defender for Cloud is not enabled for your App Service instances. Enabling the Defender security service for App Service instances allows for advanced security defense using threat detection capabilities provided by Microsoft Security Response Center.", - "Url": "" + "Text": "Enable **Defender for App Service** at subscription scope with tier `Standard`. Integrate alerts with SOC tooling, tune rules to reduce noise, and review findings regularly. Apply **defense in depth** and **least privilege**, and automate responses to contain threats quickly.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_app_services_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.metadata.json index 34f314556f..2bfcdca095 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_arm_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Azure Resource Manager Is Set To 'On' ", + "CheckTitle": "Defender for Azure Resource Manager is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Azure Resource Manager Is Set To 'On' ", - "Risk": "Scanning resource requests lets you be alerted every time there is suspicious activity in order to prevent a security threat from being introduced.", + "Description": "**Microsoft Defender for Cloud** plan for **Azure Resource Manager** is configured at the `Standard` tier for the subscription", + "Risk": "Without this protection, malicious or misconfigured ARM deployments can go unnoticed. Adversaries could create high-privilege roles, disable logging, or deploy exfiltration paths and crypto workloads, degrading **integrity**, **confidentiality**, and **availability** of Azure resources.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://techcommunity.microsoft.com/blog/coreinfrastructureandsecurityblog/switch-to-the-new-defender-for-resource-manager-pricing-plan/4001636", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/pricing-tier.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az security pricing create --name Arm --tier Standard", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for Azure Resource Manager at Standard tier\nresource example_pricing 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'Arm'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for ARM plan to Standard (ON)\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > your subscription\n3. Open Defender plans\n4. Set \"Defender for Azure Resource Manager\" to On/Standard\n5. Click Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Azure Resource Manager at Standard tier\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n tier = \"Standard\" # Critical: enables Standard pricing (ON)\n resource_type = \"Arm\" # Critical: targets Defender for Azure Resource Manager\n}\n```" }, "Recommendation": { - "Text": "Enable Microsoft Defender for Azure Resource Manager", - "Url": "" + "Text": "Enable Microsoft Defender for **Azure Resource Manager** at the `Standard` tier across all subscriptions. Apply least privilege to deployment principals, enforce the plan via policy for new subscriptions, and route alerts to centralized monitoring to support defense-in-depth and rapid response.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_arm_is_on" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.metadata.json index 9ab293b285..29adebe672 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_azure_sql_databases_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Azure SQL Databases Is Set To 'On' ", + "CheckTitle": "Defender for Azure SQL databases is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Azure SQL Databases Is Set To 'On' ", - "Risk": "Turning on Microsoft Defender for Azure SQL Databases enables threat detection for Azure SQL database servers, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.", + "Description": "Microsoft Defender for Cloud plan for **Azure SQL Database Servers** is evaluated at subscription scope, expecting the `pricing_tier` set to `Standard` for `SqlServers`. Non-standard tiers indicate the plan isn't enabled.", + "Risk": "Without **Defender for SQL**, attacks like **SQL injection**, brute-force logins, and anomalous queries may go **undetected**, enabling data exfiltration and tampering. Limited telemetry delays **incident response**, risking loss of confidentiality and integrity and aiding lateral movement.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-azure-sql.html", + "https://learn.microsoft.com/en-us/azure/azure-sql/database/azure-defender-for-sql?view=azuresql" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-azure-sql.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-azure-sql-database-servers#terraform" + "CLI": "az security pricing create --name SqlServers --tier Standard", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for Azure SQL Databases at subscription scope\ntargetScope = 'subscription'\n\nresource sqlPricing 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'SqlServers'\n properties: {\n pricingTier: 'Standard' // CRITICAL: Sets Defender plan for Azure SQL DB to ON (Standard)\n }\n}\n```", + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > your subscription\n3. Open Defender plans\n4. Turn ON the plan for Azure SQL Databases (set to Standard)\n5. Click Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Azure SQL Databases\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n resource_type = \"SqlServers\" # CRITICAL: Targets Azure SQL Databases plan\n tier = \"Standard\" # CRITICAL: Enables Defender (Standard)\n}\n```" }, "Recommendation": { - "Text": "By default, Microsoft Defender for Cloud is disabled for all your SQL database servers. Defender for Cloud monitors your SQL database servers for threats such as SQL injection, brute-force attacks, and privilege abuse. The security service provides action-oriented security alerts with details of the suspicious activity and guidance on how to mitigate the security threats.", - "Url": "" + "Text": "Enable the **Microsoft Defender** plan for Azure SQL databases with `pricing_tier: Standard` across applicable subscriptions. Integrate alerts with SIEM, enforce **least privilege** and **separation of duties**, and apply **defense in depth** (network controls, MFA) to prevent and promptly detect misuse.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_azure_sql_databases_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.metadata.json index 693ea4cba8..3567f9452a 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_containers_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Containers Is Set To 'On' ", + "CheckTitle": "Defender for Containers is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Containers Is Set To 'On' ", - "Risk": "Ensure that Microsoft Defender for Cloud is enabled for all your Azure containers. Turning on the Defender for Cloud service enables threat detection for containers, providing threat intelligence, anomaly detection, and behavior analytics.", + "Description": "**Azure subscriptions** are assessed to determine if the **Defender for Containers** plan is configured with pricing tier `Standard`.", + "Risk": "Without **Defender for Containers**, images and runtimes lack continuous **threat detection** and **vulnerability assessment**. Adversaries can ship malicious images, run **cryptomining**, exfiltrate secrets, and **move laterally**, degrading **confidentiality** and **availability** of container workloads.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-container.html", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-introduction" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-container.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-container-registries#terraform" + "CLI": "az security pricing create --name Containers --tier Standard", + "NativeIaC": "```bicep\n// Subscription-level deployment to enable Defender for Containers\ntargetScope = 'subscription'\n\nresource 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'Containers'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for Containers plan to ON (Standard)\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > choose \n3. Open Pricing & settings\n4. Find the Containers plan and set it to On (Standard)\n5. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n resource_type = \"Containers\" # Critical: targets Defender for Containers plan\n tier = \"Standard\" # Critical: enables Standard (ON)\n}\n```" }, "Recommendation": { - "Text": "By default, Microsoft Defender for Cloud is not enabled for your Azure cloud containers. Enabling the Defender security service for Azure containers allows for advanced security defense against threats, using threat detection capabilities provided by the Microsoft Security Response Center (MSRC).", - "Url": "" + "Text": "Enable the **Defender for Containers** plan at `Standard` for all relevant subscriptions. Apply **least privilege**, integrate alerts with response workflows, and use **defense in depth**: signed images, private registries, RBAC, network policies, and periodic reviews to maintain consistent coverage.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_containers_is_on" } }, - "Categories": [], + "Categories": [ + "container-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.metadata.json index 059495f5ba..8fa85c74a4 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_cosmosdb_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Cosmos DB Is Set To 'On' ", + "CheckTitle": "Defender for Cosmos DB is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Cosmos DB Is Set To 'On' ", - "Risk": "In scanning Cosmos DB requests within a subscription, requests are compared to a heuristic list of potential security threats. These threats could be a result of a security breach within your services, thus scanning for them could prevent a potential security threat from being introduced.", + "Description": "**Microsoft Defender for Azure Cosmos DB** is enabled at the subscription using the `Standard` pricing tier for the `CosmosDbs` plan, covering all Cosmos DB accounts", + "Risk": "Without this protection, Cosmos DB activity lacks advanced threat detection and telemetry. Attacks such as **SQL injection**, credential abuse, and **anomalous access patterns** may go unnoticed, enabling data exfiltration and unauthorized changes, degrading **confidentiality** and **integrity**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/th-th/Azure/defender-for-cloud/defender-for-databases-enable-cosmos-protections?tabs=azure-portal", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/CosmosDB/enable-advanced-threat-protection.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az security pricing create -n CosmosDbs --tier Standard", + "NativeIaC": "```bicep\n// Set Defender for Cosmos DB plan to Standard at subscription scope\ntargetScope = 'subscription'\n\nresource example_resource_name 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'CosmosDbs'\n properties: {\n pricingTier: 'Standard' // Critical: enables Defender for Cosmos DB (ON) at Standard tier\n }\n}\n```", + "Other": "1. In Azure portal, go to Microsoft Defender for Cloud > Environment settings\n2. Select the target subscription\n3. Open Defender plans (Pricing)\n4. Find Azure Cosmos DB and set the plan to On (Standard)\n5. Click Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Cosmos DB at Standard tier\nresource \"azurerm_security_center_subscription_pricing\" \"example_resource_name\" {\n resource_type = \"CosmosDbs\" # Critical: target Cosmos DB plan\n tier = \"Standard\" # Critical: sets plan to ON (Standard)\n}\n```" }, "Recommendation": { - "Text": "By default, Microsoft Defender for Cloud is not enabled for your App Service instances. Enabling the Defender security service for App Service instances allows for advanced security defense using threat detection capabilities provided by Microsoft Security Response Center.", - "Url": "Enable Microsoft Defender for Cosmos DB" + "Text": "Enable the `Standard` plan for **Microsoft Defender for Azure Cosmos DB** at the subscription to ensure full coverage. Enforce **least privilege**, route alerts to your SIEM, and tune detections. Use policy to require the plan across environments and regularly review findings to strengthen **defense in depth**.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_cosmosdb_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.metadata.json index 31d4d7e101..9a84caacb8 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_databases_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Databases Is Set To 'On' ", + "CheckTitle": "Defender for Databases is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Databases Is Set To 'On' ", - "Risk": "Enabling Microsoft Defender for Azure SQL Databases allows your organization more granular control of the infrastructure running your database software", + "Description": "**Azure subscription** is evaluated for **Defender for Databases** coverage: `Standard` pricing must be enabled for `SqlServers`, `SqlServerVirtualMachines`, `OpenSourceRelationalDatabases`, and `CosmosDbs`.", + "Risk": "Without this coverage, database workloads lack **advanced threat detection**, **vulnerability assessment**, and **behavior analytics**.\n\nAttacks like credential brute force, SQL injection, privilege abuse, and data exfiltration can go **undetected**, threatening **confidentiality, integrity**, and **availability**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/tutorial-enable-databases-plan", + "https://support.icompaas.com/support/solutions/articles/62000229826-ensure-that-microsoft-defender-for-databases-is-set-to-on-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "# Enable all Defender for Databases plans (must run each command separately)\naz security pricing create --name SqlServers --tier Standard\naz security pricing create --name SqlServerVirtualMachines --tier Standard\naz security pricing create --name OpenSourceRelationalDatabases --tier Standard\naz security pricing create --name CosmosDbs --tier Standard", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for Databases plans at subscription scope\ntargetScope = 'subscription'\n\nresource sqlServers 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'SqlServers'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for SQL servers to Standard (ON)\n }\n}\n\nresource sqlServerVMs 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'SqlServerVirtualMachines'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for SQL servers on machines to Standard (ON)\n }\n}\n\nresource openSourceDBs 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'OpenSourceRelationalDatabases'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for open-source databases to Standard (ON)\n }\n}\n\nresource cosmosDbs 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'CosmosDbs'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for Cosmos DB to Standard (ON)\n }\n}\n```", + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > choose your subscription\n3. Open Defender plans\n4. Set these plans to On (Standard):\n - SQL servers\n - SQL servers on machines\n - Open-source relational databases\n - Cosmos DB\n5. Click Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Databases plans\nresource \"azurerm_security_center_subscription_pricing\" \"sqlservers\" {\n resource_type = \"SqlServers\"\n tier = \"Standard\" # Critical: enables Defender (Standard)\n}\n\nresource \"azurerm_security_center_subscription_pricing\" \"sql_vm\" {\n resource_type = \"SqlServerVirtualMachines\"\n tier = \"Standard\" # Critical: enables Defender (Standard)\n}\n\nresource \"azurerm_security_center_subscription_pricing\" \"oss_db\" {\n resource_type = \"OpenSourceRelationalDatabases\"\n tier = \"Standard\" # Critical: enables Defender (Standard)\n}\n\nresource \"azurerm_security_center_subscription_pricing\" \"cosmos\" {\n resource_type = \"CosmosDbs\"\n tier = \"Standard\" # Critical: enables Defender (Standard)\n}\n```" }, "Recommendation": { - "Text": "Enable Microsoft Defender for Azure SQL Databases", - "Url": "" + "Text": "Enable **Defender for Databases** at the `Standard` tier for all supported database types across subscriptions. Integrate alerts with monitoring, automate response, and enforce **least privilege** and **network segmentation** for defense in depth. Use policy to maintain continuous coverage for new resources.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_databases_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.metadata.json index 9947218512..b1f7611af8 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_dns_is_on", - "CheckTitle": "Ensure That Microsoft Defender for DNS Is Set To 'On' ", + "CheckTitle": "Defender for DNS is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for DNS Is Set To 'On' ", - "Risk": "DNS lookups within a subscription are scanned and compared to a dynamic list of websites that might be potential security threats. These threats could be a result of a security breach within your services, thus scanning for them could prevent a potential security threat from being introduced.", + "Description": "**Microsoft Defender for DNS** is configured at the `Standard` tier for the subscription's Defender pricing", + "Risk": "Absent **Defender for DNS**, query telemetry isn't inspected, allowing **C2 callbacks**, **DNS tunneling**, and **malicious domains** to bypass detection. This increases risks to **confidentiality** (exfiltration), **integrity** (malware/DGA), and **availability** (poisoned or hijacked resolution).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-dns-introduction", + "https://support.icompaas.com/support/solutions/articles/62000234089-ensure-that-microsoft-defender-for-dns-is-set-to-on-automated-" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az security pricing create --name Dns --tier Standard", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for DNS at subscription scope\ntargetScope = 'subscription'\n\nresource example_resource_name 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'Dns'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for DNS to ON (Standard tier)\n }\n}\n```", + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud\n2. Select Environment settings and choose your subscription\n3. Open Defender plans\n4. Find DNS and set the plan to Standard (On)\n5. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"example_resource_name\" {\n resource_type = \"Dns\"\n tier = \"Standard\" # Critical: enables Defender for DNS\n}\n```" }, "Recommendation": { - "Text": "By default, Microsoft Defender for Cloud is not enabled for your App Service instances. Enabling the Defender security service for App Service instances allows for advanced security defense using threat detection capabilities provided by Microsoft Security Response Center.", - "Url": "" + "Text": "Enable **Defender for DNS** at the `Standard` tier across applicable subscriptions. Apply **defense in depth**: restrict outbound DNS, use private DNS where feasible, and log/monitor query activity. Route alerts to centralized monitoring. Enforce **least privilege** on security settings and review exclusions regularly.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_dns_is_on" } }, - "Categories": [], + "Categories": [ + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.metadata.json index db4221b4e1..7f2169bc72 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_keyvault_is_on", - "CheckTitle": "Ensure That Microsoft Defender for KeyVault Is Set To 'On' ", + "CheckTitle": "Defender for Key Vaults is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for KeyVault Is Set To 'On' ", - "Risk": "By default, Microsoft Defender for Cloud is disabled for Azure key vaults. Defender for Cloud detects unusual and potentially harmful attempts to access or exploit your Azure Key Vault data. This layer of protection allows you to address threats without being a security expert, and without the need to use and manage third-party security monitoring tools or services.", + "Description": "**Azure subscriptions** are evaluated for the **Defender for Key Vaults** plan configured at the `Standard` tier. It identifies where Key Vault protection uses this tier versus where the Defender pricing for `KeyVaults` is not set accordingly.", + "Risk": "Without **Defender for Key Vaults**, anomalous access and mass secret retrievals can go undetected, enabling:\n- Secret exfiltration (confidentiality)\n- Key/secret tampering (integrity)\n- Destructive actions like purge/delete (availability)\n\nLack of signals delays response and facilitates lateral movement.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-key-vault.html", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-key-vault-introduction" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-key-vault.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-key-vault#terraform" + "CLI": "az security pricing update --name KeyVaults --tier Standard", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for Key Vaults (Standard tier) at subscription scope\nresource example_pricing 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'KeyVaults'\n properties: {\n pricingTier: 'Standard' // Critical: sets the KeyVaults plan to Standard (ON)\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Select Environment settings, then choose your subscription\n3. Open Defender plans\n4. Find Key Vaults and set the plan to On/Standard\n5. Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Key Vaults (Standard)\nresource \"azurerm_security_center_subscription_pricing\" \"example_resource_name\" {\n resource_type = \"KeyVaults\"\n tier = \"Standard\" # Critical: sets the plan to Standard (ON)\n}\n```" }, "Recommendation": { - "Text": "Ensure that Microsoft Defender for Cloud is enabled for Azure key vaults. Key Vault is the Azure cloud service that safeguards encryption keys and secrets like certificates, connection-based strings, and passwords.", - "Url": "" + "Text": "Enable **Defender for Key Vaults** at the `Standard` tier across all subscriptions. Integrate alerts with monitoring and tune noise. Apply **least privilege** with **RBAC**, enforce purge protection and logging, and use **defense in depth** (private access and network restrictions) to prevent abuse and accelerate detection.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_keyvault_is_on" } }, - "Categories": [], + "Categories": [ + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.metadata.json index 242e0239ac..21648de49e 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_os_relational_databases_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Open-Source Relational Databases Is Set To 'On' ", + "CheckTitle": "Defender for Open-Source Relational Databases is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Open-Source Relational Databases Is Set To 'On' ", - "Risk": "Turning on Microsoft Defender for Open-source relational databases enables threat detection for Open-source relational databases, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.", + "Description": "**Microsoft Defender for Cloud** plan for **Open-Source Relational Databases** is evaluated for the `Standard` pricing tier at the subscription level.", + "Risk": "Absent the `Standard` plan, open-source databases lack **threat detection** and **behavior analytics**, reducing **confidentiality** and **integrity**. SQL injection, brute-force logins, and data exfiltration may go unnoticed, delaying response and enabling **lateral movement**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-databases-introduction", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-relational-database.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az security pricing create --name OpenSourceRelationalDatabases --tier Standard", + "NativeIaC": "```bicep\n// Deploy at subscription scope to set Defender pricing\ntargetScope = 'subscription'\n\nresource pricingOpenSource \"Microsoft.Security/pricings@2023-01-01\" = {\n name: 'OpenSourceRelationalDatabases'\n properties: {\n pricingTier: 'Standard' // Critical: sets the plan to Standard (ON)\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > your subscription\n3. Open Defender plans\n4. Find \"Open-source relational databases\" and set it to Standard/On\n5. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"example_resource_name\" {\n resource_type = \"OpenSourceRelationalDatabases\"\n tier = \"Standard\" # Critical: enables Defender (Standard tier)\n}\n```" }, "Recommendation": { - "Text": "Enabling Microsoft Defender for Open-source relational databases allows for greater defense-in-depth, with threat detection provided by the Microsoft Security Response Center (MSRC).", - "Url": "" + "Text": "Enable the plan at the `Standard` tier across relevant subscriptions. Apply **defense in depth**: enforce **least privilege**, isolate databases on private networks, require strong authentication, and route alerts to centralized monitoring for rapid triage. *Review coverage regularly*.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_os_relational_databases_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.metadata.json index c452cf025c..26f4e38701 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_server_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Servers Is Set to 'On'", + "CheckTitle": "Defender for Servers is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Servers Is Set to 'On'", - "Risk": "Turning on Microsoft Defender for Servers enables threat detection for Servers, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.", + "Description": "**Microsoft Defender for Servers** subscription plan (`VirtualMachines`) is configured to the `Standard` tier. The evaluation checks whether the Servers plan is enabled at this level for all server workloads in the subscription.", + "Risk": "Without **Defender for Servers**, endpoints lack unified EDR, hardening, and threat analytics. This enables silent malware, credential theft, and lateral movement, driving data exfiltration (C), ransomware/tampering (I), and outages or cryptomining abuse (A).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/plan-defender-for-servers-select-plan", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/faq-defender-for-servers", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/microsoft-defender-vm-server.html", + "https://learn.microsoft.com/en-us/answers/questions/1131575/defender-for-servers-policy-definitions.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/microsoft-defender-vm-server.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-servers#terraform" + "CLI": "az security pricing create --name VirtualMachines --tier Standard", + "NativeIaC": "```bicep\n// Enable Defender for Servers (Standard) at subscription scope\n@description('Enable Microsoft Defender for Servers (Standard)')\ntargetScope = 'subscription'\n\nresource 'Microsoft.Security/pricings@2024-01-01' = {\n name: 'VirtualMachines'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender for Servers to ON (Standard)\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Select Environment settings, then your \n3. On Defender plans, set Servers to On (Standard)\n4. Click Save", + "Terraform": "```hcl\n# Enable Defender for Servers (Standard) on the subscription\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n resource_type = \"VirtualMachines\"\n tier = \"Standard\" # Critical: sets Defender for Servers to ON (Standard)\n}\n```" }, "Recommendation": { - "Text": "Enabling Microsoft Defender for Cloud standard pricing tier allows for better security assessment with threat detection provided by the Microsoft Security Response Center (MSRC), advanced security policies, adaptive application control, network threat detection, and regulatory compliance management.", - "Url": "" + "Text": "Enable the **Defender for Servers** plan at the **subscription** scope with tier `Standard`, choosing P1 or P2 per asset risk. Ensure all Azure VMs and Arc-enabled servers are covered for EDR integration. Apply **defense in depth** and **least privilege**, and continuously monitor and tune alerts.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_server_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.metadata.json index ad834d46b6..dc2db32ba6 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_sql_servers_is_on", - "CheckTitle": "Ensure That Microsoft Defender for SQL Servers on Machines Is Set To 'On' ", + "CheckTitle": "Defender for SQL servers on machines is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for SQL Servers on Machines Is Set To 'On' ", - "Risk": "Turning on Microsoft Defender for SQL servers on machines enables threat detection for SQL servers on machines, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.", + "Description": "**Subscription pricing** for **Defender for SQL Server on Machines** is configured to the `Standard` plan, covering SQL Server instances running on virtual machines.", + "Risk": "Without **Defender for SQL Server on Machines**, attacks on SQL Server VMs can go **undetected**-including SQL injection, brute-force logons, and privilege abuse.\n\nThis risks data exfiltration (C), schema or record tampering (I), and outages or ransomware impact (A), while reducing visibility and delaying response.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-sql-introduction", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-sql-server-virtual-machines.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-sql-server-virtual-machines.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-sql-servers-on-machines#terraform" + "CLI": "az security pricing create -n SqlServerVirtualMachines --tier Standard", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for SQL servers on machines at subscription scope\ntargetScope = 'subscription'\n\nresource pricing 'Microsoft.Security/pricings@2022-03-01' = {\n name: 'SqlServerVirtualMachines'\n properties: {\n pricingTier: 'Standard' // Critical: sets Defender plan to Standard (ON) for SQL Server VMs\n }\n}\n```", + "Other": "1. In the Azure Portal, go to Microsoft Defender for Cloud\n2. Click Environment settings and select the target subscription\n3. Open Defender plans (Plans)\n4. Find SQL servers on machines and set it to Standard (On)\n5. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n resource_type = \"SqlServerVirtualMachines\" # Critical: target the SQL Server VMs Defender plan\n tier = \"Standard\" # Critical: enable Standard (ON)\n}\n```" }, "Recommendation": { - "Text": "By default, Microsoft Defender for Cloud is disabled for the Microsoft SQL servers running on virtual machines. Defender for Cloud for SQL Server virtual machines continuously monitors your SQL database servers for threats such as SQL injection, brute-force attacks, and privilege abuse. The security service provides security alerts together with details of the suspicious activity and guidance on how to mitigate to the security threats.", - "Url": "" + "Text": "Enable the **Defender for SQL Server on Machines** plan at the `Standard` tier for subscriptions hosting SQL Server VMs.\n\nApply defense-in-depth: enforce least privilege and strong authentication, segment networks, keep SQL patched, enable auditing, and route alerts to a SIEM for rapid containment.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_sql_servers_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.metadata.json index 9a3447329d..4ba11d66b1 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_defender_for_storage_is_on", - "CheckTitle": "Ensure That Microsoft Defender for Storage Is Set To 'On' ", + "CheckTitle": "Defender for Storage is set to On (Standard pricing tier)", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderPlan", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "Ensure That Microsoft Defender for Storage Is Set To 'On' ", - "Risk": "Ensure that Microsoft Defender for Cloud is enabled for your Microsoft Azure storage accounts. Defender for storage accounts is an Azure-native layer of security intelligence that detects unusual and potentially harmful attempts to access or exploit your Azure cloud storage accounts.", + "Description": "Azure subscription's **Defender for Storage** plan is set to `Standard` for Storage Accounts.", + "Risk": "Without **Defender for Storage**, suspicious access to blobs, files, and queues may go undetected. Compromised keys or `SAS` tokens can enable data exfiltration (**confidentiality**), object tampering (**integrity**), and mass deletion or ransomware-like encryption (**availability**).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-storage-introduction", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-storage.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-storage.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-that-azure-defender-is-set-to-on-for-storage#terraform" + "CLI": "az security pricing create -n StorageAccounts --tier Standard", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for Storage at subscription level\nresource example_resource_name 'Microsoft.Security/pricings@2023-01-01' = {\n name: 'StorageAccounts'\n properties: {\n pricingTier: 'Standard' // CRITICAL: sets the plan to Standard (ON) for Storage\n }\n}\n```", + "Other": "1. In Azure portal, open Microsoft Defender for Cloud\n2. Go to Environment settings > select \n3. Open Defender plans\n4. Set Storage to On (Standard)\n5. Click Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Storage at subscription level\nresource \"azurerm_security_center_subscription_pricing\" \"example_resource_name\" {\n resource_type = \"StorageAccounts\"\n tier = \"Standard\" # CRITICAL: sets Storage plan to Standard (ON)\n}\n```" }, "Recommendation": { - "Text": "By default, Microsoft Defender for Cloud is disabled for your storage accounts. Enabling the Defender security service for Azure storage accounts allows for advanced security defense using threat detection capabilities provided by the Microsoft Security Response Center (MSRC). MSRC investigates all reports of security vulnerabilities affecting Microsoft products and services, including Azure cloud services.", - "Url": "" + "Text": "Enable **Defender for Storage** at the `Standard` tier for subscriptions with storage workloads. Apply **defense in depth**: restrict network exposure, enforce **least privilege** on keys and `SAS`, use short-lived tokens and rotation, and route alerts to centralized monitoring for rapid response.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_for_storage_is_on" } }, - "Categories": [], + "Categories": [ + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.metadata.json index b4144cfa4e..a520f73859 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "defender_ensure_iot_hub_defender_is_on", - "CheckTitle": "Ensure That Microsoft Defender for IoT Hub Is Set To 'On'", + "CheckTitle": "Defender for IoT Hub is set to On", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "DefenderIoT", + "ResourceType": "microsoft.security/iotsecuritysolutions", "ResourceGroup": "security", - "Description": "Microsoft Defender for IoT acts as a central security hub for IoT devices within your organization.", - "Risk": "IoT devices are very rarely patched and can be potential attack vectors for enterprise networks. Updating their network configuration to use a central security hub allows for detection of these breaches.", - "RelatedUrl": "https://azure.microsoft.com/en-us/services/iot-defender/#overview", + "Description": "**Microsoft Defender for IoT security solution** exists in the subscription and reports status `Enabled` for monitored **IoT Hub** resources.", + "Risk": "Without **Defender for IoT**, device activity lacks telemetry and alerting, degrading CIA:\n- Compromised devices join botnets and exfiltrate data\n- Abused device identities alter cloud twins and commands\n- Lateral movement from IoT networks to Azure workloads\nThis blind spot increases dwell time and blast radius.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-iot/device-builders/quickstart-onboard-iot-hub", + "https://support.icompaas.com/support/solutions/articles/62000229850-ensure-that-microsoft-defender-for-iot-hub-is-set-to-on-" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```bicep\n// Enable Defender for IoT by creating an IoT Security Solution\nresource iotDefender 'Microsoft.Security/iotSecuritySolutions@2019-08-01' = {\n name: ''\n location: ''\n properties: {\n displayName: ''\n iotHubs: [''] // CRITICAL: links the IoT Hub; creating this solution enables Defender for IoT\n status: 'Enabled' // CRITICAL: ensures the solution is enabled\n }\n}\n```", + "Other": "1. In the Azure portal, go to IoT hubs and open your hub\n2. Select Defender for IoT > Overview\n3. Click Secure your IoT solution and complete onboarding (select the hub if prompted)\n4. If you see a toggle, set Enable Microsoft Defender for IoT to On and Save\n5. Verify the IoT Security Solution shows as Enabled under Defender for IoT", + "Terraform": "```hcl\n# Enable Defender for IoT by creating an IoT Security Solution\nresource \"azurerm_iot_security_solution\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n display_name = \"\"\n iothub_ids = [\"\"] # CRITICAL: links the IoT Hub; creating this solution enables Defender\n}\n```" }, "Recommendation": { - "Text": "1. Go to IoT Hub. 2. Select a IoT Hub to validate. 3. Select Overview in Defender for IoT. 4. Click on Secure your IoT solution, and complete the onboarding.", - "Url": "https://learn.microsoft.com/en-us/azure/defender-for-iot/device-builders/quickstart-onboard-iot-hub" + "Text": "Enable **Defender for IoT** on all IoT Hubs and keep it `Enabled`. Route security data to a central workspace and your SIEM. Apply **least privilege** to IoT identities, enforce **network segmentation** and private access, and use **defense in depth** with continuous monitoring, alert tuning, and periodic coverage reviews.", + "Url": "https://hub.prowler.com/check/defender_ensure_iot_hub_defender_is_on" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Enabling Microsoft Defender for IoT will incur additional charges dependent on the level of usage." diff --git a/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.metadata.json index f486d74e95..767f3b8eab 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "defender_ensure_mcas_is_enabled", - "CheckTitle": "Ensure that Microsoft Defender for Cloud Apps integration with Microsoft Defender for Cloud is Selected", + "CheckTitle": "Defender for Cloud Apps is enabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "DefenderSettings", + "ResourceType": "microsoft.security/pricings", "ResourceGroup": "security", - "Description": "This integration setting enables Microsoft Defender for Cloud Apps (formerly 'Microsoft Cloud App Security' or 'MCAS' - see additional info) to communicate with Microsoft Defender for Cloud.", - "Risk": "Microsoft Defender for Cloud offers an additional layer of protection by using Azure Resource Manager events, which is considered to be the control plane for Azure. By analyzing the Azure Resource Manager records, Microsoft Defender for Cloud detects unusual or potentially harmful operations in the Azure subscription environment. Several of the preceding analytics are powered by Microsoft Defender for Cloud Apps. To benefit from these analytics, subscription must have a Cloud App Security license. Microsoft Defender for Cloud Apps works only with Standard Tier subscriptions.", - "RelatedUrl": "https://learn.microsoft.com/en-in/azure/defender-for-cloud/defender-for-cloud-introduction#secure-cloud-applications", + "Description": "**Subscription settings** contain the `MCAS` integration for **Microsoft Defender for Cloud Apps**, and the setting is `enabled`.", + "Risk": "Missing integration leaves **Defender for Cloud** blind to SaaS context, weakening correlation of control-plane activity with app usage. Attackers can hide data exfiltration via cloud apps, abuse OAuth grants, or mask unauthorized ARM changes-impacting confidentiality and integrity and slowing incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-in/azure/defender-for-cloud/defender-for-cloud-introduction#secure-cloud-applications", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-cloud-apps-integration.html#", + "https://learn.microsoft.com/en-us/answers/questions/2045272/integrating-microsoft-defender-for-cloud-apps-with" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-cloud-apps-integration.html#", - "Terraform": "" + "CLI": "az rest --method PUT --uri https://management.azure.com/subscriptions//providers/Microsoft.Security/settings/MCAS?api-version=2021-06-01 --body '{\"properties\":{\"enabled\":true}}'", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for Cloud Apps (MCAS) at subscription scope\ntargetScope = 'subscription'\n\nresource mcas 'Microsoft.Security/settings@2021-06-01' = {\n name: 'MCAS'\n properties: {\n enabled: true // Critical: turns on MCAS integration for the subscription\n }\n}\n```", + "Other": "1. In the Azure portal, open Microsoft Defender for Cloud\n2. Go to Environment settings and select your subscription\n3. Open Settings & monitoring (or Integrations)\n4. Turn on \"Allow Microsoft Defender for Cloud Apps to access my data\"\n5. Click Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Cloud Apps (MCAS)\nresource \"azurerm_security_center_setting\" \"example\" {\n setting_name = \"MCAS\"\n enabled = true # Critical: enables MCAS integration for the subscription\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu. 2. Select Microsoft Defender for Cloud. 3. Select Environment Settings blade. 4. Select the subscription. 5. Check App Service Defender Plan to On. 6. Select Save.", - "Url": "https://docs.microsoft.com/en-us/rest/api/securitycenter/settings/list" + "Text": "Enable and keep the `MCAS` integration consistent across subscriptions.\n- Apply **least privilege** to integration roles and data access\n- Use policy to enforce the setting and prevent drift\n- Practice **defense in depth** by correlating SaaS and cloud signals\n- Review licensing and validate alert coverage regularly", + "Url": "https://hub.prowler.com/check/defender_ensure_mcas_is_enabled" } }, - "Categories": [], + "Categories": [ + "logging", + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Microsoft Defender for Cloud Apps works with Standard pricing tier Subscription. Choosing the Standard pricing tier of Microsoft Defender for Cloud incurs an additional cost per resource." diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json index a416974686..ad04e1689c 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "defender_ensure_notify_alerts_severity_is_high", - "CheckTitle": "Ensure that email notifications are configured for alerts with a minimum severity of 'High' or lower", + "CheckTitle": "Security contact has alert notifications enabled with minimum severity High or lower", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureEmailNotifications", + "ResourceType": "microsoft.resources/subscriptions", "ResourceGroup": "monitoring", - "Description": "Microsoft Defender for Cloud sends email notifications when alerts of a certain severity level or higher are triggered. By setting the minimum severity to 'High', 'Medium', or even 'Low', you ensure that alerts with equal or greater severity (e.g., High or Critical) are still delivered. Selecting a lower threshold like 'Low' results in more comprehensive alert coverage.", - "Risk": "If this setting is too restrictive (e.g., set to 'Critical' only), important security alerts with 'High' or 'Medium' severity might be missed. Ensuring that 'High' or a lower threshold is configured helps security teams stay informed about significant threats and respond in a timely manner.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/email-notifications-alerts#manage-notifications-on-email", + "Description": "**Microsoft Defender for Cloud** email notifications use a minimum alert severity of `High` or more inclusive (`Medium`/`Low`). The evaluation inspects security contacts to confirm a threshold is defined and not `Critical`.", + "Risk": "Setting the threshold to `Critical` or leaving it unset limits alerting, causing **delayed detection** of `High`/`Medium` threats. Attackers can persist, escalate privileges, and exfiltrate data, impacting **confidentiality**, **integrity**, and **availability** via ransomware or service disruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/enable-high-severity-email-notifications.html", + "https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/enable-high-severity-email-notifications.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/bc_azr_general_4#terraform" + "CLI": "az rest --method PUT --url \"https://management.azure.com/subscriptions//providers/Microsoft.Security/securityContacts/default?api-version=2023-12-01-preview\" --body '{\"properties\":{\"emails\":\"\",\"isEnabled\":true,\"notificationsSources\":[{\"sourceType\":\"Alert\",\"minimalSeverity\":\"High\"}]}}'", + "NativeIaC": "```bicep\ntargetScope = 'subscription'\n\nresource contact 'Microsoft.Security/securityContacts@2023-12-01-preview' = {\n name: ''\n properties: {\n emails: ''\n isEnabled: true\n notificationsSources: [\n {\n sourceType: 'Alert'\n minimalSeverity: 'High' // Critical line: sets minimum alert severity to High to pass the check\n }\n ]\n }\n}\n```", + "Other": "1. In Azure Portal, go to Defender for Cloud > Environment settings > select your subscription\n2. Open Email notifications\n3. Turn on \"Send email notifications for alerts\"\n4. Set \"Minimum alert severity\" to High (or Medium/Low)\n5. Enter at least one email address\n6. Click Save", + "Terraform": "```hcl\nresource \"azapi_resource\" \"\" {\n type = \"Microsoft.Security/securityContacts@2023-12-01-preview\"\n name = \"\"\n parent_id = \"/subscriptions/\"\n\n body = jsonencode({\n properties = {\n emails = \"\"\n isEnabled = true\n notificationsSources = [\n {\n sourceType = \"Alert\"\n minimalSeverity = \"High\" # Critical line: sets minimum alert severity to High to pass the check\n }\n ]\n }\n })\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu. 2. Select Microsoft Defender for Cloud. 3. Click on Environment Settings. 4. Click on the appropriate Management Group, Subscription, or Workspace. 5. Click on Email notifications. 6. Under 'Notify about alerts with the following severity (or higher)', select at least 'High' (or optionally 'Medium' or 'Low' for broader coverage). 7. Click Save.", - "Url": "https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list" + "Text": "Configure the minimum alert notification severity to `High` (or `Medium`/`Low`) and send to accountable recipients and RBAC roles. Apply **defense in depth**: route alerts to SIEM, use redundant contacts, and periodically test delivery. Review thresholds regularly to balance noise while avoiding false negatives.", + "Url": "https://hub.prowler.com/check/defender_ensure_notify_alerts_severity_is_high" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.metadata.json index b7eee7f6a9..30961d75ff 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "defender_ensure_notify_emails_to_owners", - "CheckTitle": "Ensure That 'All users with the following roles' is set to 'Owner'", + "CheckTitle": "Security contact notifications include the Owner role", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AzureEmailNotifications", + "ResourceType": "microsoft.resources/subscriptions", "ResourceGroup": "monitoring", - "Description": "Enable security alert emails to subscription owners.", - "Risk": "Enabling security alert emails to subscription owners ensures that they receive security alert emails from Microsoft. This ensures that they are aware of any potential security issues and can mitigate the risk in a timely fashion.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details", + "Description": "**Microsoft Defender for Cloud** email notifications target subscription users in the `Owner` role through role-based recipients.", + "Risk": "Without notifying **Owners**, critical alerts can be missed, delaying incident response. Attackers gain longer dwell time for data exfiltration, privilege abuse, and service disruption, undermining **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/rest/api/defenderforcloud/security-contacts/list?view=rest-defenderforcloud-2023-12-01-preview&tabs=HTTP", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/email-to-subscription-owners.html", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/email-to-subscription-owners.html", - "Terraform": "" + "CLI": "az security contact create --name default --email --alerts-admins On", + "NativeIaC": "```bicep\n// Enable Defender for Cloud notifications to the Owner role\nresource contact 'Microsoft.Security/securityContacts@2023-12-01-preview' = {\n name: 'default'\n properties: {\n emails: ''\n notificationsByRole: {\n state: 'On' // CRITICAL: Turn on role-based notifications\n roles: [ 'Owner' ] // CRITICAL: Ensure the Owner role is notified\n }\n }\n}\n```", + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > choose the target subscription\n3. Open Email notifications\n4. Enable \"Send email notifications to users with the following roles\"\n5. Select the role: Owner\n6. Click Save", + "Terraform": "```hcl\n# Enable notifications to subscription owners (Owner role)\nresource \"azurerm_security_center_contact\" \"\" {\n email = \"\"\n alert_notifications = true\n alerts_to_admins = true # CRITICAL: Notifies users with the Owner role\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. In the drop down of the All users with the following roles field select Owner 7. Click Save", - "Url": "https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list" + "Text": "Enable role-based notifications to the `Owner` role and use monitored, up-to-date distribution lists. Add secondary recipients (SOC/security admins) for redundancy, tune thresholds to reduce noise, and integrate with SIEM/automation. Apply **defense in depth** and **least privilege** for alert dissemination.", + "Url": "https://hub.prowler.com/check/defender_ensure_notify_emails_to_owners" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.metadata.json index 0cff932851..da21a48b65 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "defender_ensure_system_updates_are_applied", - "CheckTitle": "Ensure that Microsoft Defender Recommendation for 'Apply system updates' status is 'Completed'", + "CheckTitle": "All virtual machines in the subscription have system updates applied", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureDefenderRecommendation", + "ResourceType": "microsoft.compute/virtualmachines", "ResourceGroup": "security", - "Description": "Ensure that the latest OS patches for all virtual machines are applied.", - "Risk": "The Azure Security Center retrieves a list of available security and critical updates from Windows Update or Windows Server Update Services (WSUS), depending on which service is configured on a Windows VM. The security center also checks for the latest updates in Linux systems. If a VM is missing a system update, the security center will recommend system updates be applied.", - "RelatedUrl": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-posture-vulnerability-management#pv-7-rapidly-and-automatically-remediate-software-vulnerabilities", + "Description": "**Azure VMs** are evaluated for:\n- Presence of a monitoring agent\n- Periodic checks for missing updates\n- Installation of the latest **security and critical OS updates** on Windows and Linux", + "Risk": "Unpatched VMs are exposed to **known exploits** (RCE, privilege escalation), enabling **initial access** and **lateral movement**. This endangers **confidentiality** (data theft), **integrity** (tampering), and **availability** (ransomware, outages). Lapses in periodic assessment prolong exposure to critical vulnerabilities.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/apply-latest-os-patches.html", + "https://learn.microsoft.com/en-us/azure/virtual-machines/updates-maintenance-overview", + "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-posture-vulnerability-management#pv-7-rapidly-and-automatically-remediate-software-vulnerabilities" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/apply-latest-os-patches.html", + "Other": "1. In the Azure portal, go to Microsoft Defender for Cloud > Recommendations\n2. Search for \"Log Analytics agent should be installed on virtual machines\"\n - Select affected VMs > Fix > choose a Log Analytics workspace > Apply\n3. Search for \"Machines should be configured to periodically check for missing system updates\"\n - Select affected VMs > Fix > Apply\n4. Search for \"System updates should be installed on your machines\" (may show as powered by Azure Update Manager)\n - Select affected VMs > Fix > Install updates now (or One-time update) > Install\n5. Wait for installation to complete, then verify all three recommendations show Healthy for the subscription", "Terraform": "" }, "Recommendation": { - "Text": "Follow Microsoft Azure documentation to apply security patches from the security center. Alternatively, you can employ your own patch assessment and management tool to periodically assess, report, and install the required security patches for your OS.", - "Url": "https://learn.microsoft.com/en-us/azure/virtual-machines/updates-maintenance-overview" + "Text": "Adopt **automated patching** for all VMs:\n- Schedule recurring assessments\n- Deploy security/critical updates promptly using maintenance windows and rings\n- Ensure a supported update/monitoring agent\n- Enforce risk-based SLAs, test in stages, keep backups, and use **least privilege** for patch tools", + "Url": "https://hub.prowler.com/check/defender_ensure_system_updates_are_applied" } }, - "Categories": [], + "Categories": [ + "vulnerabilities", + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Running Microsoft Defender for Cloud incurs additional charges for each resource monitored. Please see attached reference for exact charges per hour." diff --git a/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.metadata.json index 7702fc721c..75166b2ecf 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled.metadata.json @@ -1,30 +1,38 @@ { "Provider": "azure", "CheckID": "defender_ensure_wdatp_is_enabled", - "CheckTitle": "Ensure that Microsoft Defender for Endpoint integration with Microsoft Defender for Cloud is selected", + "CheckTitle": "Defender for Endpoint is enabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "DefenderSettings", + "Severity": "high", + "ResourceType": "microsoft.security/integrations", "ResourceGroup": "security", - "Description": "This integration setting enables Microsoft Defender for Endpoint (formerly 'Advanced Threat Protection' or 'ATP' or 'WDATP' - see additional info) to communicate with Microsoft Defender for Cloud.", - "Risk": "Microsoft Defender for Endpoint integration brings comprehensive Endpoint Detection and Response (EDR) capabilities within Microsoft Defender for Cloud. This integration helps to spot abnormalities, as well as detect and respond to advanced attacks on endpoints monitored by Microsoft Defender for Cloud. MDE works only with Standard Tier subscriptions.", - "RelatedUrl": "https://learn.microsoft.com/en-in/azure/defender-for-cloud/integration-defender-for-endpoint?tabs=windows", + "Description": "**Azure subscription** integrates **Microsoft Defender for Endpoint** with **Defender for Cloud** via `WDATP`. The setting's presence and enabled state at the subscription scope are evaluated.", + "Risk": "Without this integration, servers lack **EDR telemetry**, automated onboarding, and unified alerts, shrinking visibility. Hands-on-keyboard intrusions, ransomware, and credential theft can persist unnoticed, enabling data exfiltration (**confidentiality**), unauthorized changes (**integrity**), and outages (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/azure-server-integration?view=o365-worldwide", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-endpoint-integration.html", + "https://learn.microsoft.com/en-in/azure/defender-for-cloud/integration-defender-for-endpoint?tabs=windows" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/SecurityCenter/defender-endpoint-integration.html", - "Terraform": "" + "CLI": "az rest --method put --uri https://management.azure.com/subscriptions//providers/Microsoft.Security/settings/WDATP?api-version=2019-01-01 --body '{\"properties\":{\"isEnabled\":true}}'", + "NativeIaC": "```bicep\n// Enable Microsoft Defender for Endpoint (WDATP) integration at subscription scope\nresource 'Microsoft.Security/settings@2019-01-01' = {\n name: 'WDATP'\n properties: {\n isEnabled: true // Critical: turns on the WDATP (Defender for Endpoint) integration\n }\n}\n```", + "Other": "1. In Azure Portal, go to Microsoft Defender for Cloud\n2. Select Environment settings > choose your subscription\n3. Open Settings (or Integrations)\n4. Find Microsoft Defender for Endpoint (WDATP) integration\n5. Toggle On and Save", + "Terraform": "```hcl\n# Enable Microsoft Defender for Endpoint (WDATP) integration\nresource \"azurerm_security_center_setting\" \"\" {\n setting_name = \"WDATP\"\n enabled = true # Critical: turns on WDATP integration\n}\n```" }, "Recommendation": { - "Text": "", - "Url": "https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/azure-server-integration?view=o365-worldwide" + "Text": "Enable the **Defender for Endpoint** integration in **Defender for Cloud** at the subscription scope and ensure agents are deployed on supported machines.\n\n- Apply **least privilege** to onboarding roles\n- Centralize alerting and response\n- Use **defense in depth** with hardening and network controls to reduce attack surface", + "Url": "https://hub.prowler.com/check/defender_ensure_wdatp_is_enabled" } }, - "Categories": [], + "Categories": [ + "vulnerabilities", + "forensics-ready" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Microsoft Defender for Endpoint works with Standard pricing tier Subscription. Choosing the Standard pricing tier of Microsoft Defender for Cloud incurs an additional cost per resource." diff --git a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py index 706f912a82..c86fc02da7 100644 --- a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py @@ -21,7 +21,7 @@ class entra_non_privileged_user_has_mfa(Check): f"Non-privileged user {user.name} does not have MFA." ) - if len(user.authentication_methods) > 1: + if user.is_mfa_capable: report.status = "PASS" report.status_extended = ( f"Non-privileged user {user.name} has MFA." diff --git a/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py b/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py index 5605ba4a83..c8c625f927 100644 --- a/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py @@ -21,7 +21,7 @@ class entra_privileged_user_has_mfa(Check): f"Privileged user {user.name} does not have MFA." ) - if len(user.authentication_methods) > 1: + if user.is_mfa_capable: report.status = "PASS" report.status_extended = f"Privileged user {user.name} has MFA." diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index 841283d42a..fffd64b976 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -66,6 +66,7 @@ class Entra(AzureService): for tenant, client in self.clients.items(): users.update({tenant: {}}) users_response = await client.users.get() + registration_details = await self._get_user_registration_details(client) try: while users_response: @@ -75,19 +76,9 @@ class Entra(AzureService): user.id: User( id=user.id, name=user.display_name, - authentication_methods=[ - AuthMethod( - id=auth_method.id, - type=getattr( - auth_method, "odata_type", None - ), - ) - for auth_method in ( - await client.users.by_user_id( - user.id - ).authentication.methods.get() - ).value - ], + is_mfa_capable=registration_details.get( + user.id, False + ), ) } ) @@ -98,17 +89,9 @@ class Entra(AzureService): users_response = await client.users.with_url(next_link).get() except Exception as error: - if ( - error.__class__.__name__ == "ODataError" - and error.__dict__.get("response_status_code", None) == 403 - ): - logger.error( - "You need 'UserAuthenticationMethod.Read.All' permission to access this information. It only can be granted through Service Principal authentication." - ) - else: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -116,6 +99,34 @@ class Entra(AzureService): return users + async def _get_user_registration_details(self, client): + registration_details = {} + try: + registration_builder = ( + client.reports.authentication_methods.user_registration_details + ) + registration_response = await registration_builder.get() + + while registration_response: + for detail in getattr(registration_response, "value", []) or []: + registration_details.update( + {detail.id: getattr(detail, "is_mfa_capable", False)} + ) + + next_link = getattr(registration_response, "odata_next_link", None) + if not next_link: + break + registration_response = await registration_builder.with_url( + next_link + ).get() + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return registration_details + async def _get_authorization_policy(self): logger.info("Entra - Getting authorization policy...") @@ -391,15 +402,10 @@ class Entra(AzureService): return conditional_access_policy -class AuthMethod(BaseModel): - id: str - type: str - - class User(BaseModel): id: str name: str - authentication_methods: List[AuthMethod] = [] + is_mfa_capable: bool = False class DefaultUserRolePermissions(BaseModel): diff --git a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py index ad3b6819ae..2c6e53d153 100644 --- a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py @@ -43,7 +43,7 @@ class entra_user_with_vm_access_has_mfa(Check): report.subscription = subscription_name report.status = "FAIL" report.status_extended = f"User {user.name} without MFA can access VMs in subscription {subscription_name}" - if len(user.authentication_methods) > 1: + if user.is_mfa_capable: report.status = "PASS" report.status_extended = f"User {user.name} can access VMs in subscription {subscription_name} but it has MFA." diff --git a/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.metadata.json b/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.metadata.json index e8ed3f53e4..f6b7ec661e 100644 --- a/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.metadata.json +++ b/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "iam_custom_role_has_permissions_to_administer_resource_locks", - "CheckTitle": "Ensure an IAM custom role has permissions to administer resource locks", + "CheckTitle": "Custom role has permission to administer resource locks", "CheckType": [], "ServiceName": "iam", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "AzureRole", + "Severity": "medium", + "ResourceType": "microsoft.authorization/roledefinitions", "ResourceGroup": "IAM", - "Description": "Ensure a Custom Role is Assigned Permissions for Administering Resource Locks", - "Risk": "In Azure, resource locks are a way to prevent accidental deletion or modification of critical resources. These locks can be set at the resource group level or the individual resource level. Resource locks administration is a critical task that should be preformed from a custom role with the appropriate permissions. This ensures that only authorized users can administer resource locks.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/lock-resources?tabs=json", + "Description": "**Azure custom RBAC roles** include the `Microsoft.Authorization/locks/*` action, indicating permission to administer **management locks** at subscription, resource group, or resource scope.", + "Risk": "Absent a scoped custom role for `Microsoft.Authorization/locks/*`, lock control falls to broad roles (e.g., Owner), weakening **least privilege**. Locks can be disabled or altered, enabling unauthorized changes or deletion, harming **integrity** and **availability**, and reducing **separation of duties** and accountability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/lock-resources?tabs=json", + "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/AccessControl/resource-lock-custom-role.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/AccessControl/resource-lock-custom-role.html", - "Terraform": "" + "CLI": "az role definition create --role-definition '{\"Name\":\"\",\"Description\":\"Custom role to administer resource locks\",\"IsCustom\":true,\"Actions\":[\"Microsoft.Authorization/locks/*\"],\"NotActions\":[],\"AssignableScopes\":[\"/subscriptions/\"]}'", + "NativeIaC": "```bicep\n// Custom role that can administer resource locks\ntargetScope = 'subscription'\n\nresource roleDef 'Microsoft.Authorization/roleDefinitions@2022-04-01' = {\n name: guid(subscription().id, '') // CRITICAL: use GUID for role definition name\n properties: {\n roleName: ''\n description: 'Custom role to administer resource locks'\n permissions: [\n {\n actions: [\n 'Microsoft.Authorization/locks/*' // CRITICAL: grants lock administration to pass the check\n ]\n notActions: []\n }\n ]\n assignableScopes: [ subscription().id ]\n }\n}\n```", + "Other": "1. In the Azure portal, go to the target scope (Subscription or Resource group) and open Access control (IAM)\n2. Click Roles, find your custom role, and select Edit\n3. Go to Permissions > Add permissions\n4. Search for \"Microsoft.Authorization/locks\" and select Microsoft.Authorization/locks/*\n5. Click Add, then Review + save > Save", + "Terraform": "```hcl\n# Custom role with permission to administer resource locks\nresource \"azurerm_role_definition\" \"\" {\n name = \"\"\n scope = \"/subscriptions/\"\n\n permissions {\n actions = [\n \"Microsoft.Authorization/locks/*\" # CRITICAL: adds lock admin permission to pass the check\n ]\n }\n\n assignable_scopes = [\"/subscriptions/\"]\n}\n```" }, "Recommendation": { - "Text": "Resouce locks are needed to prevent accidental deletion or modification of critical Azure resources. The administration of resource locks should be performed from a custom role with the appropriate permissions.", - "Url": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/AccessControl/resource-lock-custom-role.html" + "Text": "Define a **least-privilege custom role** restricted to `Microsoft.Authorization/locks/*` and assign it to a tightly controlled group at minimal scope. Apply **separation of duties**, use just-in-time elevation, audit lock changes, and avoid broad roles or pipeline identities managing locks. Layer with **defense-in-depth** controls.", + "Url": "https://hub.prowler.com/check/iam_custom_role_has_permissions_to_administer_resource_locks" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.metadata.json b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.metadata.json index f16dba55e6..908fd34768 100644 --- a/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.metadata.json +++ b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "iam_role_user_access_admin_restricted", - "CheckTitle": "Ensure 'User Access Administrator' role is restricted", + "CheckTitle": "Role assignment does not grant the User Access Administrator role", "CheckType": [], "ServiceName": "iam", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureIAMRoleassignment", + "ResourceType": "microsoft.authorization/roleassignments", "ResourceGroup": "IAM", - "Description": "Checks for active assignments of the highly privileged 'User Access Administrator' role in Azure subscriptions.", - "Risk": "Persistent assignment of this role can lead to privilege escalation and unauthorized access, increasing the risk of security breaches.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#user-access-administrator", + "Description": "**Azure subscription role assignments** granting **User Access Administrator** are identified to surface principals able to manage access (`Azure RBAC`) at that scope.", + "Risk": "Persistent `User Access Administrator` enables assigning high-privilege roles and reading control-plane data, enabling privilege escalation and unauthorized access. Impact: **confidentiality** (data exposure), **integrity** (unauthorized changes), **availability** (service disruption).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/role-based-access-control/elevate-access-global-admin?tabs=azure-portal%2Centra-audit-logs", + "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#user-access-administrator" + ], "Remediation": { "Code": { - "CLI": "az role assignment delete --role 'User Access Administrator' --scope '/subscriptions/'", + "CLI": "az role assignment delete --assignee --role \"User Access Administrator\" --scope \"/subscriptions/\"", "NativeIaC": "", - "Other": "", + "Other": "1. In the Azure portal, go to Subscriptions and select .\n2. Open Access control (IAM) > Role assignments.\n3. Filter by Role = User Access Administrator.\n4. Select the assignment(s) and click Remove. Confirm.", "Terraform": "" }, "Recommendation": { - "Text": "Remove 'User Access Administrator' role assignments immediately after use to minimize security risks.", - "Url": "https://learn.microsoft.com/en-us/azure/role-based-access-control/elevate-access-global-admin?tabs=azure-portal%2Centra-audit-logs" + "Text": "Enforce **least privilege**:\n- Avoid standing `User Access Administrator`; use time-bound, approval-based elevation (PIM)\n- Scope access to only required subscriptions/resource groups\n- Require MFA and monitor role activity\n- Review regularly and remove unused grants", + "Url": "https://hub.prowler.com/check/iam_role_user_access_admin_restricted" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.metadata.json b/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.metadata.json index c7075529ca..7aa85cdcb3 100644 --- a/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.metadata.json +++ b/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "iam_subscription_roles_owner_custom_not_created", - "CheckTitle": "Ensure that no custom subscription owner roles are created", + "CheckTitle": "Custom role is not a subscription owner role", "CheckType": [], "ServiceName": "iam", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AzureRole", + "ResourceType": "microsoft.authorization/roledefinitions", "ResourceGroup": "IAM", - "Description": "Ensure that no custom subscription owner roles are created", - "Risk": "Subscription ownership should not include permission to create custom owner roles. The principle of least privilege should be followed and only necessary privileges should be assigned instead of allowing full administrative access.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/role-based-access-control/custom-roles", + "Description": "**Azure custom roles** are analyzed for wildcard permissions. Roles that allow `*` in `actions` within their assignable scopes are treated as **owner-equivalent**, granting unrestricted control over subscription resources.", + "Risk": "Wildcard access grants full administrative control at subscription scope. If abused or compromised, an actor can exfiltrate data, alter configurations, deploy malware, delete resources, and disable logging, impacting confidentiality, integrity, and availability across the subscription.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/role-based-access-control/custom-roles", + "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/AccessControl/remove-custom-owner-roles.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/AccessControl/remove-custom-owner-roles.html", - "Terraform": "" + "CLI": "az role definition update --role-definition '{\"Name\":\"\",\"Description\":\"Restricted custom role\",\"Actions\":[\"Microsoft.Resources/subscriptions/resourceGroups/read\"],\"NotActions\":[],\"DataActions\":[],\"NotDataActions\":[],\"AssignableScopes\":[\"/subscriptions/\"]}'", + "NativeIaC": "```bicep\n// Subscription-scoped deployment to ensure the custom role does not use global \"*\" permissions\ntargetScope = 'subscription'\n\nresource roleDef 'Microsoft.Authorization/roleDefinitions@2022-04-01' = {\n name: guid(subscription().id, '') // CRITICAL: use GUID for role definition name\n properties: {\n roleName: ''\n description: 'Restricted custom role'\n assignableScopes: [\n subscription().id\n ]\n permissions: [\n {\n actions: [\n 'Microsoft.Resources/subscriptions/resourceGroups/read' // CRITICAL: remove \"*\" and allow only specific actions to avoid owner-equivalent wildcard\n ]\n notActions: []\n dataActions: []\n notDataActions: []\n }\n ]\n }\n}\n```", + "Other": "1. In the Azure portal, go to Subscriptions > > Access control (IAM)\n2. Select the Roles tab, then open the Custom roles tab\n3. Click the custom role that is failing, then click Edit\n4. In Permissions, remove the action \"*\" (All permissions)\n5. Add only the specific actions required (avoid using \"*\")\n6. Click Save", + "Terraform": "```hcl\n# Define a custom role without using the global \"*\" action\nresource \"azurerm_role_definition\" \"\" {\n name = \"\"\n scope = \"/subscriptions/\"\n\n permissions {\n actions = [\"Microsoft.Resources/subscriptions/resourceGroups/read\"] # CRITICAL: do not use \"*\"; specify only required actions\n }\n\n assignable_scopes = [\"/subscriptions/\"]\n}\n```" }, "Recommendation": { - "Text": "Custom subscription owner roles should not be created. This is because the principle of least privilege should be followed and only necessary privileges should be assigned instead of allowing full administrative access", - "Url": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/AccessControl/remove-custom-owner-roles.html" + "Text": "Avoid owner-equivalent custom roles. Apply **least privilege**: prefer built-in roles, define explicit allowed `actions` (avoid `*`), and limit assignment scope to the minimum needed. Enforce **separation of duties**, require just-in-time elevation, and perform periodic access reviews to prevent privilege creep.", + "Url": "https://hub.prowler.com/check/iam_subscription_roles_owner_custom_not_created" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py index 0a3648b907..cd5ec567aa 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py @@ -5,22 +5,21 @@ from prowler.providers.azure.services.keyvault.keyvault_client import keyvault_c class keyvault_rbac_secret_expiration_set(Check): def execute(self) -> Check_Report_Azure: findings = [] + for subscription, key_vaults in keyvault_client.key_vaults.items(): for keyvault in key_vaults: if keyvault.properties.enable_rbac_authorization and keyvault.secrets: - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) - report.subscription = subscription - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has all the secrets with expiration date set." - has_secret_without_expiration = False for secret in keyvault.secrets: + report = Check_Report_Azure( + metadata=self.metadata(), resource=secret + ) + report.subscription = subscription if not secret.attributes.expires and secret.enabled: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the secret {secret.name} without expiration date set." - has_secret_without_expiration = True - findings.append(report) - if not has_secret_without_expiration: + report.status_extended = f"Secret '{secret.name}' in KeyVault '{keyvault.name}' does not have expiration date set." + else: + report.status = "PASS" + report.status_extended = f"Secret '{secret.name}' in KeyVault '{keyvault.name}' has expiration date set." findings.append(report) + return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_service.py b/prowler/providers/azure/services/keyvault/keyvault_service.py index e63e799017..8f8a0cc452 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_service.py +++ b/prowler/providers/azure/services/keyvault/keyvault_service.py @@ -1,3 +1,4 @@ +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime from typing import List, Optional, Union @@ -20,99 +21,155 @@ class KeyVault(AzureService): self.key_vaults = self._get_key_vaults(provider) def _get_key_vaults(self, provider): + """ + Get all KeyVaults with parallel processing. + + Optimizations: + 1. Uses list_by_subscription() for full Vault objects + 2. Processes vaults in parallel using __threading_call__ + 3. Each vault's keys/secrets/monitor fetched in parallel + """ logger.info("KeyVault - Getting key_vaults...") key_vaults = {} + for subscription, client in self.clients.items(): try: - key_vaults.update({subscription: []}) - key_vaults_list = client.vaults.list() - for keyvault in key_vaults_list: - resource_group = keyvault.id.split("/")[4] - keyvault_name = keyvault.name - keyvault_properties = client.vaults.get( - resource_group, keyvault_name - ).properties - keys = self._get_keys( - subscription, resource_group, keyvault_name, provider - ) - secrets = self._get_secrets( - subscription, resource_group, keyvault_name - ) - key_vaults[subscription].append( - KeyVaultInfo( - id=getattr(keyvault, "id", ""), - name=getattr(keyvault, "name", ""), - location=getattr(keyvault, "location", ""), - resource_group=resource_group, - properties=VaultProperties( - tenant_id=getattr(keyvault_properties, "tenant_id", ""), - enable_rbac_authorization=getattr( - keyvault_properties, - "enable_rbac_authorization", - False, - ), - private_endpoint_connections=[ - PrivateEndpointConnection(id=conn.id) - for conn in ( - getattr( - keyvault_properties, - "private_endpoint_connections", - [], - ) - or [] - ) - ], - enable_soft_delete=getattr( - keyvault_properties, "enable_soft_delete", False - ), - enable_purge_protection=getattr( - keyvault_properties, - "enable_purge_protection", - False, - ), - public_network_access_disabled=( - getattr( - keyvault_properties, - "public_network_access", - "Enabled", - ) - == "Disabled" - ), - ), - keys=keys, - secrets=secrets, - monitor_diagnostic_settings=self._get_vault_monitor_settings( - keyvault_name, resource_group, subscription - ), - ) - ) + key_vaults[subscription] = [] + vaults_list = list(client.vaults.list_by_subscription()) + + if not vaults_list: + continue + + # Prepare items for parallel processing + items = [ + { + "subscription": subscription, + "keyvault": vault, + "provider": provider, + } + for vault in vaults_list + ] + + # Process all KeyVaults in parallel + results = self.__threading_call__(self._process_single_keyvault, items) + key_vaults[subscription] = results + except Exception as error: logger.error( f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return key_vaults + def _process_single_keyvault(self, item: dict) -> Optional["KeyVaultInfo"]: + """Process a single KeyVault in parallel.""" + subscription = item["subscription"] + keyvault = item["keyvault"] + provider = item["provider"] + + try: + resource_group = keyvault.id.split("/")[4] + keyvault_name = keyvault.name + keyvault_properties = keyvault.properties + + # Fetch keys, secrets, and monitor in parallel + with ThreadPoolExecutor(max_workers=3) as executor: + keys_future = executor.submit( + self._get_keys, + subscription, + resource_group, + keyvault_name, + provider, + ) + secrets_future = executor.submit( + self._get_secrets, subscription, resource_group, keyvault_name + ) + monitor_future = executor.submit( + self._get_vault_monitor_settings, + keyvault_name, + resource_group, + subscription, + ) + + keys = keys_future.result() + secrets = secrets_future.result() + monitor_settings = monitor_future.result() + + return KeyVaultInfo( + id=getattr(keyvault, "id", ""), + name=getattr(keyvault, "name", ""), + location=getattr(keyvault, "location", ""), + resource_group=resource_group, + properties=VaultProperties( + tenant_id=getattr(keyvault_properties, "tenant_id", ""), + enable_rbac_authorization=getattr( + keyvault_properties, + "enable_rbac_authorization", + False, + ), + private_endpoint_connections=[ + PrivateEndpointConnection(id=conn.id) + for conn in ( + getattr( + keyvault_properties, + "private_endpoint_connections", + [], + ) + or [] + ) + ], + enable_soft_delete=getattr( + keyvault_properties, "enable_soft_delete", False + ), + enable_purge_protection=getattr( + keyvault_properties, + "enable_purge_protection", + False, + ), + public_network_access_disabled=( + getattr( + keyvault_properties, + "public_network_access", + "Enabled", + ) + == "Disabled" + ), + ), + keys=keys, + secrets=secrets, + monitor_diagnostic_settings=monitor_settings, + ) + + except Exception as error: + logger.error( + f"KeyVault {keyvault.name} in {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + def _get_keys(self, subscription, resource_group, keyvault_name, provider): logger.info(f"KeyVault - Getting keys for {keyvault_name}...") keys = [] + keys_dict = {} + try: client = self.clients[subscription] keys_list = client.keys.list(resource_group, keyvault_name) for key in keys_list: - keys.append( - Key( - id=getattr(key, "id", ""), - name=getattr(key, "name", ""), + key_obj = Key( + id=getattr(key, "id", ""), + name=getattr(key, "name", ""), + enabled=getattr(key.attributes, "enabled", False), + location=getattr(key, "location", ""), + attributes=KeyAttributes( enabled=getattr(key.attributes, "enabled", False), - location=getattr(key, "location", ""), - attributes=KeyAttributes( - enabled=getattr(key.attributes, "enabled", False), - created=getattr(key.attributes, "created", 0), - updated=getattr(key.attributes, "updated", 0), - expires=getattr(key.attributes, "expires", 0), - ), - ) + created=getattr(key.attributes, "created", 0), + updated=getattr(key.attributes, "updated", 0), + expires=getattr(key.attributes, "expires", 0), + ), ) + keys.append(key_obj) + keys_dict[key_obj.name] = key_obj + except Exception as error: logger.error( f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -124,12 +181,19 @@ class KeyVault(AzureService): # TODO: review the following line credential=provider.session, ) - properties = key_client.list_properties_of_keys() - for prop in properties: - policy = key_client.get_key_rotation_policy(prop.name) - for key in keys: - if key.name == prop.name: - key.rotation_policy = KeyRotationPolicy( + properties = list(key_client.list_properties_of_keys()) + + if properties: + items = [ + {"key_client": key_client, "prop": prop} for prop in properties + ] + rotation_results = self.__threading_call__( + self._get_single_rotation_policy, items + ) + + for name, policy in rotation_results: + if policy and name in keys_dict: + keys_dict[name].rotation_policy = KeyRotationPolicy( id=getattr(policy, "id", ""), lifetime_actions=[ KeyRotationLifetimeAction(action=action.action) @@ -142,8 +206,25 @@ class KeyVault(AzureService): logger.warning( f"Subscription name: {subscription} -- has no access policy configured for keyvault {keyvault_name}" ) + return keys + def _get_single_rotation_policy(self, item: dict) -> tuple: + """Thread-safe rotation policy retrieval.""" + key_client = item["key_client"] + prop = item["prop"] + + try: + policy = key_client.get_key_rotation_policy(prop.name) + return (prop.name, policy) + except HttpResponseError: + return (prop.name, None) + except Exception as error: + logger.warning( + f"KeyVault - Failed to get rotation policy for key {prop.name}: {error}" + ) + return (prop.name, None) + def _get_secrets(self, subscription, resource_group, keyvault_name): logger.info(f"KeyVault - Getting secrets for {keyvault_name}...") secrets = [] @@ -177,6 +258,7 @@ class KeyVault(AzureService): logger.error( f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return secrets def _get_vault_monitor_settings(self, keyvault_name, resource_group, subscription): @@ -192,8 +274,9 @@ class KeyVault(AzureService): ) except Exception as error: logger.error( - f"Subscription name: {self.subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return monitor_diagnostics_settings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.metadata.json index 4e17cd332d..df76de17b1 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.metadata.json +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "mysql_flexible_server_audit_log_connection_activated", - "CheckTitle": "Ensure server parameter 'audit_log_events' has 'CONNECTION' set for MySQL Database Server", + "CheckTitle": "MySQL flexible server has audit_log_events including CONNECTION", "CheckType": [], "ServiceName": "mysql", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.DBforMySQL/flexibleServers", + "ResourceType": "microsoft.dbformysql/flexibleservers", "ResourceGroup": "database", - "Description": "Set audit_log_enabled to include CONNECTION on MySQL Servers.", - "Risk": "Enabling CONNECTION helps MySQL Database to log items such as successful and failed connection attempts to the server. Log data can be used to identify, troubleshoot, and repair configuration errors and suboptimal performance.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/mysql/single-server/how-to-configure-audit-logs-portal", + "Description": "**Azure Database for MySQL Flexible Server** audit configuration includes the `CONNECTION` event in `audit_log_events`.", + "Risk": "Without **CONNECTION auditing**, login attempts are invisible, weakening detection of **brute-force**, **credential stuffing**, and anomalous access. This enables unnoticed account takeover and lateral movement, impacting **confidentiality** and **integrity**, and hinders **forensics** and timely response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/MySQL/configure-audit-log-events-for-mysql-flexible-servers.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.tenable.com/audits/items/CIS_Microsoft_Azure_Foundations_v2.0.0_L2.audit:06ec721d4c0ea9169db2b0c6876c5f38", - "Terraform": "" + "CLI": "az mysql flexible-server parameter set --resource-group --server-name --name audit_log_events --value CONNECTION", + "NativeIaC": "```bicep\n// Set MySQL Flexible Server audit_log_events to include CONNECTION\nresource cfg 'Microsoft.DBforMySQL/flexibleServers/configurations@2021-05-01' = {\n name: '/audit_log_events'\n properties: {\n value: 'CONNECTION' // Critical: ensures 'CONNECTION' is logged, making the check PASS\n }\n}\n```", + "Other": "1. In the Azure Portal, go to Azure Database for MySQL flexible server\n2. Select your server, then go to Server parameters\n3. Search for audit_log_events\n4. Set its value to CONNECTION\n5. Click Save", + "Terraform": "```hcl\nresource \"azurerm_mysql_flexible_server_configuration\" \"\" {\n name = \"audit_log_events\"\n resource_group_name = \"\"\n server_name = \"\"\n value = \"CONNECTION\" # Critical: includes CONNECTION in audit logs to pass the check\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu. 2. Select Azure Database for MySQL servers. 3. Select a database. 4. Under Settings, select Server parameters. 5. Update audit_log_enabled parameter to ON. 6. Update audit_log_events parameter to have at least CONNECTION checked. 7. Click Save. 8. Under Monitoring, select Diagnostic settings. 9. Select + Add diagnostic setting. 10. Provide a diagnostic setting name. 11. Under Categories, select MySQL Audit Logs. 12. Specify destination details. 13. Click Save.", - "Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-3-enable-logging-for-security-investigation" + "Text": "Include `CONNECTION` in `audit_log_events` to capture login activity. Centralize and retain **audit logs**, restrict access by **least privilege**, and protect logs from tampering. Monitor for anomalous sign-in patterns and alert. Pair with **defense-in-depth** controls (MFA, network allow-listing) to reduce exposure.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_audit_log_connection_activated" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "There are further costs incurred for storage of logs. For high traffic databases these logs will be significant. Determine your organization's needs before enabling." diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.metadata.json index 5a1f793978..241a730606 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.metadata.json +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "mysql_flexible_server_audit_log_enabled", - "CheckTitle": "Ensure server parameter 'audit_log_enabled' is set to 'ON' for MySQL Database Server", + "CheckTitle": "MySQL flexible server has audit_log_enabled set to ON", "CheckType": [], "ServiceName": "mysql", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.DBforMySQL/flexibleServers", + "ResourceType": "microsoft.dbformysql/flexibleservers", "ResourceGroup": "database", - "Description": "Enable audit_log_enabled on MySQL Servers.", - "Risk": "Enabling audit_log_enabled helps MySQL Database to log items such as connection attempts to the server, DDL/DML access, and more. Log data can be used to identify, troubleshoot, and repair configuration errors and suboptimal performance.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/mysql/single-server/how-to-configure-audit-logs-portal", + "Description": "**Azure Database for MySQL Flexible Server** with `audit_log_enabled` set to `ON` generates **audit logs** for connections, authentication, DDL/DML, and administrative actions.", + "Risk": "Missing **audit logs** reduces **accountability** and obscures activity affecting **confidentiality** and **integrity**. Unauthorized logins, privilege abuse, or suspicious queries may go undetected, impeding **forensics**, slowing incident response, and enabling covert data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/tutorial-configure-audit", + "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-3-enable-logging-for-security-investigation", + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/scripts/sample-cli-audit-logs" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.tenable.com/audits/items/CIS_Microsoft_Azure_Foundations_v1.5.0_L2.audit:c073639a1ce546b535ba73afbf6542aa", - "Terraform": "" + "CLI": "az mysql flexible-server parameter set --name audit_log_enabled --resource-group --server-name --value ON", + "NativeIaC": "```bicep\n// Enable audit logs on an existing MySQL Flexible Server\nresource server 'Microsoft.DBforMySQL/flexibleServers@2021-12-01-preview' existing = {\n name: ''\n}\n\nresource audit 'Microsoft.DBforMySQL/flexibleServers/configurations@2021-12-01-preview' = {\n name: 'audit_log_enabled'\n parent: server\n properties: {\n value: 'ON' // CRITICAL: turns audit_log_enabled ON to pass the check\n }\n}\n```", + "Other": "1. Sign in to the Azure portal\n2. Go to: Azure Database for MySQL flexible server > Your server\n3. Under Settings, select Server parameters\n4. Find audit_log_enabled and set it to ON\n5. Click Save", + "Terraform": "```hcl\n# Enable audit logs on MySQL Flexible Server\nresource \"azurerm_mysql_flexible_server_configuration\" \"\" {\n name = \"audit_log_enabled\"\n resource_group_name = \"\"\n server_name = \"\"\n value = \"ON\" # CRITICAL: enables audit logging to pass the check\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com. 2. Select Azure Database for MySQL Servers. 3. Select a database. 4. Under Settings, select Server parameters. 5. Update audit_log_enabled parameter to ON 6. Under Monitoring, select Diagnostic settings. 7. Select + Add diagnostic setting. 8. Provide a diagnostic setting name. 9. Under Categories, select MySQL Audit Logs. 10. Specify destination details. 11. Click Save.", - "Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-3-enable-logging-for-security-investigation" + "Text": "Enable **audit logging** (`audit_log_enabled=ON`) and select events that matter. Export `MySqlAuditLogs` to a centralized store, enforce **least privilege** on log access, set retention, and create alerts for anomalies. Regularly review logs as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_audit_log_enabled" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.metadata.json index 7de8a36677..48b6004079 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.metadata.json +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_minimum_tls_version_12/mysql_flexible_server_minimum_tls_version_12.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "mysql_flexible_server_minimum_tls_version_12", - "CheckTitle": "Ensure 'TLS Version' is set to 'TLSV1.2' for MySQL flexible Database Server", + "CheckTitle": "MySQL flexible server enforces TLS 1.2 or higher", "CheckType": [], "ServiceName": "mysql", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.DBforMySQL/flexibleServers", + "ResourceType": "microsoft.dbformysql/flexibleservers", "ResourceGroup": "database", - "Description": "Ensure TLS version on MySQL flexible servers is set to the default value.", - "Risk": "TLS connectivity helps to provide a new layer of security by connecting database server to client applications using Transport Layer Security (TLS). Enforcing TLS connections between database server and client applications helps protect against 'man in the middle' attacks by encrypting the data stream between the server and application.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/mysql/concepts-ssl-connection-security", + "Description": "**Azure Database for MySQL Flexible Server** uses the `tls_version` setting to permit only **modern TLS** for client connections, requiring `TLSv1.2+` and excluding `TLSv1.0` and `TLSv1.1`.", + "Risk": "Allowing legacy TLS (`TLSv1.0`/`TLSv1.1`) weakens **confidentiality** and **integrity** of data in transit. Attackers can force downgrades and perform **man-in-the-middle** interception, exposing credentials and queries or altering results, leading to unauthorized access and data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/MySQL/mysql-flexible-server-tls-version.html", + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/security-tls-how-to-connect" + ], "Remediation": { "Code": { - "CLI": "az mysql flexible-server parameter set --name tls_version --resource-group --server-name --value TLSV1.2", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/MySQL/mysql-flexible-server-tls-version.html", - "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-mysql-is-using-the-latest-version-of-tls-encryption#terraform" + "CLI": "az mysql flexible-server parameter set --resource-group --server-name --name tls_version --value TLSv1.2", + "NativeIaC": "```bicep\n// Set MySQL Flexible Server to enforce TLS 1.2\nresource tlsVersion 'Microsoft.DBforMySQL/flexibleServers/configurations@2022-01-01' = {\n name: '/tls_version'\n properties: {\n value: 'TLSv1.2' // Critical: enforces minimum TLS 1.2 and rejects TLS 1.0/1.1\n }\n}\n```", + "Other": "1. In Azure portal, go to Azure Database for MySQL flexible server \n2. Select Server parameters\n3. Search for tls_version\n4. Set the value to TLSv1.2\n5. Click Save", + "Terraform": "```hcl\n# Enforce TLS 1.2 on MySQL Flexible Server\nresource \"azurerm_mysql_flexible_server_configuration\" \"tls\" {\n name = \"tls_version\"\n resource_group_name = \"\"\n server_name = \"\"\n value = \"TLSv1.2\" # Critical: sets minimum TLS to 1.2 (no 1.0/1.1)\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to Azure Database for MySQL flexible servers 3. For each database, click on Server parameters under Settings 4. In the search box, type in tls_version 5. Click on the VALUE dropdown, and ensure only TLSV1.2 is selected for tls_version", - "Url": "https://docs.microsoft.com/en-us/azure/mysql/howto-configure-ssl" + "Text": "Enforce a **minimum TLS** of `TLSv1.2` (prefer `TLSv1.3`) and disable `TLSv1.0`/`TLSv1.1`. Ensure clients and drivers support modern TLS, deprecate weak cipher suites, and validate in staging. Apply **defense in depth** with private connectivity and restricted network access.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_minimum_tls_version_12" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.metadata.json index 62797b06d6..184f94c8b2 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.metadata.json +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "azure", "CheckID": "mysql_flexible_server_ssl_connection_enabled", - "CheckTitle": "Ensure 'Enforce SSL connection' is set to 'Enabled' for Standard MySQL Database Server", + "CheckTitle": "MySQL Flexible Server enforces SSL connections", "CheckType": [], "ServiceName": "mysql", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Microsoft.DBforMySQL/flexibleServers", + "ResourceType": "microsoft.dbformysql/flexibleservers", "ResourceGroup": "database", - "Description": "Enable SSL connection on MYSQL Servers.", - "Risk": "SSL connectivity helps to provide a new layer of security by connecting database server to client applications using Secure Sockets Layer (SSL). Enforcing SSL connections between database server and client applications helps protect against 'man in the middle' attacks by encrypting the data stream between the server and application.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/mysql/single-server/concepts-ssl-connection-security", + "Description": "**Azure Database for MySQL Flexible Server** uses the `require_secure_transport` parameter to enforce **encrypted connections**. This evaluation determines whether the server is configured to require **TLS/SSL** for all client sessions.", + "Risk": "Without **TLS enforcement**, credentials and queries may traverse the network in cleartext, enabling **man-in-the-middle**, **credential theft**, tampering, and data exfiltration. This directly impacts **confidentiality** and **integrity** and can lead to compliance violations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-networking", + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-troubleshoot-common-connection-issues", + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-connect-tls-ssl" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.tenable.com/policies/[type]/AC_AZURE_0131", - "Terraform": "" + "CLI": "az mysql flexible-server parameter set --resource-group --server-name --name require_secure_transport --value ON", + "NativeIaC": "```bicep\n// Enforce SSL/TLS by enabling require_secure_transport on MySQL Flexible Server\nresource reqSecureTransport 'Microsoft.DBforMySQL/flexibleServers/configurations@2023-12-30' = {\n name: '/require_secure_transport'\n properties: {\n value: 'ON' // Critical: turns on SSL enforcement (require_secure_transport)\n }\n}\n```", + "Other": "1. Sign in to the Azure portal\n2. Go to: Azure Database for MySQL Flexible Server > \n3. Select Server parameters\n4. Find require_secure_transport and set it to ON\n5. Click Save\n6. Verify by refreshing Server parameters and confirming the value is ON", + "Terraform": "```hcl\n# Enforce SSL/TLS on MySQL Flexible Server\nresource \"azurerm_mysql_flexible_server_configuration\" \"secure\" {\n name = \"require_secure_transport\"\n resource_group_name = \"\"\n server_name = \"\"\n value = \"ON\" # Critical: enables SSL enforcement\n}\n```" }, "Recommendation": { - "Text": "1. Login to Azure Portal using https://portal.azure.com 2. Go to Azure Database for MySQL servers 3. For each database, click on Connection security 4. In SSL settings, click on ENABLED to Enforce SSL connections", - "Url": "https://docs.microsoft.com/en-us/azure/mysql/single-server/how-to-configure-ssl" + "Text": "Set `require_secure_transport=ON` and permit only **TLS 1.2+**. Ensure clients validate certificates and use FQDNs. Combine with **private access** (Private Link or VNet), restrictive firewall rules, and **least privilege** to reduce exposure. *Avoid legacy TLS or plaintext connections.*", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_ssl_connection_enabled" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.metadata.json b/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.metadata.json index 36b6d714e7..a98cf5aceb 100644 --- a/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.metadata.json +++ b/prowler/providers/azure/services/policy/policy_ensure_asc_enforcement_enabled/policy_ensure_asc_enforcement_enabled.metadata.json @@ -1,27 +1,32 @@ { "Provider": "azure", "CheckID": "policy_ensure_asc_enforcement_enabled", - "CheckTitle": "Ensure Any of the ASC Default Policy Settings are Not Set to 'Disabled'", + "CheckTitle": "Security Center built-in policy assignment has enforcement mode set to Default", "CheckType": [], "ServiceName": "policy", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Microsoft.Authorization/policyAssignments", + "ResourceType": "microsoft.authorization/policyassignments", "ResourceGroup": "governance", - "Description": "None of the settings offered by ASC Default policy should be set to effect Disabled.", - "Risk": "A security policy defines the desired configuration of your workloads and helps ensure compliance with company or regulatory security requirements. ASC Default policy is associated with every subscription by default. ASC default policy assignment is a set of security recommendations based on best practices. Enabling recommendations in ASC default policy ensures that Azure security center provides the ability to monitor all of the supported recommendations and optionally allow automated action for a few of the supported recommendations.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/security-policy-concept", + "Description": "**Defender for Cloud default policy assignment** (`SecurityCenterBuiltIn`) uses enforcement mode `Default` rather than `DoNotEnforce`", + "Risk": "With `DoNotEnforce`, policy effects like `deny` and `deployIfNotExists` aren't applied, letting insecure configs persist. This erodes **confidentiality** and **integrity** (exposed endpoints, weak encryption) and can affect **availability** via unpatched or misconfigured services, enabling compromise and lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/policy-reference", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/implement-security-recommendations", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/security-policy-concept" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "az policy assignment update --name SecurityCenterBuiltIn --scope /subscriptions/ --enforcement-mode Default", + "NativeIaC": "```bicep\n// Set enforcement mode to Default for the Security Center built-in assignment\n// Deploy at subscription scope\ntargetScope = 'subscription'\n\nresource policyAssignment 'Microsoft.Authorization/policyAssignments@2021-06-01' = {\n name: 'SecurityCenterBuiltIn'\n properties: {\n policyDefinitionId: ''\n enforcementMode: 'Default' // CRITICAL: Ensures the assignment enforces policy (fixes the finding)\n }\n}\n```", + "Other": "1. In Azure portal, go to Policy > Assignments\n2. Find the assignment named \"SecurityCenterBuiltIn\" and select it\n3. Click Edit assignment\n4. Set Enforcement mode to Enabled (Default)\n5. Click Review + save to apply", + "Terraform": "```hcl\n# Set enforcement mode to Default for the Security Center built-in assignment\nresource \"azurerm_policy_assignment\" \"\" {\n name = \"SecurityCenterBuiltIn\"\n scope = \"/subscriptions/\"\n policy_definition_id = \"\"\n enforcement_mode = \"Default\" # CRITICAL: Enables enforcement to pass the check\n}\n```" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu 2. Select Policy 3. Select ASC Default for each subscription 4. Click on 'view Assignment' 5. Click on 'Edit assignment' 6. Ensure Policy Enforcement is Enabled 7. Click 'Review + Save'", - "Url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/implement-security-recommendations" + "Text": "Keep enforcement mode `Default` on the default initiative and avoid disabling critical effects. Apply at scale for consistent governance, align with **least privilege** and **defense in depth**, validate changes in `Audit` in non-prod, and manage justified exceptions via time-bound policy exemptions instead of turning enforcement off.", + "Url": "https://hub.prowler.com/check/policy_ensure_asc_enforcement_enabled" } }, "Categories": [], diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py index 0dddcdb8d4..ea0a620087 100644 --- a/prowler/providers/cloudflare/cloudflare_provider.py +++ b/prowler/providers/cloudflare/cloudflare_provider.py @@ -14,6 +14,7 @@ from prowler.lib.utils.utils import print_boxes from prowler.providers.cloudflare.exceptions.exceptions import ( CloudflareCredentialsError, CloudflareIdentityError, + CloudflareInvalidAccountError, CloudflareSessionError, ) from prowler.providers.cloudflare.lib.mutelist.mutelist import CloudflareMutelist @@ -36,11 +37,13 @@ class CloudflareProvider(Provider): _fixer_config: dict _mutelist: CloudflareMutelist _filter_zones: set[str] | None + _filter_accounts: set[str] | None audit_metadata: Audit_Metadata def __init__( self, filter_zones: Iterable[str] | None = None, + filter_accounts: Iterable[str] | None = None, config_path: str = None, config_content: dict | None = None, fixer_config: dict = {}, @@ -74,6 +77,23 @@ class CloudflareProvider(Provider): # Store zone filter for filtering resources across services self._filter_zones = set(filter_zones) if filter_zones else None + # Store account filter and restrict audited_accounts accordingly + self._filter_accounts = set(filter_accounts) if filter_accounts else None + if self._filter_accounts: + discovered_account_ids = {account.id for account in self._identity.accounts} + invalid_accounts = self._filter_accounts - discovered_account_ids + if invalid_accounts: + invalid_str = ", ".join(sorted(invalid_accounts)) + raise CloudflareInvalidAccountError( + file=os.path.basename(__file__), + message=f"Account IDs not found: {invalid_str}.", + ) + self._identity.audited_accounts = [ + account_id + for account_id in self._identity.audited_accounts + if account_id in self._filter_accounts + ] + Provider.set_global_provider(self) @property @@ -105,6 +125,11 @@ class CloudflareProvider(Provider): """Zone filter from --region argument to filter resources.""" return self._filter_zones + @property + def filter_accounts(self) -> set[str] | None: + """Account filter from --account-id argument to restrict scanned accounts.""" + return self._filter_accounts + @property def accounts(self) -> list[CloudflareAccount]: return self._identity.accounts @@ -248,10 +273,23 @@ class CloudflareProvider(Provider): if email: report_lines.append(f"Email: {Fore.YELLOW}{email}{Style.RESET_ALL}") - # Accounts - if self.accounts: - accounts = ", ".join([account.id for account in self.accounts]) - report_lines.append(f"Accounts: {Fore.YELLOW}{accounts}{Style.RESET_ALL}") + # Audited accounts (only the ones that will actually be scanned) + audited_accounts = self.identity.audited_accounts + if audited_accounts: + account_names = { + account.id: account.name for account in self.identity.accounts + } + accounts_str = ", ".join( + ( + f"{account_id} ({account_names[account_id]})" + if account_id in account_names and account_names[account_id] + else account_id + ) + for account_id in audited_accounts + ) + report_lines.append( + f"Audited Accounts: {Fore.YELLOW}{accounts_str}{Style.RESET_ALL}" + ) print_boxes(report_lines, report_title) diff --git a/prowler/providers/cloudflare/lib/arguments/arguments.py b/prowler/providers/cloudflare/lib/arguments/arguments.py index 4e26282023..2947f787e3 100644 --- a/prowler/providers/cloudflare/lib/arguments/arguments.py +++ b/prowler/providers/cloudflare/lib/arguments/arguments.py @@ -5,6 +5,13 @@ def init_parser(self): ) scope_group = cloudflare_parser.add_argument_group("Scope") + scope_group.add_argument( + "--account-id", + nargs="+", + default=None, + metavar="ACCOUNT_ID", + help="Filter scan to specific Cloudflare account IDs. Only zones belonging to these accounts will be scanned.", + ) scope_group.add_argument( "--region", "--filter-region", diff --git a/prowler/providers/cloudflare/models.py b/prowler/providers/cloudflare/models.py index c4eba74521..72df9d1b10 100644 --- a/prowler/providers/cloudflare/models.py +++ b/prowler/providers/cloudflare/models.py @@ -30,7 +30,7 @@ class CloudflareIdentityInfo(BaseModel): email: Optional[str] = None accounts: list[CloudflareAccount] = Field(default_factory=list) audited_accounts: list[str] = Field(default_factory=list) - audited_zones: list[str] = Field(default_factory=list) + audited_zone: list[str] = Field(default_factory=list) class CloudflareOutputOptions(ProviderOutputOptions): diff --git a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/__init__.py b/prowler/providers/cloudflare/services/dns/__init__.py similarity index 100% rename from prowler/providers/cloudflare/services/zones/zones_ssl_strict/__init__.py rename to prowler/providers/cloudflare/services/dns/__init__.py diff --git a/prowler/providers/cloudflare/services/dns/dns_client.py b/prowler/providers/cloudflare/services/dns/dns_client.py new file mode 100644 index 0000000000..70062519db --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_client.py @@ -0,0 +1,4 @@ +from prowler.providers.cloudflare.services.dns.dns_service import DNS +from prowler.providers.common.provider import Provider + +dns_client = DNS(Provider.get_global_provider()) diff --git a/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/__init__.py b/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.metadata.json b/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.metadata.json new file mode 100644 index 0000000000..ff043c2122 --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "dns_record_cname_target_valid", + "CheckTitle": "DNS records pointing to hostnames have valid targets without takeover risk", + "CheckType": [], + "ServiceName": "dns", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "DNSRecord", + "ResourceGroup": "network", + "Description": "**Cloudflare DNS records** (CNAME, MX, NS, SRV) that point to hostnames are assessed for **dangling record** vulnerabilities by checking if the target domain resolves to a valid address, preventing **subdomain takeover**, **mail interception**, and **service hijacking** attacks.", + "Risk": "Dangling **DNS records** pointing to non-existent targets create multiple vulnerabilities.\n- **Confidentiality**: dangling CNAME/NS allows subdomain takeover; dangling MX allows mail interception\n- **Integrity**: attackers can impersonate your organization, intercept emails, or hijack services\n- **Availability**: legitimate services may be disrupted or redirected to attacker-controlled infrastructure", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Identify CNAME, MX, NS, or SRV records with dangling targets\n4. Either update the record to point to a valid target or delete the record\n5. If the target service was decommissioned, remove the DNS record", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove or update **dangling DNS records** to prevent takeover and interception attacks.\n- Regularly audit DNS records when decommissioning services\n- Remove CNAME, MX, NS, and SRV records pointing to deprovisioned resources\n- Monitor for unauthorized changes to DNS records\n- Consider using DNS monitoring tools to detect dangling records", + "Url": "https://hub.prowler.com/checks/cloudflare/dns_record_cname_target_valid" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Subdomain takeover occurs when a CNAME or NS record points to a service that has been deprovisioned, allowing attackers to claim that service and control the subdomain. Similarly, dangling MX records can allow mail interception, and dangling SRV records can expose service discovery vulnerabilities." +} diff --git a/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.py b/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.py new file mode 100644 index 0000000000..31a7d81978 --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.py @@ -0,0 +1,109 @@ +import socket + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client + +# Record types that point to hostnames and can be dangling: +# - CNAME: Alias to another hostname +# - MX: Mail server hostname (dangling = potential mail interception) +# - NS: Nameserver delegation (dangling = subdomain takeover) +# - SRV: Service location hostname +DANGLING_RISK_TYPES = {"CNAME", "MX", "NS", "SRV"} + +# Risk descriptions for each record type +RISK_DESCRIPTIONS = { + "CNAME": "subdomain takeover risk", + "MX": "potential mail interception risk", + "NS": "subdomain delegation takeover risk", + "SRV": "service discovery vulnerability", +} + + +class dns_record_cname_target_valid(Check): + """Ensure that DNS records pointing to hostnames have valid, resolvable targets. + + Dangling DNS records that point to non-existent or unresolvable targets pose + significant security risks. CNAME and NS records can lead to subdomain takeover, + MX records can allow mail interception, and SRV records can expose service + vulnerabilities. Attackers can claim orphaned target resources and serve + malicious content, intercept email, or hijack services under your domain. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the dangling DNS record validation check. + + Iterates through all DNS records that point to hostnames (CNAME, MX, NS, SRV) + and attempts to resolve their targets using DNS lookup. Records pointing to + unresolvable targets are flagged as potential security risks. + + Returns: + A list of CheckReportCloudflare objects with PASS status if the + target resolves successfully, or FAIL status if the target + cannot be resolved (dangling record). + """ + findings = [] + + for record in dns_client.records: + # Check record types that point to hostnames + if record.type not in DANGLING_RISK_TYPES: + continue + + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=record, + ) + + target = self._extract_target(record.type, record.content) + is_valid = self._check_target_resolves(target) + risk_desc = RISK_DESCRIPTIONS.get(record.type, "security risk") + + if is_valid: + report.status = "PASS" + report.status_extended = f"{record.type} record {record.name} points to valid target {target}." + else: + report.status = "FAIL" + report.status_extended = ( + f"{record.type} record {record.name} points to potentially dangling " + f"target {target} - {risk_desc}." + ) + findings.append(report) + + return findings + + def _extract_target(self, record_type: str, content: str) -> str: + """Extract the target hostname from record content. + + Different record types have different content formats: + - CNAME: hostname + - MX: priority hostname (e.g., "10 mail.example.com") + - NS: hostname + - SRV: Cloudflare returns "weight port hostname" (e.g., "5 80 sip.example.com") + """ + if record_type == "MX": + # MX format: "priority hostname" + parts = content.split(None, 1) + return parts[1] if len(parts) > 1 else content + elif record_type == "SRV": + # SRV format from Cloudflare: "weight port hostname" + parts = content.split() + # Target is the last part (hostname) + return parts[-1] if parts else content + else: + # CNAME and NS are just hostnames + return content + + def _check_target_resolves(self, target: str) -> bool: + """Check if target hostname resolves to a valid address.""" + # Remove trailing dot if present + target = target.rstrip(".") + + try: + # Attempt DNS resolution + socket.getaddrinfo(target, None, socket.AF_UNSPEC) + return True + except socket.gaierror: + # DNS resolution failed - potential dangling record + return False + except Exception: + # On any other error, assume valid to avoid false positives + return True diff --git a/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/__init__.py b/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip.metadata.json b/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip.metadata.json new file mode 100644 index 0000000000..4eb3bbb0ad --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "dns_record_no_internal_ip", + "CheckTitle": "DNS records do not expose internal IP addresses", + "CheckType": [], + "ServiceName": "dns", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "DNSRecord", + "ResourceGroup": "network", + "Description": "**Cloudflare DNS records** are assessed for **internal IP exposure** by checking if A or AAAA records point to private, loopback, or reserved IP addresses which could **leak internal network structure**.", + "Risk": "DNS records exposing **internal IP addresses** leak sensitive network information.\n- **Confidentiality**: reveals internal network topology and addressing schemes to attackers\n- **Integrity**: provides reconnaissance data for targeted attacks on internal infrastructure\n- **Availability**: internal IPs in public DNS may indicate misconfiguration affecting service routing", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Identify A/AAAA records pointing to internal IP addresses\n4. Update records to point to public IP addresses or remove if not needed\n5. Use split-horizon DNS if internal resolution is required", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove **internal IP addresses** from public DNS records.\n- Use split-horizon DNS for internal service resolution\n- Ensure DNS records only contain publicly routable IP addresses\n- Review DNS records after network changes or migrations\n- Consider using Cloudflare Access for secure internal service access", + "Url": "https://hub.prowler.com/checks/cloudflare/dns_record_no_internal_ip" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Internal IP ranges include: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (IPv4), fc00::/7 (IPv6 ULA), and loopback addresses. These should not appear in public DNS records." +} diff --git a/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip.py b/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip.py new file mode 100644 index 0000000000..e436c398f4 --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip.py @@ -0,0 +1,73 @@ +import ipaddress + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client + + +class dns_record_no_internal_ip(Check): + """Ensure that DNS records do not expose internal or private IP addresses. + + Public DNS records should only contain publicly routable IP addresses. + Exposing internal, private, loopback, or link-local addresses in DNS records + can leak information about internal network infrastructure, potentially + aiding attackers in reconnaissance and targeted attacks against internal + systems. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the internal IP address exposure check. + + Iterates through all A and AAAA DNS records and checks if they contain + private, loopback, link-local, or reserved IP addresses that should not + be exposed publicly. + + Returns: + A list of CheckReportCloudflare objects with PASS status if the + record points to a public IP address, or FAIL status if it exposes + an internal IP address. + """ + findings = [] + + for record in dns_client.records: + # Only check A and AAAA records + if record.type not in ("A", "AAAA"): + continue + + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=record, + ) + + is_internal = self._is_internal_ip(record.content) + + if not is_internal: + report.status = "PASS" + report.status_extended = ( + f"DNS record {record.name} ({record.type}) points to " + f"public IP address {record.content}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"DNS record {record.name} ({record.type}) exposes " + f"internal IP address {record.content} - information disclosure risk." + ) + findings.append(report) + + return findings + + def _is_internal_ip(self, ip_str: str) -> bool: + """Check if IP address is internal/private.""" + try: + ip = ipaddress.ip_address(ip_str) + # Check for private, loopback, link-local, or reserved addresses + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_unspecified + ) + except ValueError: + # Invalid IP format, assume not internal + return False diff --git a/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/__init__.py b/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard.metadata.json b/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard.metadata.json new file mode 100644 index 0000000000..6560ac46a2 --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "dns_record_no_wildcard", + "CheckTitle": "DNS records do not use wildcard entries", + "CheckType": [], + "ServiceName": "dns", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "DNSRecord", + "ResourceGroup": "network", + "Description": "**Cloudflare DNS records** are assessed for **wildcard usage** by checking if A, AAAA, CNAME, MX, or SRV records use wildcard entries (*.example.com) which can **increase attack surface**, expose unintended services, or allow mail interception.", + "Risk": "**Wildcard DNS records** can expose unintended services and increase attack surface.\n- **Confidentiality**: any subdomain resolves, potentially exposing internal naming conventions; wildcard MX allows mail interception\n- **Integrity**: attackers can access unintended services via arbitrary subdomains\n- **Availability**: wildcard records may route traffic or services not designed for public access", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Identify wildcard DNS records (starting with *.)\n4. Evaluate if the wildcard is necessary for your use case\n5. Replace wildcard records with specific subdomain records where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Avoid using **wildcard DNS records** unless absolutely necessary.\n- Use specific subdomain records instead of wildcards\n- If wildcards are required, ensure the target service handles unknown subdomains securely\n- Document the business justification for any wildcard records\n- Combine with proper web server configuration to reject unknown hosts", + "Url": "https://hub.prowler.com/checks/cloudflare/dns_record_no_wildcard" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Wildcard DNS records (*.example.com) cause any subdomain query to resolve. While useful for some applications, they can expose services unintentionally and make subdomain enumeration easier for attackers. Wildcard MX records can accept mail for any subdomain, and wildcard SRV records can expose services on arbitrary subdomains." +} diff --git a/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard.py b/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard.py new file mode 100644 index 0000000000..424c0b273a --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client + +# Record types where wildcards pose security risks: +# - A, AAAA: Wildcard resolves any subdomain to an IP, exposing services +# - CNAME: Wildcard aliases any subdomain, potential for subdomain takeover +# - MX: Wildcard accepts mail for any subdomain, potential mail interception +# - SRV: Wildcard exposes services on any subdomain +WILDCARD_RISK_TYPES = {"A", "AAAA", "CNAME", "MX", "SRV"} + + +class dns_record_no_wildcard(Check): + """Ensure that wildcard DNS records are not configured for the zone. + + Wildcard DNS records (*.domain.com) match any subdomain that doesn't have + an explicit record, which can unintentionally expose services or create + security risks. Attackers may discover hidden services, and wildcard + certificates combined with wildcard DNS can increase the attack surface + for subdomain takeover vulnerabilities. Wildcard MX records can allow + mail interception for arbitrary subdomains. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the wildcard DNS record check. + + Iterates through all security-relevant DNS records (A, AAAA, CNAME, MX, SRV) + and identifies those configured as wildcard records (starting with *.). + Wildcard records may expose unintended services or create security risks. + + Returns: + A list of CheckReportCloudflare objects with PASS status if the + record is not a wildcard, or FAIL status if it is a wildcard record. + """ + findings = [] + + for record in dns_client.records: + # Check record types where wildcards pose security risks + if record.type not in WILDCARD_RISK_TYPES: + continue + + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=record, + ) + + # Check if record name starts with wildcard + is_wildcard = record.name.startswith("*.") + + if not is_wildcard: + report.status = "PASS" + report.status_extended = f"DNS record {record.name} ({record.type}) is not a wildcard record." + else: + report.status = "FAIL" + report.status_extended = ( + f"DNS record {record.name} ({record.type}) is a wildcard record - " + f"may expose unintended services." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/cloudflare/services/dns/dns_record_proxied/__init__.py b/prowler/providers/cloudflare/services/dns/dns_record_proxied/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied.metadata.json b/prowler/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied.metadata.json new file mode 100644 index 0000000000..d9f21aecfd --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "dns_record_proxied", + "CheckTitle": "Cloudflare proxy is enabled for applicable DNS records", + "CheckType": [], + "ServiceName": "dns", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "DNSRecord", + "ResourceGroup": "network", + "Description": "**Cloudflare DNS records** are assessed for **proxy configuration** by checking if A, AAAA, and CNAME records are proxied through Cloudflare to benefit from **DDoS protection**, **WAF**, and **caching** capabilities.", + "Risk": "Unproxied **DNS records** expose origin server IP addresses directly to the internet.\n- **Confidentiality**: origin IP exposure enables targeted reconnaissance and attacks\n- **Integrity**: direct access to origin bypasses WAF and security controls\n- **Availability**: origin is exposed to DDoS attacks without Cloudflare protection", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/reference/proxied-dns-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. For each A, AAAA, or CNAME record that should be protected\n4. Click Edit and toggle Proxy status to Proxied (orange cloud)\n5. Save the changes and verify traffic flows through Cloudflare", + "Terraform": "```hcl\n# Enable Cloudflare proxy for DNS records\nresource \"cloudflare_record\" \"proxied_record\" {\n zone_id = \"\"\n name = \"www\"\n content = \"192.0.2.1\"\n type = \"A\"\n proxied = true # Critical: enables DDoS protection, WAF, and caching\n}\n```" + }, + "Recommendation": { + "Text": "Enable the **Cloudflare proxy** (orange cloud) for DNS records that should be protected.\n- Proxied records benefit from DDoS protection, WAF, and caching\n- Origin server IP addresses are hidden from public DNS queries\n- Apply defense in depth by combining proxy protection with origin hardening\n- Some record types (MX, TXT) cannot be proxied by design", + "Url": "https://hub.prowler.com/checks/cloudflare/dns_record_proxied" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Only A, AAAA, and CNAME records can be proxied. MX, TXT, and other record types are always DNS-only. Some services may require DNS-only mode for specific use cases." +} diff --git a/prowler/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied.py b/prowler/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied.py new file mode 100644 index 0000000000..92b6f5d473 --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client + +PROXYABLE_TYPES = {"A", "AAAA", "CNAME"} + + +class dns_record_proxied(Check): + """Ensure that DNS records are proxied through Cloudflare. + + Proxying DNS records through Cloudflare hides the origin server's IP address + and provides DDoS protection, WAF capabilities, and performance optimizations. + Non-proxied (DNS-only) records expose the origin IP directly, bypassing + Cloudflare's security features and making the origin vulnerable to direct + attacks. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the DNS record proxy status check. + + Iterates through all proxyable DNS records (A, AAAA, CNAME) and verifies + that they are configured to be proxied through Cloudflare. Non-proxied + records bypass Cloudflare's security and performance features. + + Returns: + A list of CheckReportCloudflare objects with PASS status if the + record is proxied through Cloudflare, or FAIL status if it is + DNS-only (not proxied). + """ + findings = [] + + for record in dns_client.records: + # Only check proxyable record types + if record.type not in PROXYABLE_TYPES: + continue + + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=record, + ) + + if record.proxied: + report.status = "PASS" + report.status_extended = f"DNS record {record.name} ({record.type}) is proxied through Cloudflare." + else: + report.status = "FAIL" + report.status_extended = f"DNS record {record.name} ({record.type}) is not proxied through Cloudflare." + findings.append(report) + + return findings diff --git a/prowler/providers/cloudflare/services/dns/dns_service.py b/prowler/providers/cloudflare/services/dns/dns_service.py new file mode 100644 index 0000000000..bc718f3c5f --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_service.py @@ -0,0 +1,110 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.cloudflare.lib.service.service import CloudflareService + + +class DNS(CloudflareService): + """Retrieve Cloudflare DNS records for all zones.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.records: list["CloudflareDNSRecord"] = [] + self._list_dns_records() + + def _list_dns_records(self) -> None: + """List DNS records for all zones.""" + logger.info("DNS - Listing DNS records...") + try: + # Get zones directly from API to avoid circular dependency with zone_client + zones = self._get_zones() + + for zone_id, zone_name in zones.items(): + seen_record_ids: set[str] = set() + try: + for record in self.client.dns.records.list(zone_id=zone_id): + record_id = getattr(record, "id", None) + # Prevent infinite loop + if record_id in seen_record_ids: + break + seen_record_ids.add(record_id) + + self.records.append( + CloudflareDNSRecord( + id=record_id, + zone_id=zone_id, + zone_name=zone_name, + name=getattr(record, "name", None), + type=getattr(record, "type", None), + content=getattr(record, "content", ""), + ttl=getattr(record, "ttl", None), + proxied=getattr(record, "proxied", False), + ) + ) + except Exception as error: + logger.error( + f"{zone_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones(self) -> dict[str, str]: + """Get zones directly from Cloudflare API. + + Returns: + Dictionary mapping zone_id to zone_name. + """ + zones = {} + audited_accounts = self.provider.identity.audited_accounts + filter_zones = self.provider.filter_zones + seen_zone_ids: set[str] = set() + + try: + for zone in self.client.zones.list(): + zone_id = getattr(zone, "id", None) + # Prevent infinite loop - skip if we've seen this zone + if zone_id in seen_zone_ids: + break + seen_zone_ids.add(zone_id) + + zone_account = getattr(zone, "account", None) + account_id = getattr(zone_account, "id", None) if zone_account else None + + # Filter by audited accounts + if audited_accounts and account_id not in audited_accounts: + continue + + zone_name = getattr(zone, "name", None) + + # Apply zone filter if specified via --region + if ( + filter_zones + and zone_id not in filter_zones + and zone_name not in filter_zones + ): + continue + + zones[zone_id] = zone_name + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return zones + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False diff --git a/prowler/providers/cloudflare/services/firewall/__init__.py b/prowler/providers/cloudflare/services/firewall/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/firewall/firewall_client.py b/prowler/providers/cloudflare/services/firewall/firewall_client.py new file mode 100644 index 0000000000..33b9efae03 --- /dev/null +++ b/prowler/providers/cloudflare/services/firewall/firewall_client.py @@ -0,0 +1,4 @@ +from prowler.providers.cloudflare.services.firewall.firewall_service import Firewall +from prowler.providers.common.provider import Provider + +firewall_client = Firewall(Provider.get_global_provider()) diff --git a/prowler/providers/cloudflare/services/firewall/firewall_service.py b/prowler/providers/cloudflare/services/firewall/firewall_service.py new file mode 100644 index 0000000000..7363829e1b --- /dev/null +++ b/prowler/providers/cloudflare/services/firewall/firewall_service.py @@ -0,0 +1,123 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.cloudflare.lib.service.service import CloudflareService + + +class Firewall(CloudflareService): + """Retrieve Cloudflare firewall rules for all zones.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.rules: list["CloudflareFirewallRule"] = [] + self._list_rulesets() + + def _list_rulesets(self) -> None: + """List firewall rulesets for all zones.""" + logger.info("Firewall - Listing firewall rulesets...") + try: + # Get zones directly from API to avoid circular dependency with zone_client + zones = self._get_zones() + + for zone_id, zone_name in zones.items(): + try: + # Get all rulesets for the zone + rulesets = self.client.rulesets.list(zone_id=zone_id) + for ruleset in rulesets: + ruleset_id = getattr(ruleset, "id", None) + phase = getattr(ruleset, "phase", None) + if not ruleset_id: + continue + + # Get rules within each ruleset + try: + ruleset_detail = self.client.rulesets.get( + ruleset_id=ruleset_id, zone_id=zone_id + ) + rules = getattr(ruleset_detail, "rules", []) or [] + for rule in rules: + self.rules.append( + CloudflareFirewallRule( + id=getattr(rule, "id", None), + zone_id=zone_id, + zone_name=zone_name, + ruleset_id=ruleset_id, + phase=phase, + action=getattr(rule, "action", None), + expression=getattr(rule, "expression", None), + description=getattr(rule, "description", None), + enabled=getattr(rule, "enabled", True), + ) + ) + except Exception as error: + logger.debug( + f"{zone_id} ruleset {ruleset_id} -- {error.__class__.__name__}: {error}" + ) + except Exception as error: + logger.error( + f"{zone_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones(self) -> dict[str, str]: + """Get zones directly from Cloudflare API. + + Returns: + Dictionary mapping zone_id to zone_name. + """ + zones = {} + audited_accounts = self.provider.identity.audited_accounts + filter_zones = self.provider.filter_zones + seen_zone_ids: set[str] = set() + + try: + for zone in self.client.zones.list(): + zone_id = getattr(zone, "id", None) + # Prevent infinite loop - skip if we've seen this zone + if zone_id in seen_zone_ids: + break + seen_zone_ids.add(zone_id) + + zone_account = getattr(zone, "account", None) + account_id = getattr(zone_account, "id", None) if zone_account else None + + # Filter by audited accounts + if audited_accounts and account_id not in audited_accounts: + continue + + zone_name = getattr(zone, "name", None) + + # Apply zone filter if specified via --region + if ( + filter_zones + and zone_id not in filter_zones + and zone_name not in filter_zones + ): + continue + + zones[zone_id] = zone_name + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return zones + + +class CloudflareFirewallRule(BaseModel): + """Cloudflare firewall rule representation.""" + + id: Optional[str] = None + zone_id: str + zone_name: str + ruleset_id: Optional[str] = None + phase: Optional[str] = None + action: Optional[str] = None + expression: Optional[str] = None + description: Optional[str] = None + enabled: bool = True diff --git a/prowler/providers/cloudflare/services/zone/__init__.py b/prowler/providers/cloudflare/services/zone/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled.metadata.json new file mode 100644 index 0000000000..4f78f0067d --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_always_online_disabled", + "CheckTitle": "Always Online is disabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Always Online** configuration by checking if it is disabled to prevent serving **stale cached content** when the origin server is unavailable, which could expose outdated or sensitive information.", + "Risk": "With **Always Online** enabled, Cloudflare serves cached pages when the origin is unavailable.\n- **Confidentiality**: stale cache may expose sensitive information that was subsequently removed\n- **Integrity**: outdated content may contain incorrect or superseded information\n- **Availability**: reliance on cached content masks origin failures requiring attention", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/cache/how-to/always-online/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Caching > Configuration\n3. Scroll to Always Online\n4. Toggle the setting to Off\n5. Implement proper high availability through redundant origins or load balancing", + "Terraform": "```hcl\n# Disable Always Online to prevent serving stale cached content\nresource \"cloudflare_zone_settings_override\" \"always_online\" {\n zone_id = \"\"\n settings {\n always_online = \"off\" # Critical: prevents serving potentially stale or sensitive cached content\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disable **Always Online** and implement proper high availability solutions.\n- Use redundant origins or load balancing for genuine high availability\n- Stale cached content may contain outdated security information\n- Origin failures should be detected and addressed, not masked\n- Consider Cloudflare Workers for custom failover logic if needed", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_always_online_disabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Always Online is a legacy feature that serves cached copies of pages when the origin is unreachable. Modern high availability should use redundant origins or failover configurations." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled.py b/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled.py new file mode 100644 index 0000000000..f04d2829fc --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled.py @@ -0,0 +1,45 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_always_online_disabled(Check): + """Ensure that Always Online is disabled for Cloudflare zones. + + Always Online serves stale cached content when the origin server is unavailable. + While this maintains availability, it can expose outdated or potentially sensitive + information. For security-sensitive applications, it is recommended to disable + this feature to ensure users always receive current, accurate content or an + appropriate error message when the origin is down. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Always Online disabled check. + + Iterates through all Cloudflare zones and verifies that Always Online + is disabled. When enabled, this feature may serve stale cached content + that could contain outdated or sensitive information. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Always + Online is disabled, or FAIL status if it is enabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + always_online = (zone.settings.always_online or "").lower() + + if always_online == "off": + report.status = "PASS" + report.status_extended = ( + f"Always Online is disabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Always Online is enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.metadata.json new file mode 100644 index 0000000000..2625a2f6e1 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_automatic_https_rewrites_enabled", + "CheckTitle": "Automatic HTTPS Rewrites is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Automatic HTTPS Rewrites** configuration by checking if it is enabled to automatically rewrite insecure HTTP links to HTTPS, resolving **mixed content issues** and enhancing site security.", + "Risk": "Without **Automatic HTTPS Rewrites**, pages may contain mixed content where HTTP resources load over HTTPS pages.\n- **Confidentiality**: insecure resources can be intercepted and modified by attackers\n- **Integrity**: browsers block or warn about mixed content, indicating potential tampering\n- **User Experience**: security warnings degrade trust and some browsers block mixed content entirely", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/automatic-https-rewrites/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to Automatic HTTPS Rewrites\n4. Toggle the setting to On\n5. Verify that your site loads correctly without mixed content warnings", + "Terraform": "```hcl\n# Enable Automatic HTTPS Rewrites to fix mixed content\nresource \"cloudflare_zone_settings_override\" \"https_rewrites\" {\n zone_id = \"\"\n settings {\n automatic_https_rewrites = \"on\" # Critical: automatically rewrites HTTP URLs to HTTPS\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Automatic HTTPS Rewrites** as part of a comprehensive HTTPS strategy.\n- Automatically fixes mixed content by rewriting HTTP URLs to HTTPS\n- Combine with **Always Use HTTPS** and **HSTS** for defense in depth\n- Works best when all resources are available over HTTPS\n- Monitor for resources that cannot be rewritten and fix them at the source", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_automatic_https_rewrites_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This feature works best when combined with Always Use HTTPS to ensure the entire site is served over HTTPS. Some resources may not be rewritable if they don't support HTTPS." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.py b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.py new file mode 100644 index 0000000000..6dc491062f --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.py @@ -0,0 +1,45 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_automatic_https_rewrites_enabled(Check): + """Ensure that Automatic HTTPS Rewrites is enabled for Cloudflare zones. + + Automatic HTTPS Rewrites automatically rewrites insecure HTTP links to HTTPS, + resolving mixed content issues and enhancing site security. This feature scans + HTML responses and rewrites HTTP URLs to HTTPS for resources that are known to + be available over a secure connection, preventing mixed content warnings. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Automatic HTTPS Rewrites enabled check. + + Iterates through all Cloudflare zones and verifies that Automatic HTTPS + Rewrites is enabled. This setting automatically fixes mixed content issues + by rewriting HTTP links to HTTPS where possible. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Automatic + HTTPS Rewrites is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + automatic_https_rewrites = ( + zone.settings.automatic_https_rewrites or "" + ).lower() + if automatic_https_rewrites == "on": + report.status = "PASS" + report.status_extended = ( + f"Automatic HTTPS Rewrites is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Automatic HTTPS Rewrites is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled.metadata.json new file mode 100644 index 0000000000..090413bb87 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_bot_fight_mode_enabled", + "CheckTitle": "Bot Fight Mode is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Bot Fight Mode** configuration by checking if it is enabled to detect and mitigate **automated bot traffic** targeting the zone through browser integrity checks.", + "Risk": "Without **Bot Fight Mode**, zones are vulnerable to automated attacks.\n- **Confidentiality**: web scraping bots can harvest sensitive data from your site\n- **Integrity**: credential stuffing attacks can compromise user accounts\n- **Availability**: bot traffic can overwhelm resources causing service degradation", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/bots/get-started/free/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > Bots\n3. Enable Bot Fight Mode\n4. Monitor bot analytics to fine-tune protection\n5. Consider combining with rate limiting for comprehensive protection", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Bot Fight Mode** as part of a layered bot management strategy.\n- Detects and challenges automated bot traffic\n- Protects against web scraping and credential stuffing\n- Combine with rate limiting and WAF rules for comprehensive protection\n- Monitor bot analytics to understand traffic patterns", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_bot_fight_mode_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Bot Fight Mode is a free feature that uses browser integrity checks to detect and challenge automated traffic. For more advanced bot management, consider Cloudflare's paid Bot Management product." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled.py b/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled.py new file mode 100644 index 0000000000..375bb47cb6 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_bot_fight_mode_enabled(Check): + """Ensure that Bot Fight Mode is enabled for Cloudflare zones. + + Bot Fight Mode is a free Cloudflare feature that detects and mitigates automated + bot traffic. It uses JavaScript challenges and behavioral analysis to identify + bots and block malicious automated traffic, protecting against scraping, spam, + credential stuffing, and other automated attacks. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Bot Fight Mode enabled check. + + Iterates through all Cloudflare zones and verifies that Bot Fight Mode + is enabled via the Bot Management API. This feature helps identify and + block malicious bot traffic. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Bot Fight + Mode is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + if zone.settings.bot_fight_mode_enabled: + report.status = "PASS" + report.status_extended = ( + f"Bot Fight Mode is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Bot Fight Mode is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled.metadata.json new file mode 100644 index 0000000000..189ca7ad5d --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_browser_integrity_check_enabled", + "CheckTitle": "Cloudflare Zone Browser Integrity Check Is Enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Browser Integrity Check** configuration by verifying that HTTP headers are analyzed to identify requests from bots or clients with missing/invalid browser signatures.", + "Risk": "Without **Browser Integrity Check**, malformed or suspicious requests reach the origin.\n- **Confidentiality**: basic bots can access and scrape content without challenge\n- **Integrity**: requests with invalid headers may exploit application vulnerabilities\n- **Availability**: automated traffic without browser signatures consumes resources", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/tools/browser-integrity-check/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > Settings\n3. Enable Browser Integrity Check\n4. This feature is enabled by default on most Cloudflare plans", + "Terraform": "```hcl\n# Enable Browser Integrity Check\nresource \"cloudflare_zone_settings_override\" \"browser_check\" {\n zone_id = \"\"\n settings {\n browser_check = \"on\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Browser Integrity Check** to filter basic bot traffic.\n- Validates HTTP headers to identify non-browser requests\n- Challenges requests with missing or invalid browser signatures\n- Enabled by default on most Cloudflare plans\n- Low impact on legitimate users with standard browsers", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_browser_integrity_check_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Browser Integrity Check is enabled by default on most Cloudflare plans. It provides basic protection against requests with invalid or missing browser headers." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled.py b/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled.py new file mode 100644 index 0000000000..5022658c32 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_browser_integrity_check_enabled(Check): + """Ensure that Browser Integrity Check is enabled for Cloudflare zones. + + Browser Integrity Check analyzes HTTP headers to identify requests from + bots or clients with missing/invalid browser signatures. It challenges + suspicious requests that don't have valid browser characteristics, + protecting against basic automated attacks and malformed requests. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Browser Integrity Check enabled check. + + Iterates through all Cloudflare zones and verifies that Browser + Integrity Check is enabled. This feature validates browser headers + to filter out basic bot traffic. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Browser + Integrity Check is enabled, or FAIL status if it is disabled. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + browser_check = (zone.settings.browser_check or "").lower() + if browser_check == "on": + report.status = "PASS" + report.status_extended = ( + f"Browser Integrity Check is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Browser Integrity Check is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/__init__.py b/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured.metadata.json b/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured.metadata.json new file mode 100644 index 0000000000..b09b4c55a0 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_challenge_passage_configured", + "CheckTitle": "Cloudflare Zone Challenge Passage Is Configured Between 15 and 45 Minutes", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Challenge Passage** (challenge TTL) configuration by checking if it is set between **15 minutes** and **45 minutes** to balance security with user experience.", + "Risk": "Improperly configured **Challenge Passage** can impact security or user experience.\n- **Confidentiality**: TTL set too long may allow attackers extended access after passing initial challenge\n- **Integrity**: security controls become less effective with overly permissive TTL settings\n- **Availability**: TTL set too short causes excessive challenges degrading user experience", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/tools/challenge-passage/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > Settings\n3. Scroll to Challenge Passage\n4. Set the value between 15 and 45 minutes\n5. The default value of 30 minutes is recommended for most use cases", + "Terraform": "```hcl\n# Configure Challenge Passage between 15-45 minutes\nresource \"cloudflare_zone_settings_override\" \"challenge_passage\" {\n zone_id = \"\"\n settings {\n challenge_ttl = 1800 # 30 minutes - recommended default\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure **Challenge Passage** between 15 and 45 minutes.\n- Values below 15 minutes may frustrate legitimate users with excessive challenges\n- Values above 45 minutes give attackers too much time after passing challenges\n- The default Cloudflare value of 30 minutes is recommended for most use cases\n- Adjust based on your specific threat model and user experience requirements", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_challenge_passage_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Challenge Passage determines how long a visitor who passes a challenge can access the site without being challenged again. Setting this value too low can frustrate legitimate users with excessive security challenges, while setting it too high reduces security effectiveness. The default value is 30 minutes." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured.py b/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured.py new file mode 100644 index 0000000000..59cabdadb5 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured.py @@ -0,0 +1,45 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_challenge_passage_configured(Check): + """Ensure that Challenge Passage is configured between 15 and 45 minutes for Cloudflare zones. + + Challenge Passage (Challenge TTL) determines how long a visitor who has passed + a security challenge can access the site before being challenged again. A value + between 15 and 45 minutes balances security with user experience. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Challenge Passage configured check. + + Iterates through all Cloudflare zones and verifies that Challenge Passage + is set between 15 and 45 minutes. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Challenge + Passage is between 15 and 45 minutes, or FAIL status otherwise. + """ + findings = [] + min_minutes = 15 + max_minutes = 45 + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + # API returns seconds, convert to minutes + challenge_ttl_minutes = zone.settings.challenge_ttl // 60 + + if min_minutes <= challenge_ttl_minutes <= max_minutes: + report.status = "PASS" + report.status_extended = f"Challenge Passage is set to {challenge_ttl_minutes} minutes for zone {zone.name}." + else: + report.status = "FAIL" + report.status_extended = ( + f"Challenge Passage is set to {challenge_ttl_minutes} minutes for zone {zone.name} " + f"(recommended: between {min_minutes} and {max_minutes} minutes)." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_client.py b/prowler/providers/cloudflare/services/zone/zone_client.py new file mode 100644 index 0000000000..c1f6906576 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_client.py @@ -0,0 +1,4 @@ +from prowler.providers.cloudflare.services.zone.zone_service import Zone +from prowler.providers.common.provider import Provider + +zone_client = Zone(Provider.get_global_provider()) diff --git a/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled.metadata.json new file mode 100644 index 0000000000..cccc93efee --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_development_mode_disabled", + "CheckTitle": "Development mode is disabled for production zones", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Development Mode** configuration by checking if it is disabled to ensure **caching**, **security features**, and **performance optimizations** are active in production environments.", + "Risk": "With **Development Mode** enabled, Cloudflare bypasses caching and some optimizations.\n- **Confidentiality**: some security features may be affected or bypassed\n- **Integrity**: performance optimizations are disabled impacting site reliability\n- **Availability**: origin server is exposed to increased load without caching protection", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/cache/reference/development-mode/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Caching > Configuration\n3. Scroll to Development Mode\n4. Ensure Development Mode is Off\n5. Note: Development Mode auto-expires after 3 hours if left enabled", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Development Mode** for production environments.\n- Use only temporarily during active development when cache bypassing is necessary\n- Development Mode auto-expires after 3 hours\n- Ensure it is manually disabled after development work completes\n- Consider using cache purge or page rules for targeted cache invalidation instead", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_development_mode_disabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Development Mode temporarily suspends Cloudflare caching and minification. It auto-expires after 3 hours to prevent accidental prolonged use." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled.py b/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled.py new file mode 100644 index 0000000000..581105eab0 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_development_mode_disabled(Check): + """Ensure that Development Mode is disabled for production Cloudflare zones. + + Development Mode temporarily bypasses Cloudflare's caching and performance + optimizations, serving content directly from the origin server. While useful + for testing changes, it should be disabled in production to maintain caching, + security features, and performance optimizations. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Development Mode disabled check. + + Iterates through all Cloudflare zones and verifies that Development Mode + is disabled. When enabled, this mode bypasses caching and can impact + performance and security. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Development + Mode is disabled, or FAIL status if it is enabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + dev_mode = (zone.settings.development_mode or "").lower() + if dev_mode == "off" or not dev_mode: + report.status = "PASS" + report.status_extended = ( + f"Development mode is disabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Development mode is enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.metadata.json similarity index 92% rename from prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.metadata.json rename to prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.metadata.json index 0c3056dde6..a72f7a6fd7 100644 --- a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.metadata.json @@ -1,13 +1,14 @@ { "Provider": "cloudflare", - "CheckID": "zones_dnssec_enabled", + "CheckID": "zone_dnssec_enabled", "CheckTitle": "DNSSEC is enabled", "CheckType": [], - "ServiceName": "zones", + "ServiceName": "zone", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **DNSSEC** configuration by checking if it is enabled to **cryptographically sign DNS responses** and protect against DNS spoofing and cache poisoning attacks.", "Risk": "Without **DNSSEC**, DNS responses can be spoofed or modified by attackers.\n- **Confidentiality**: users can be redirected to malicious sites that harvest credentials\n- **Integrity**: DNS hijacking enables man-in-the-middle attacks and content modification\n- **Availability**: cache poisoning can cause denial of service by directing traffic to non-existent servers", "RelatedUrl": "", @@ -23,7 +24,7 @@ }, "Recommendation": { "Text": "Enable **DNSSEC** and ensure **DS records** are properly configured at your domain registrar.\n- DNSSEC provides cryptographic authenticity for DNS responses\n- After enabling in Cloudflare, you must add the DS record at your registrar\n- Use online DNSSEC validators to verify correct configuration", - "Url": "https://hub.prowler.com/checks/cloudflare/zones_dnssec_enabled" + "Url": "https://hub.prowler.com/checks/cloudflare/zone_dnssec_enabled" } }, "Categories": [ diff --git a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.py b/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.py similarity index 89% rename from prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.py rename to prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.py index 9dcf32f14e..6e806906bf 100644 --- a/prowler/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled.py +++ b/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.py @@ -1,8 +1,8 @@ from prowler.lib.check.models import Check, CheckReportCloudflare -from prowler.providers.cloudflare.services.zones.zones_client import zones_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client -class zones_dnssec_enabled(Check): +class zone_dnssec_enabled(Check): """Ensure that DNSSEC is enabled for Cloudflare zones. DNSSEC (Domain Name System Security Extensions) adds cryptographic signatures @@ -23,7 +23,7 @@ class zones_dnssec_enabled(Check): is active, or FAIL status if DNSSEC is not enabled for the zone. """ findings = [] - for zone in zones_client.zones.values(): + for zone in zone_client.zones.values(): report = CheckReportCloudflare( metadata=self.metadata(), resource=zone, diff --git a/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.metadata.json new file mode 100644 index 0000000000..be8b51f627 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_email_obfuscation_enabled", + "CheckTitle": "Email Obfuscation is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Email Obfuscation** (Scrape Shield) configuration by checking if it is enabled to protect email addresses on the website from **automated harvesting** by bots and spammers.", + "Risk": "Without **Email Obfuscation**, email addresses displayed on your website can be harvested by bots.\n- **Confidentiality**: harvested emails become targets for spam and phishing campaigns\n- **Integrity**: employees may fall victim to targeted social engineering attacks\n- **Availability**: increased spam volume can overwhelm email systems", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Scrape Shield (or Security > Settings in newer UI)\n3. Scroll to Email Address Obfuscation\n4. Toggle the setting to On\n5. Verify that email addresses still work correctly for human visitors", + "Terraform": "```hcl\n# Enable Email Obfuscation to protect against email harvesting\nresource \"cloudflare_zone_settings_override\" \"email_obfuscation\" {\n zone_id = \"\"\n settings {\n email_obfuscation = \"on\" # Critical: hides email addresses from automated scrapers\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Email Obfuscation** as part of anti-scraping protections.\n- Automatically encodes email addresses to prevent bot harvesting\n- Email addresses remain visible and clickable for human visitors\n- Works with mailto: links and plain text email addresses\n- Part of the Scrape Shield feature set for comprehensive protection", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_email_obfuscation_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Email Obfuscation automatically hides email addresses from bots while keeping them visible and clickable for human visitors. May not work with JavaScript-rendered email addresses." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.py b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.py new file mode 100644 index 0000000000..4009378c82 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_email_obfuscation_enabled(Check): + """Ensure that Email Obfuscation is enabled for Cloudflare zones. + + Email Obfuscation is part of Cloudflare's Scrape Shield suite that protects + email addresses displayed on websites from automated harvesting by bots and + spammers. It encrypts email addresses in the HTML source while keeping them + visible to human visitors, reducing spam and protecting user privacy. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Email Obfuscation enabled check. + + Iterates through all Cloudflare zones and verifies that Email Obfuscation + is enabled. This feature helps prevent email harvesting by obfuscating + email addresses in the page source. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Email + Obfuscation is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + email_obfuscation = (zone.settings.email_obfuscation or "").lower() + if email_obfuscation == "on": + report.status = "PASS" + report.status_extended = ( + f"Email Obfuscation is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Email Obfuscation is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/__init__.py b/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured.metadata.json b/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured.metadata.json new file mode 100644 index 0000000000..a1e3368ac3 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_firewall_blocking_rules_configured", + "CheckTitle": "Cloudflare Zone Firewall Rules Use Blocking Actions to Protect Against Threats", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **firewall blocking rules** by checking if custom rules use block, challenge, js_challenge, or managed_challenge actions to actively protect against threats rather than only logging.", + "Risk": "Firewall rules configured only for **logging** provide visibility but no protection.\n- **Confidentiality**: malicious traffic can access and exfiltrate sensitive data\n- **Integrity**: application exploits can modify data without being blocked\n- **Availability**: credential stuffing and abuse attacks reach the origin unimpeded", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/custom-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > WAF > Custom rules\n3. Review existing rules and their actions\n4. Update rules to use blocking actions (Block, Challenge, JS Challenge, Managed Challenge)\n5. Test rules in log mode first, then enable blocking actions", + "Terraform": "```hcl\n# Configure firewall rule with blocking action\nresource \"cloudflare_ruleset\" \"blocking_rule\" {\n zone_id = \"\"\n name = \"Block malicious requests\"\n kind = \"zone\"\n phase = \"http_request_firewall_custom\"\n rules {\n action = \"block\" # Actively blocks matching traffic\n expression = \"(ip.geoip.country eq \\\"XX\\\")\"\n description = \"Block traffic from high-risk country\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure **firewall rules** with blocking actions to enforce security policies.\n- Use challenge actions for suspicious traffic to verify human visitors\n- Use block actions for known malicious patterns and high-risk sources\n- Test rules in log mode before enabling blocking to avoid false positives\n- Follow the principle of least privilege in rule configuration", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_firewall_blocking_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Blocking actions include: block, challenge, js_challenge, managed_challenge. Log-only rules provide visibility but do not prevent attacks." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured.py b/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured.py new file mode 100644 index 0000000000..0a7c894792 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured.py @@ -0,0 +1,53 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + +BLOCKING_ACTIONS = {"block", "challenge", "js_challenge", "managed_challenge"} + + +class zone_firewall_blocking_rules_configured(Check): + """Ensure that firewall rules with blocking actions are configured for Cloudflare zones. + + Firewall rules should use blocking actions (block, challenge, js_challenge, + managed_challenge) to actively protect against threats rather than only logging + traffic. Without blocking actions, malicious requests can reach the origin server + and potentially compromise the application's security. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the firewall blocking rules configured check. + + Iterates through all Cloudflare zones and verifies that at least one + firewall rule exists with a blocking action. Blocking actions include + block, challenge, js_challenge, and managed_challenge. + + Returns: + A list of CheckReportCloudflare objects with PASS status if blocking + rules are configured, or FAIL status if no blocking rules exist. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # Find blocking rules for this zone + blocking_rules = [ + rule for rule in zone.firewall_rules if rule.action in BLOCKING_ACTIONS + ] + + if blocking_rules: + report.status = "PASS" + report.status_extended = ( + f"Zone {zone.name} has firewall rules with blocking actions " + f"({len(blocking_rules)} rule(s))." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Zone {zone.name} has no firewall rules with blocking actions." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled.metadata.json new file mode 100644 index 0000000000..2bf9985523 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_hotlink_protection_enabled", + "CheckTitle": "Hotlink Protection is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Hotlink Protection** (Scrape Shield) configuration by checking if it is enabled to prevent other websites from directly linking to **images and media**, consuming bandwidth without authorization.", + "Risk": "Without **Hotlink Protection**, external websites can embed your media directly.\n- **Confidentiality**: content may be used without proper attribution or permission\n- **Integrity**: unauthorized use of media may misrepresent your brand\n- **Availability**: bandwidth theft increases costs and may degrade performance", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/tools/scrape-shield/hotlink-protection/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Scrape Shield (or Security > Settings in newer UI)\n3. Scroll to Hotlink Protection\n4. Toggle the setting to On\n5. Review allowed referrers if legitimate integrations require access", + "Terraform": "```hcl\n# Enable Hotlink Protection to prevent bandwidth theft\nresource \"cloudflare_zone_settings_override\" \"hotlink_protection\" {\n zone_id = \"\"\n settings {\n hotlink_protection = \"on\" # Blocks unauthorized embedding of your media resources\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Hotlink Protection** to prevent unauthorized embedding of your media.\n- Blocks requests to images when HTTP referer does not match your domain\n- Reduces bandwidth costs from unauthorized embedding\n- Review allowed referrers for legitimate integrations\n- Part of the Scrape Shield feature set for comprehensive protection", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_hotlink_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Hotlink Protection blocks requests to images when the HTTP referer does not match your domain. May need configuration for legitimate third-party embedding use cases." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled.py b/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled.py new file mode 100644 index 0000000000..40864d1ab1 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_hotlink_protection_enabled(Check): + """Ensure that Hotlink Protection is enabled for Cloudflare zones. + + Hotlink Protection is part of Cloudflare's Scrape Shield suite that prevents + other websites from directly linking to images, videos, and other media files, + which consumes bandwidth without authorization. It blocks requests where the + HTTP referer does not match your domain. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Hotlink Protection enabled check. + + Iterates through all Cloudflare zones and verifies that Hotlink Protection + is enabled. This feature prevents bandwidth theft by blocking unauthorized + embedding of your media on external sites. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Hotlink + Protection is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + hotlink_protection = (zone.settings.hotlink_protection or "").lower() + if hotlink_protection == "on": + report.status = "PASS" + report.status_extended = ( + f"Hotlink Protection is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Hotlink Protection is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.metadata.json similarity index 94% rename from prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.metadata.json rename to prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.metadata.json index 80d466e408..16888fb30b 100644 --- a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.metadata.json @@ -1,13 +1,14 @@ { "Provider": "cloudflare", - "CheckID": "zones_hsts_enabled", + "CheckID": "zone_hsts_enabled", "CheckTitle": "HSTS is enabled with recommended max-age and includes subdomains", "CheckType": [], - "ServiceName": "zones", + "ServiceName": "zone", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **HTTP Strict Transport Security (HSTS)** by checking if it is enabled with a `max-age` of at least **6 months** (15768000 seconds) and **includes subdomains** to instruct browsers to always use HTTPS across the entire domain.", "Risk": "Without **HSTS**, browsers may initially connect over HTTP before redirecting to HTTPS.\n- **Confidentiality**: creates a window for SSL stripping attacks where attackers downgrade connections to unencrypted HTTP\n- **Integrity**: first request can be intercepted and modified before HTTPS redirect\n- **Session hijacking**: cookies and credentials may be captured during initial HTTP request", "RelatedUrl": "", @@ -23,7 +24,7 @@ }, "Recommendation": { "Text": "Enable **HSTS** with at least a **6-month max-age** (12 months recommended).\n- Verify all resources work over HTTPS before enabling\n- Enable **include_subdomains** to protect all subdomains\n- Consider **HSTS preloading** for maximum protection against SSL stripping attacks\n- Test thoroughly as HSTS cannot be easily disabled once deployed", - "Url": "https://hub.prowler.com/checks/cloudflare/zones_hsts_enabled" + "Url": "https://hub.prowler.com/checks/cloudflare/zone_hsts_enabled" } }, "Categories": [ diff --git a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.py b/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.py similarity index 93% rename from prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.py rename to prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.py index cfe98e76ac..5cd0c941f8 100644 --- a/prowler/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled.py +++ b/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.py @@ -1,8 +1,8 @@ from prowler.lib.check.models import Check, CheckReportCloudflare -from prowler.providers.cloudflare.services.zones.zones_client import zones_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client -class zones_hsts_enabled(Check): +class zone_hsts_enabled(Check): """Ensure that HSTS is enabled with secure settings for Cloudflare zones. HTTP Strict Transport Security (HSTS) forces browsers to only connect via @@ -29,7 +29,7 @@ class zones_hsts_enabled(Check): # Recommended minimum max-age is 6 months (15768000 seconds) recommended_max_age = 15768000 - for zone in zones_client.zones.values(): + for zone in zone_client.zones.values(): report = CheckReportCloudflare( metadata=self.metadata(), resource=zone, diff --git a/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.metadata.json similarity index 87% rename from prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.metadata.json rename to prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.metadata.json index 11263719a3..c529a98d6a 100644 --- a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.metadata.json @@ -1,19 +1,19 @@ { "Provider": "cloudflare", - "CheckID": "zones_https_redirect_enabled", + "CheckID": "zone_https_redirect_enabled", "CheckTitle": "Always Use HTTPS is enabled", "CheckType": [], - "ServiceName": "zones", + "ServiceName": "zone", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **Always Use HTTPS** setting by checking if it is enabled to automatically redirect all **HTTP requests to HTTPS**, enforcing encrypted transport for all visitors.", "Risk": "Without **automatic HTTPS redirects**, users may access resources over unencrypted HTTP.\n- **Confidentiality**: traffic can be intercepted and read by attackers on the network path\n- **Integrity**: HTTP responses can be modified in transit (content injection, malware insertion)\n- **Authentication**: session cookies and credentials may be transmitted in plaintext", "RelatedUrl": "", "AdditionalURLs": [ - "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/", - "https://developers.cloudflare.com/terraform/tutorial/configure-https-settings/" + "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/" ], "Remediation": { "Code": { @@ -24,7 +24,7 @@ }, "Recommendation": { "Text": "Enable **Always Use HTTPS** to enforce encrypted connections for all visitors.\n- Combine with **HSTS** to prevent SSL stripping attacks\n- Ensure all resources (images, scripts, stylesheets) are served over HTTPS\n- Test for mixed content warnings before enabling", - "Url": "https://hub.prowler.com/checks/cloudflare/zones_https_redirect_enabled" + "Url": "https://hub.prowler.com/checks/cloudflare/zone_https_redirect_enabled" } }, "Categories": [ diff --git a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.py b/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.py similarity index 90% rename from prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.py rename to prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.py index 8bae20941b..9585f3a191 100644 --- a/prowler/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled.py +++ b/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.py @@ -1,8 +1,8 @@ from prowler.lib.check.models import Check, CheckReportCloudflare -from prowler.providers.cloudflare.services.zones.zones_client import zones_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client -class zones_https_redirect_enabled(Check): +class zone_https_redirect_enabled(Check): """Ensure that Always Use HTTPS redirect is enabled for Cloudflare zones. The Always Use HTTPS setting automatically redirects all HTTP requests to @@ -24,7 +24,7 @@ class zones_https_redirect_enabled(Check): setting is disabled for the zone. """ findings = [] - for zone in zones_client.zones.values(): + for zone in zone_client.zones.values(): report = CheckReportCloudflare( metadata=self.metadata(), resource=zone, diff --git a/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled.metadata.json new file mode 100644 index 0000000000..7900f08272 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_ip_geolocation_enabled", + "CheckTitle": "IP Geolocation is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **IP Geolocation** configuration by checking if it is enabled to add the **CF-IPCountry header** to requests, enabling geographic-based access controls, firewall rules, and analytics.", + "Risk": "Without **IP Geolocation**, geographic-based security controls cannot be implemented.\n- **Confidentiality**: unable to restrict access from high-risk regions\n- **Integrity**: cannot enforce geographic data residency requirements\n- **Availability**: limited visibility into traffic origins for threat analysis", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/network/ip-geolocation/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Network\n3. Scroll to IP Geolocation\n4. Toggle the setting to On\n5. Use the CF-IPCountry header in your firewall rules or application logic", + "Terraform": "```hcl\n# Enable IP Geolocation for geographic-based security controls\nresource \"cloudflare_zone_settings_override\" \"ip_geolocation\" {\n zone_id = \"\"\n settings {\n ip_geolocation = \"on\" # Adds CF-IPCountry header for geo-based controls\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **IP Geolocation** to support geographic-based security policies.\n- Adds CF-IPCountry header to all requests\n- Enables geo-blocking rules in firewall configuration\n- Provides visibility into traffic origins for threat analysis\n- Essential for geographic data residency compliance", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_ip_geolocation_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "IP Geolocation is essential for implementing geo-blocking or country-specific security rules. The CF-IPCountry header contains ISO 3166-1 Alpha 2 country codes." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled.py b/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled.py new file mode 100644 index 0000000000..b5fb1aa301 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled.py @@ -0,0 +1,44 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_ip_geolocation_enabled(Check): + """Ensure that IP Geolocation is enabled for Cloudflare zones. + + IP Geolocation adds the CF-IPCountry header to all requests, containing the + two-letter country code of the visitor's location. This enables geographic-based + access controls, firewall rules, content customization, and analytics based on + visitor location. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the IP Geolocation enabled check. + + Iterates through all Cloudflare zones and verifies that IP Geolocation + is enabled. This feature adds geographic information to requests for + enhanced security controls and analytics. + + Returns: + A list of CheckReportCloudflare objects with PASS status if IP + Geolocation is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + ip_geolocation = (zone.settings.ip_geolocation or "").lower() + + if ip_geolocation == "on": + report.status = "PASS" + report.status_extended = ( + f"IP Geolocation is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"IP Geolocation is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/__init__.py b/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.metadata.json b/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.metadata.json similarity index 92% rename from prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.metadata.json rename to prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.metadata.json index 26ca76e0c8..af89341222 100644 --- a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.metadata.json @@ -1,13 +1,14 @@ { "Provider": "cloudflare", - "CheckID": "zones_min_tls_version_secure", + "CheckID": "zone_min_tls_version_secure", "CheckTitle": "Minimum TLS version is set to 1.2 or higher", "CheckType": [], - "ServiceName": "zones", + "ServiceName": "zone", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **minimum TLS version** configuration by checking if the version is set to at least `TLS 1.2` to ensure connections use **secure, modern cryptographic protocols**.", "Risk": "Allowing **legacy TLS versions** (1.0, 1.1) exposes connections to known protocol vulnerabilities.\n- **Confidentiality**: BEAST, POODLE, and weak cipher suites can be exploited for traffic decryption\n- **Compliance**: TLS 1.0/1.1 are deprecated by PCI-DSS, NIST, and major browsers\n- **Integrity**: downgrade attacks can force weaker encryption that is susceptible to tampering", "RelatedUrl": "", @@ -24,7 +25,7 @@ }, "Recommendation": { "Text": "Set **minimum TLS version** to `1.2` or higher.\n- **TLS 1.0 and 1.1** are deprecated by all major browsers and contain known vulnerabilities\n- Consider setting to `TLS 1.3` for environments with modern client requirements\n- Test client compatibility before upgrading minimum version", - "Url": "https://hub.prowler.com/checks/cloudflare/zones_min_tls_version_secure" + "Url": "https://hub.prowler.com/checks/cloudflare/zone_min_tls_version_secure" } }, "Categories": [ diff --git a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.py b/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.py similarity index 91% rename from prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.py rename to prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.py index a3220376c7..6964b3f038 100644 --- a/prowler/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure.py +++ b/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.py @@ -1,8 +1,8 @@ from prowler.lib.check.models import Check, CheckReportCloudflare -from prowler.providers.cloudflare.services.zones.zones_client import zones_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client -class zones_min_tls_version_secure(Check): +class zone_min_tls_version_secure(Check): """Ensure that minimum TLS version is set to 1.2 or higher for Cloudflare zones. TLS 1.0 and 1.1 have known vulnerabilities (BEAST, POODLE) and are deprecated. @@ -26,7 +26,7 @@ class zones_min_tls_version_secure(Check): """ findings = [] - for zone in zones_client.zones.values(): + for zone in zone_client.zones.values(): report = CheckReportCloudflare( metadata=self.metadata(), resource=zone, diff --git a/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled.metadata.json new file mode 100644 index 0000000000..d0bb24a2f4 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_rate_limiting_enabled", + "CheckTitle": "Rate limiting is configured for the zone", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Rate Limiting** configuration by checking if rules are configured to protect against **DDoS attacks**, **brute force attempts**, and **API abuse**.", + "Risk": "Without **Rate Limiting**, applications are vulnerable to volumetric attacks.\n- **Confidentiality**: credential brute forcing can compromise user accounts\n- **Integrity**: API abuse can manipulate data through excessive requests\n- **Availability**: volumetric attacks can exhaust resources causing service degradation", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/rate-limiting-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > WAF > Rate limiting rules\n3. Create a new rate limiting rule\n4. Configure thresholds based on expected traffic patterns\n5. Apply stricter limits to sensitive endpoints like authentication and APIs", + "Terraform": "```hcl\n# Configure Rate Limiting to protect against volumetric attacks\nresource \"cloudflare_ruleset\" \"rate_limit\" {\n zone_id = \"\"\n name = \"Rate limiting\"\n kind = \"zone\"\n phase = \"http_ratelimit\"\n rules {\n action = \"block\"\n ratelimit {\n characteristics = [\"ip.src\"]\n period = 60\n requests_per_period = 100\n mitigation_timeout = 600\n }\n expression = \"true\"\n description = \"Rate limit all requests\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Implement **Rate Limiting** as part of defense in depth.\n- Configure thresholds based on expected traffic patterns\n- Apply stricter limits to sensitive endpoints like authentication and APIs\n- Use different rate limits for different paths or endpoints\n- Monitor rate limiting analytics to fine-tune thresholds", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_rate_limiting_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Rate limiting rules are configured in the http_ratelimit phase. Consider different thresholds for different types of endpoints based on their sensitivity and expected usage patterns." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled.py b/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled.py new file mode 100644 index 0000000000..1664d48c4b --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_rate_limiting_enabled(Check): + """Ensure that Rate Limiting is configured for Cloudflare zones. + + Rate Limiting protects against DDoS attacks, brute force attempts, and API + abuse by limiting the number of requests from a single source within a specified + time window. Rules are configured in the http_ratelimit phase and help maintain + service availability under high-traffic conditions. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Rate Limiting enabled check. + + Iterates through all Cloudflare zones and verifies that at least one + enabled rate limiting rule exists. + + Returns: + A list of CheckReportCloudflare objects with PASS status if rate + limiting rules are configured, or FAIL status if no rules exist. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # Get enabled rate limiting rules for this zone + enabled_rules = [rule for rule in zone.rate_limit_rules if rule.enabled] + + if enabled_rules: + report.status = "PASS" + rules_str = ", ".join( + rule.description or rule.id for rule in enabled_rules + ) + report.status_extended = ( + f"Rate limiting is configured for zone {zone.name}: {rules_str}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"No rate limiting rules configured for zone {zone.name}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json new file mode 100644 index 0000000000..07e9181831 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_caa_exists", + "CheckTitle": "CAA record exists with issue or issuewild tag", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **CAA (Certificate Authority Authorization)** DNS records by checking if they exist with **`issue` or `issuewild` tags** that specify which **certificate authorities** are permitted to issue SSL/TLS certificates for the domain.", + "Risk": "Without **CAA** records or without `issue`/`issuewild` tags, any certificate authority can issue certificates for your domain.\n- **Confidentiality**: unauthorized certificates enable man-in-the-middle attacks\n- **Integrity**: attackers can impersonate your domain with fraudulently obtained certificates\n- **Trust**: CA compromise or social engineering can result in unauthorized certificate issuance\n- **Missing tags**: CAA records without `issue` or `issuewild` tags (e.g., only `iodef`) do not restrict certificate issuance", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/caa-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Click Add record\n4. Select CAA as the record type\n5. Enter @ for the Name field\n6. Set flags to 0, tag to issue, and value to your authorized CA (e.g., letsencrypt.org)\n7. Click Save\n8. Optionally add issuewild records to control wildcard certificate issuance", + "Terraform": "```hcl\n# Create CAA record to restrict certificate issuance\nresource \"cloudflare_record\" \"caa_issue\" {\n zone_id = \"\"\n name = \"@\"\n type = \"CAA\"\n data {\n flags = \"0\"\n tag = \"issue\" # Restricts which CAs can issue regular certificates\n value = \"letsencrypt.org\" # Specify your authorized certificate authority\n }\n}\n\n# Create CAA record to restrict wildcard certificate issuance\nresource \"cloudflare_record\" \"caa_issuewild\" {\n zone_id = \"\"\n name = \"@\"\n type = \"CAA\"\n data {\n flags = \"0\"\n tag = \"issuewild\" # Restricts which CAs can issue wildcard certificates\n value = \"letsencrypt.org\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Add **CAA records** with `issue` or `issuewild` tags specifying authorized certificate authorities.\n- `issue` tag: authorizes CAs for regular (non-wildcard) certificates\n- `issuewild` tag: authorizes CAs for wildcard certificates specifically\n- Use `issue \";\"` to block all certificate issuance (for domains that should never have certificates)\n- Use `iodef` tag to receive reports of policy violations (optional, for monitoring)\n- All CAs are required to check CAA records before issuing certificates", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_caa_exists" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "CAA records help prevent unauthorized SSL/TLS certificate issuance. This check verifies both existence and that the record contains issue or issuewild tags. Records with only iodef tag will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.py new file mode 100644 index 0000000000..2f9275cb91 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.py @@ -0,0 +1,82 @@ +import re + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_caa_exists(Check): + """Ensure that CAA record exists with certificate issuance restrictions. + + CAA (Certificate Authority Authorization) is a DNS record type that allows + domain owners to specify which certificate authorities (CAs) are permitted + to issue SSL/TLS certificates for their domain. This check verifies that CAA + records exist with "issue" or "issuewild" tags that explicitly authorize + specific CAs, preventing unauthorized certificate issuance and reducing the + risk of man-in-the-middle attacks from fraudulent certificates. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the CAA record exists check. + + Iterates through all Cloudflare zones and verifies CAA configuration. + The check validates two conditions: + 1. CAA records exist for the zone + 2. At least one record has an "issue" or "issuewild" tag specifying authorized CAs + + Returns: + A list of CheckReportCloudflare objects with PASS status if CAA + records with issuance restrictions exist, or FAIL status if no CAA + records are found or they lack proper issue/issuewild tags. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # CAA records restrict which CAs can issue certificates + caa_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id and record.type == "CAA" + ] + + if not caa_records: + report.status = "FAIL" + report.status_extended = f"No CAA record found for zone {zone.name}." + else: + # Check if CAA records have issue or issuewild tags with CA specified + issue_records = [ + record + for record in caa_records + if self._has_issue_tag_with_ca(record.content) + ] + + records_str = ", ".join(record.name for record in caa_records) + + if issue_records: + report.status = "PASS" + report.status_extended = f"CAA record with certificate issuance restrictions exists for zone {zone.name}: {records_str}." + else: + report.status = "FAIL" + report.status_extended = f"CAA record exists for zone {zone.name} but does not specify authorized CAs with issue or issuewild tags: {records_str}." + + findings.append(report) + + return findings + + def _has_issue_tag_with_ca(self, content: str) -> bool: + """Check if CAA record has issue or issuewild tag with a CA specified. + + CAA content format: "flags tag value" e.g., "0 issue letsencrypt.org" + """ + # Strip quotes that may be present from Cloudflare API + content_lower = content.strip('"').lower() + # Match issue or issuewild tag followed by a value (CA name or ";" to block all) + # Format: "0 issue letsencrypt.org" or "0 issuewild ;" or "0 issue \"digicert.com\"" + issue_match = re.search(r"\bissue\b", content_lower) + issuewild_match = re.search(r"\bissuewild\b", content_lower) + return bool(issue_match or issuewild_match) diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json new file mode 100644 index 0000000000..a8811febbf --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_dkim_exists", + "CheckTitle": "DKIM record exists with valid public key", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **DKIM (DomainKeys Identified Mail)** records by checking if TXT records exist at `*._domainkey` subdomains containing a **cryptographically valid public key** in the `p=` parameter used to **verify email signatures**.", + "Risk": "Without **DKIM** or with a revoked/empty public key, email recipients cannot verify that messages were sent by authorized servers.\n- **Confidentiality**: attackers can forge emails appearing to come from your domain\n- **Integrity**: no cryptographic proof that email content hasn't been modified in transit\n- **Reputation**: DMARC policies relying on DKIM will fail, affecting email deliverability\n- **Revoked keys**: A DKIM record with `p=` empty (e.g., `p=;`) indicates a revoked key that cannot authenticate emails", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Generate DKIM keys using your email provider or mail server\n2. Log in to the Cloudflare dashboard and select your account and domain\n3. Go to DNS > Records\n4. Click Add record\n5. Select TXT as the record type\n6. Enter selector._domainkey for the Name field (e.g., google._domainkey)\n7. Enter the DKIM public key record provided by your email service (must include p= with actual key)\n8. Click Save", + "Terraform": "```hcl\n# Create DKIM record for email authentication\nresource \"cloudflare_record\" \"dkim\" {\n zone_id = \"\"\n name = \"google._domainkey\" # Selector varies by email provider\n type = \"TXT\"\n content = \"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4...\" # Must contain valid public key\n ttl = 3600\n}\n```" + }, + "Recommendation": { + "Text": "Configure **DKIM records** with valid public keys from your email provider.\n- Each email service requires its own DKIM selector (e.g., google._domainkey for Google Workspace)\n- The `p=` parameter must contain the actual public key, not be empty\n- An empty `p=` value indicates a revoked key and will fail this check\n- DKIM works alongside SPF and DMARC for comprehensive email authentication\n- Rotate DKIM keys periodically for enhanced security\n- Use 2048-bit keys for stronger cryptographic protection", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_dkim_exists" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "DKIM records are TXT records at *._domainkey subdomains starting with 'v=DKIM1'. This check uses cryptographic validation to verify that the p= parameter contains a real DER-encoded public key, not just non-empty Base64. Revoked keys, invalid Base64, or malformed keys will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.py new file mode 100644 index 0000000000..3ecf725d44 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.py @@ -0,0 +1,116 @@ +import base64 +import re + +from cryptography.hazmat.primitives.serialization import load_der_public_key + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_dkim_exists(Check): + """Ensure that DKIM record exists with valid public key for Cloudflare zones. + + DKIM (DomainKeys Identified Mail) is an email authentication method that allows + the receiver to verify that an email was sent by the domain owner and was not + modified in transit. This check verifies that DKIM records exist at *._domainkey + subdomains containing "v=DKIM1" with a cryptographically valid public key in the + p= parameter that can be used to verify email signatures. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the DKIM record exists check. + + Iterates through all Cloudflare zones and verifies DKIM configuration. + The check validates three conditions: + 1. A DKIM record exists (TXT record at *._domainkey with "v=DKIM1") + 2. The record contains a p= parameter with a public key + 3. The public key is cryptographically valid (valid Base64 and DER format) + + Returns: + A list of CheckReportCloudflare objects with PASS status if a DKIM + record with valid public key exists, or FAIL status if no DKIM record + is found or the public key is invalid/missing. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # DKIM records are TXT records at *._domainkey subdomain containing "v=DKIM1" + dkim_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id + and record.type == "TXT" + and record.name + and "_domainkey" in record.name + and "V=DKIM1" + in record.content.replace('" "', "").replace('"', "").upper() + ] + + if not dkim_records: + report.status = "FAIL" + report.status_extended = f"No DKIM record found for zone {zone.name}." + else: + # Check if DKIM records have a valid public key + valid_key_records = [ + record + for record in dkim_records + if self._has_valid_public_key(record.content) + ] + + records_str = ", ".join(record.name for record in dkim_records) + + if valid_key_records: + report.status = "PASS" + report.status_extended = f"DKIM record with valid public key exists for zone {zone.name}: {records_str}." + else: + report.status = "FAIL" + report.status_extended = f"DKIM record exists for zone {zone.name} but has invalid or missing public key: {records_str}." + + findings.append(report) + + return findings + + def _has_valid_public_key(self, content: str) -> bool: + """Check if DKIM record has a valid public key. + + Validates that: + 1. The p= parameter exists and is not empty + 2. The key is valid Base64 + 3. The key can be loaded as a valid DER-encoded public key + """ + # Cloudflare API may return TXT records with quotes, and long records + # may be split into multiple quoted strings like: "part1" "part2" + # First remove '" "' to join split parts, then remove remaining quotes + content = content.replace('" "', "").replace('"', "") + + # Extract the public key value from p= parameter + match = re.search(r"p\s*=\s*([^;\s]*)", content, re.IGNORECASE) + if not match: + return False + + key_value = match.group(1) + + # Empty key means revoked + if not key_value: + return False + + try: + # Add padding if necessary for Base64 decoding + padding = 4 - (len(key_value) % 4) + if padding != 4: + key_value += "=" * padding + + # Decode Base64 to get DER-encoded key + der_key = base64.b64decode(key_value, validate=True) + + # Try to load as a public key using cryptography library + load_der_public_key(der_key) + return True + except Exception: + return False diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json new file mode 100644 index 0000000000..2aa572da36 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_dmarc_exists", + "CheckTitle": "DMARC record exists with enforcement policy (p=reject or p=quarantine)", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **DMARC (Domain-based Message Authentication, Reporting, and Conformance)** records by checking if a TXT record exists at `_dmarc` subdomain with an **enforcement policy (`p=reject` or `p=quarantine`)** to actively block or quarantine spoofed emails.", + "Risk": "Without **DMARC** or with a monitoring-only policy (`p=none`), there is no active protection against email spoofing.\n- **Confidentiality**: attackers can spoof emails for phishing campaigns to steal credentials\n- **Integrity**: no visibility into email authentication failures or abuse attempts\n- **Reputation**: domain reputation damage from spoofed emails sent in your name\n- **Monitoring-only policy**: `p=none` only generates reports but does not block or quarantine spoofed emails, providing no real protection", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Click Add record\n4. Select TXT as the record type\n5. Enter _dmarc for the Name field\n6. Enter your DMARC policy with enforcement (e.g., v=DMARC1; p=reject; rua=mailto:dmarc@example.com)\n7. Click Save", + "Terraform": "```hcl\n# Create DMARC record with enforcement policy\nresource \"cloudflare_record\" \"dmarc\" {\n zone_id = \"\"\n name = \"_dmarc\"\n type = \"TXT\"\n content = \"v=DMARC1; p=reject; rua=mailto:dmarc@example.com\" # Use p=reject or p=quarantine for enforcement\n ttl = 3600\n}\n```" + }, + "Recommendation": { + "Text": "Implement **DMARC** with an enforcement policy (`p=reject` or `p=quarantine`).\n- `p=reject`: receiving servers should reject emails that fail authentication\n- `p=quarantine`: receiving servers should send suspicious emails to spam folder\n- `p=none`: only monitoring, provides no protection (will fail this check)\n- Ensure SPF and DKIM are properly configured before enabling enforcement\n- Configure `rua` to receive aggregate reports on authentication results\n- Use `pct` parameter to gradually roll out enforcement (e.g., `pct=10` for 10% of emails)", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_dmarc_exists" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "DMARC records are TXT records at _dmarc subdomain starting with 'v=DMARC1'. This check verifies both existence and enforcement policy (p=reject or p=quarantine). Monitoring-only policy (p=none) will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.py new file mode 100644 index 0000000000..bf894a2bf1 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.py @@ -0,0 +1,88 @@ +import re + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_dmarc_exists(Check): + """Ensure that DMARC record exists with enforcement policy for Cloudflare zones. + + DMARC (Domain-based Message Authentication, Reporting, and Conformance) is an + email authentication protocol that builds on SPF and DKIM. It allows domain + owners to specify how receiving mail servers should handle emails that fail + authentication checks. This check verifies that a DMARC record exists at the + _dmarc subdomain with an enforcement policy (p=reject or p=quarantine) to + actively block or quarantine spoofed emails, not just monitor them (p=none). + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the DMARC record exists check. + + Iterates through all Cloudflare zones and verifies DMARC configuration. + The check validates two conditions: + 1. A DMARC record exists (TXT record at _dmarc subdomain with "v=DMARC1") + 2. The record uses an enforcement policy (p=reject or p=quarantine) + + Returns: + A list of CheckReportCloudflare objects with PASS status if a DMARC + record with enforcement policy exists, or FAIL status if no DMARC + record is found or it uses monitoring-only policy (p=none). + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # DMARC records are TXT records at _dmarc subdomain starting with "v=DMARC1" + dmarc_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id + and record.type == "TXT" + and record.name + and record.name.startswith("_dmarc") + and "V=DMARC1" in record.content.strip('"').upper() + ] + + if not dmarc_records: + report.status = "FAIL" + report.status_extended = f"No DMARC record found for zone {zone.name}." + else: + # Check if DMARC uses enforcement policy (p=reject or p=quarantine) vs monitoring (p=none) + enforcement_records = [ + record + for record in dmarc_records + if self._get_policy_value(record.content) + in ("reject", "quarantine") + ] + + records_str = ", ".join(record.name for record in dmarc_records) + + if enforcement_records: + # Get the actual policy value from the first enforcement record + policy = self._get_policy_value(enforcement_records[0].content) + report.status = "PASS" + report.status_extended = f"DMARC record with enforcement policy p={policy} exists for zone {zone.name}: {records_str}." + else: + # Get the actual policy value to show in the message + policy = self._get_policy_value(dmarc_records[0].content) or "none" + report.status = "FAIL" + report.status_extended = f"DMARC record exists for zone {zone.name} but uses monitoring-only policy p={policy}: {records_str}." + + findings.append(report) + + return findings + + def _get_policy_value(self, content: str) -> str | None: + """Extract the DMARC policy value (reject, quarantine, or none).""" + # Strip quotes that may be present from Cloudflare API + content_clean = content.strip('"') + # Match p= (with optional spaces around =) + match = re.search(r"p\s*=\s*(\w+)", content_clean, re.IGNORECASE) + if match: + return match.group(1).lower() + return None diff --git a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json new file mode 100644 index 0000000000..07e2d4ee5f --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_spf_exists", + "CheckTitle": "SPF record exists with strict policy (-all)", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **SPF (Sender Policy Framework)** records by checking if a TXT record exists that specifies which mail servers are **authorized to send email** on behalf of the domain, and verifies that the record uses a **strict policy (`-all`)** to reject unauthorized senders.", + "Risk": "Without **SPF** or with a permissive policy (`~all`, `?all`, `+all`), attackers can forge emails appearing to come from your domain.\n- **Confidentiality**: phishing attacks can harvest sensitive information from recipients who trust spoofed emails\n- **Integrity**: brand reputation damage from fraudulent emails sent in your name\n- **Availability**: email deliverability issues as receiving servers may reject or quarantine legitimate emails\n- **Permissive policies**: `~all` (softfail) only marks emails as suspicious but does not reject them, `?all` (neutral) provides no protection", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Click Add record\n4. Select TXT as the record type\n5. Enter @ for the Name field\n6. Enter your SPF record with strict policy (e.g., v=spf1 include:_spf.google.com -all)\n7. Click Save", + "Terraform": "```hcl\n# Create SPF record for email authentication with strict policy\nresource \"cloudflare_record\" \"spf\" {\n zone_id = \"\"\n name = \"@\"\n type = \"TXT\"\n content = \"v=spf1 include:_spf.google.com -all\" # Use -all for strict enforcement\n ttl = 3600\n}\n```" + }, + "Recommendation": { + "Text": "Configure **SPF records** with strict policy (`-all`) listing authorized mail servers.\n- SPF records start with `v=spf1` and define authorized senders\n- Use `-all` (hardfail) to reject emails from unauthorized servers\n- Avoid `~all` (softfail) in production as it only marks suspicious emails but does not reject them\n- Use `include:` to authorize third-party mail services\n- Combine with **DKIM** and **DMARC** for comprehensive email authentication\n- Test SPF records using online validators before deployment", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_spf_exists" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "SPF records start with 'v=spf1' and define authorized mail servers. This check verifies both existence and strict policy (-all). Permissive policies like ~all (softfail) or ?all (neutral) will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.py new file mode 100644 index 0000000000..861a0c82ab --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.py @@ -0,0 +1,68 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_spf_exists(Check): + """Ensure that SPF record exists with strict policy for Cloudflare zones. + + SPF (Sender Policy Framework) is an email authentication method that specifies + which mail servers are authorized to send email on behalf of the domain. This + check verifies that an SPF record exists as a TXT record starting with "v=spf1" + and uses the strict policy qualifier "-all" to instruct receiving servers to + reject emails from unauthorized sources. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the SPF record exists check. + + Iterates through all Cloudflare zones and verifies SPF configuration. + The check validates two conditions: + 1. An SPF record exists (TXT record starting with "v=spf1") + 2. The record uses strict policy "-all" (not ~all, ?all, or +all) + + Returns: + A list of CheckReportCloudflare objects with PASS status if an SPF + record with strict policy exists, or FAIL status if no SPF record + is found or it uses a permissive policy. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # SPF records are TXT records starting with "v=spf1" + spf_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id + and record.type == "TXT" + and record.content.strip('"').startswith("v=spf1") + ] + + if not spf_records: + report.status = "FAIL" + report.status_extended = f"No SPF record found for zone {zone.name}." + else: + # Check if SPF uses strict policy (-all) vs permissive (~all, ?all, +all) + strict_records = [ + record + for record in spf_records + if record.content.strip('"').rstrip().endswith("-all") + ] + + records_str = ", ".join(record.name for record in spf_records) + + if strict_records: + report.status = "PASS" + report.status_extended = f"SPF record with strict policy -all exists for zone {zone.name}: {records_str}." + else: + report.status = "FAIL" + report.status_extended = f"SPF record exists for zone {zone.name} but does not use strict policy -all: {records_str}." + + findings.append(report) + + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.metadata.json new file mode 100644 index 0000000000..405b88180f --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_security_under_attack_disabled", + "CheckTitle": "Under Attack Mode is disabled during normal operations", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Under Attack Mode** configuration by checking if it is disabled during normal operations, as this mode performs additional security checks including an **interstitial JavaScript challenge page** that significantly impacts user experience.", + "Risk": "Keeping **Under Attack Mode** permanently enabled causes operational issues.\n- **Availability**: all visitors face a 5-second interstitial challenge page before accessing the site\n- **Accessibility**: visitors without JavaScript support cannot access the site at all\n- **User Experience**: legitimate users experience unnecessary delays and third-party analytics show degraded performance", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/fundamentals/reference/under-attack-mode/", + "https://developers.cloudflare.com/waf/tools/security-level/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > Settings\n3. Under 'Under Attack Mode', toggle it OFF\n4. Consider setting Security Level to 'High' for continued protection without the interstitial", + "Terraform": "```hcl\n# Set security level to high instead of under_attack\nresource \"cloudflare_zone_settings_override\" \"security_settings\" {\n zone_id = \"\"\n settings {\n security_level = \"high\" # Use high instead of under_attack for normal operations\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disable **Under Attack Mode** when not actively under a DDoS attack.\n- Use it only as a **last resort** during active layer 7 DDoS attacks\n- For ongoing protection, use **Security Level** settings (Low, Medium, High)\n- Configure specific **WAF rules** for targeted protection without impacting all users", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_security_under_attack_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Under Attack Mode is designed as a temporary measure during active attacks. The interstitial challenge page validates visitors using JavaScript, blocking automated attacks while allowing legitimate users through after a brief delay." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.py b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.py new file mode 100644 index 0000000000..e0dbf9b455 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.py @@ -0,0 +1,47 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_security_under_attack_disabled(Check): + """Ensure that Under Attack Mode is disabled during normal operations. + + Under Attack Mode is a DDoS mitigation feature that performs additional + security checks including an interstitial JavaScript challenge page for all + visitors. While effective during active attacks, it significantly impacts + user experience and should only be enabled temporarily during actual DDoS + incidents, not as a permanent security measure. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Under Attack Mode disabled check. + + Iterates through all Cloudflare zones and verifies that the security + level is not set to "under_attack". Having this mode permanently enabled + indicates either an ongoing attack or misconfiguration that degrades + user experience unnecessarily. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Under + Attack Mode is disabled, or FAIL status if it is currently enabled. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + security_level = (zone.settings.security_level or "").lower() + + if security_level == "under_attack": + report.status = "FAIL" + report.status_extended = ( + f"Zone {zone.name} has Under Attack Mode enabled." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Zone {zone.name} does not have Under Attack Mode enabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_service.py b/prowler/providers/cloudflare/services/zone/zone_service.py new file mode 100644 index 0000000000..7a718cd3fb --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_service.py @@ -0,0 +1,460 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.cloudflare.lib.service.service import CloudflareService +from prowler.providers.cloudflare.models import CloudflareAccount + + +class CloudflareRateLimitRule(BaseModel): + """Cloudflare rate limiting rule representation.""" + + id: str + description: Optional[str] = None + action: Optional[str] = None + enabled: bool = True + expression: Optional[str] = None + + +class CloudflareFirewallRule(BaseModel): + """Represents a firewall rule from custom rulesets.""" + + id: str + name: str = "" + description: Optional[str] = None + action: Optional[str] = None + enabled: bool = True + expression: Optional[str] = None + phase: Optional[str] = None + + class Config: + arbitrary_types_allowed = True + + +class CloudflareWAFRuleset(BaseModel): + """Represents a WAF ruleset (managed rules) for a zone.""" + + id: str + name: str + kind: Optional[str] = None + phase: Optional[str] = None + enabled: bool = True + + +class Zone(CloudflareService): + """Retrieve Cloudflare zones with security-relevant settings.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.zones: dict[str, "CloudflareZone"] = {} + self._list_zones() + self._get_zones_settings() + self._get_zones_dnssec() + self._get_zones_universal_ssl() + self._get_zones_rate_limit_rules() + self._get_zones_bot_management() + self._get_zones_firewall_rules() + self._get_zones_waf_rulesets() + + def _list_zones(self) -> None: + """List all Cloudflare zones with their basic information.""" + logger.info("Zone - Listing zones...") + audited_accounts = self.provider.identity.audited_accounts + filter_zones = self.provider.filter_zones + seen_zone_ids: set[str] = set() + + try: + for zone in self.client.zones.list(): + zone_id = getattr(zone, "id", None) + # Prevent infinite loop - skip if we've seen this zone + if zone_id in seen_zone_ids: + break + seen_zone_ids.add(zone_id) + + zone_account = getattr(zone, "account", None) + account_id = getattr(zone_account, "id", None) if zone_account else None + + # Filter by audited accounts + if audited_accounts and account_id not in audited_accounts: + continue + + zone_name = getattr(zone, "name", None) + + # Apply zone filter if specified via --region + if ( + filter_zones + and zone_id not in filter_zones + and zone_name not in filter_zones + ): + continue + + zone_plan = getattr(zone, "plan", None) + self.zones[zone_id] = CloudflareZone( + id=zone_id, + name=zone_name, + status=getattr(zone, "status", None), + paused=getattr(zone, "paused", False), + account=( + CloudflareAccount( + id=account_id, + name=( + getattr(zone_account, "name", "") + if zone_account + else "" + ), + type=( + getattr(zone_account, "type", None) + if zone_account + else None + ), + ) + if zone_account + else None + ), + plan=getattr(zone_plan, "name", None) if zone_plan else None, + ) + + if not self.zones: + logger.warning( + "No Cloudflare zones discovered with current credentials." + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones_settings(self) -> None: + """Get settings for all zones.""" + logger.info("Zone - Getting zone settings...") + for zone in self.zones.values(): + try: + zone.settings = self._get_zone_settings(zone.id) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones_dnssec(self) -> None: + """Get DNSSEC status for all zones.""" + logger.info("Zone - Getting DNSSEC status...") + for zone in self.zones.values(): + try: + dnssec = self.client.dns.dnssec.get(zone_id=zone.id) + zone.dnssec_status = getattr(dnssec, "status", None) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones_universal_ssl(self) -> None: + """Get Universal SSL settings for all zones.""" + logger.info("Zones - Getting Universal SSL settings...") + for zone in self.zones.values(): + try: + universal_ssl = self.client.ssl.universal.settings.get(zone_id=zone.id) + zone.settings.universal_ssl_enabled = getattr( + universal_ssl, "enabled", False + ) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones_rate_limit_rules(self) -> None: + """Get rate limiting rules for all zones.""" + logger.info("Zone - Getting rate limit rules...") + for zone in self.zones.values(): + try: + seen_ruleset_ids: set[str] = set() + for ruleset in self.client.rulesets.list(zone_id=zone.id): + ruleset_id = getattr(ruleset, "id", "") + if ruleset_id in seen_ruleset_ids: + break + seen_ruleset_ids.add(ruleset_id) + + phase = getattr(ruleset, "phase", "") + if phase == "http_ratelimit": + try: + ruleset_detail = self.client.rulesets.get( + ruleset_id=ruleset_id, zone_id=zone.id + ) + rules = getattr(ruleset_detail, "rules", []) or [] + seen_rule_ids: set[str] = set() + for rule in rules: + rule_id = getattr(rule, "id", "") + if rule_id in seen_rule_ids: + break + seen_rule_ids.add(rule_id) + zone.rate_limit_rules.append( + CloudflareRateLimitRule( + id=rule_id, + description=getattr(rule, "description", None), + action=getattr(rule, "action", None), + enabled=getattr(rule, "enabled", True), + expression=getattr(rule, "expression", None), + ) + ) + except Exception as error: + logger.debug( + f"{zone.id} ruleset {ruleset_id} -- {error.__class__.__name__}: {error}" + ) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones_bot_management(self) -> None: + """Get Bot Management settings for all zones.""" + logger.info("Zone - Getting Bot Management settings...") + for zone in self.zones.values(): + try: + bot_management = self.client.bot_management.get(zone_id=zone.id) + zone.settings.bot_fight_mode_enabled = getattr( + bot_management, "fight_mode", False + ) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones_firewall_rules(self) -> None: + """Get firewall rules for all zones.""" + logger.info("Zones - Getting firewall rules...") + for zone in self.zones.values(): + try: + self._get_zone_firewall_rules(zone) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zone_firewall_rules(self, zone: "CloudflareZone") -> None: + """List firewall rules from custom rulesets for a zone.""" + seen_ruleset_ids: set[str] = set() + try: + for ruleset in self.client.rulesets.list(zone_id=zone.id): + ruleset_id = getattr(ruleset, "id", "") + if ruleset_id in seen_ruleset_ids: + break + seen_ruleset_ids.add(ruleset_id) + + ruleset_phase = getattr(ruleset, "phase", "") + if ruleset_phase in [ + "http_request_firewall_custom", + "http_ratelimit", + "http_request_firewall_managed", + ]: + try: + ruleset_detail = self.client.rulesets.get( + ruleset_id=ruleset_id, zone_id=zone.id + ) + rules = getattr(ruleset_detail, "rules", []) or [] + seen_rule_ids: set[str] = set() + for rule in rules: + rule_id = getattr(rule, "id", "") + if rule_id in seen_rule_ids: + break + seen_rule_ids.add(rule_id) + try: + zone.firewall_rules.append( + CloudflareFirewallRule( + id=rule_id, + name=getattr(rule, "description", "") + or rule_id, + description=getattr(rule, "description", None), + action=getattr(rule, "action", None), + enabled=getattr(rule, "enabled", True), + expression=getattr(rule, "expression", None), + phase=ruleset_phase, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zones_waf_rulesets(self) -> None: + """Get WAF rulesets for all zones.""" + logger.info("Zones - Getting WAF rulesets...") + for zone in self.zones.values(): + try: + self._get_zone_waf_rulesets(zone) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zone_waf_rulesets(self, zone: "CloudflareZone") -> None: + """List WAF rulesets for a zone using the rulesets API.""" + seen_ids: set[str] = set() + try: + for ruleset in self.client.rulesets.list(zone_id=zone.id): + ruleset_id = getattr(ruleset, "id", "") + if ruleset_id in seen_ids: + break + seen_ids.add(ruleset_id) + try: + zone.waf_rulesets.append( + CloudflareWAFRuleset( + id=ruleset_id, + name=getattr(ruleset, "name", ""), + kind=getattr(ruleset, "kind", None), + phase=getattr(ruleset, "phase", None), + enabled=True, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zone_setting(self, zone_id: str, setting_id: str): + """Get a single zone setting by ID.""" + try: + result = self.client.zones.settings.get( + setting_id=setting_id, zone_id=zone_id + ) + return getattr(result, "value", None) + except Exception: + return None + + def _get_zone_settings(self, zone_id: str) -> "CloudflareZoneSettings": + """Get all settings for a zone.""" + settings = { + setting_id: self._get_zone_setting(zone_id, setting_id) + for setting_id in [ + "always_use_https", + "min_tls_version", + "ssl", + "tls_1_3", + "automatic_https_rewrites", + "security_header", + "waf", + "security_level", + "browser_check", + "challenge_ttl", + "ip_geolocation", + "email_obfuscation", + "server_side_exclude", + "hotlink_protection", + "development_mode", + "always_online", + ] + } + + return CloudflareZoneSettings( + always_use_https=settings.get("always_use_https"), + min_tls_version=str(settings.get("min_tls_version") or ""), + ssl_encryption_mode=settings.get("ssl"), + tls_1_3=settings.get("tls_1_3"), + automatic_https_rewrites=settings.get("automatic_https_rewrites"), + strict_transport_security=self._get_strict_transport_security( + settings.get("security_header") + ), + waf=settings.get("waf"), + security_level=settings.get("security_level"), + browser_check=settings.get("browser_check"), + challenge_ttl=settings.get("challenge_ttl") or 0, + ip_geolocation=settings.get("ip_geolocation"), + email_obfuscation=settings.get("email_obfuscation"), + server_side_exclude=settings.get("server_side_exclude"), + hotlink_protection=settings.get("hotlink_protection"), + development_mode=settings.get("development_mode"), + always_online=settings.get("always_online"), + ) + + def _get_strict_transport_security( + self, security_header + ) -> "StrictTransportSecurity": + """Parse HSTS settings from security_header.""" + if hasattr(security_header, "strict_transport_security"): + sts = security_header.strict_transport_security + sts_data = { + "enabled": getattr(sts, "enabled", False), + "max_age": getattr(sts, "max_age", 0), + "include_subdomains": getattr(sts, "include_subdomains", False), + "preload": getattr(sts, "preload", False), + "nosniff": getattr(sts, "nosniff", False), + } + elif isinstance(security_header, dict): + sts_data = security_header.get("strict_transport_security", {}) + else: + sts_data = {} + + return StrictTransportSecurity( + enabled=sts_data.get("enabled", False), + max_age=sts_data.get("max_age", 0), + include_subdomains=sts_data.get("include_subdomains", False), + preload=sts_data.get("preload", False), + nosniff=sts_data.get("nosniff", False), + ) + + +class StrictTransportSecurity(BaseModel): + """HTTP Strict Transport Security (HSTS) settings.""" + + enabled: bool = False + max_age: int = 0 + include_subdomains: bool = False + preload: bool = False + nosniff: bool = False + + +class CloudflareZoneSettings(BaseModel): + """Selected Cloudflare zone security settings.""" + + # TLS/SSL settings + always_use_https: Optional[str] = None + min_tls_version: Optional[str] = None + ssl_encryption_mode: Optional[str] = None + tls_1_3: Optional[str] = None + automatic_https_rewrites: Optional[str] = None + universal_ssl_enabled: bool = False + # HSTS settings + strict_transport_security: StrictTransportSecurity = Field( + default_factory=StrictTransportSecurity + ) + # Security settings + waf: Optional[str] = None + security_level: Optional[str] = None + browser_check: Optional[str] = None + challenge_ttl: Optional[int] = None + ip_geolocation: Optional[str] = None + # Scrape Shield settings + email_obfuscation: Optional[str] = None + server_side_exclude: Optional[str] = None + hotlink_protection: Optional[str] = None + # Zone state + development_mode: Optional[str] = None + always_online: Optional[str] = None + # Bot management + bot_fight_mode_enabled: bool = False + + +class CloudflareZone(BaseModel): + """Cloudflare zone representation used across services.""" + + id: str + name: str + status: Optional[str] = None + paused: bool = False + account: Optional[CloudflareAccount] = None + plan: Optional[str] = None + settings: CloudflareZoneSettings = Field(default_factory=CloudflareZoneSettings) + dnssec_status: Optional[str] = None + rate_limit_rules: list[CloudflareRateLimitRule] = Field(default_factory=list) + firewall_rules: list[CloudflareFirewallRule] = Field(default_factory=list) + waf_rulesets: list[CloudflareWAFRuleset] = Field(default_factory=list) diff --git a/prowler/providers/cloudflare/services/zone/zone_ssl_strict/__init__.py b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.metadata.json b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json similarity index 92% rename from prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.metadata.json rename to prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json index 1b56458b7f..d236895198 100644 --- a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json @@ -1,13 +1,14 @@ { "Provider": "cloudflare", - "CheckID": "zones_ssl_strict", + "CheckID": "zone_ssl_strict", "CheckTitle": "SSL/TLS encryption mode is set to Full (Strict)", "CheckType": [], - "ServiceName": "zones", + "ServiceName": "zone", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **SSL/TLS encryption mode** by checking if the mode is set to `Full (Strict)` to ensure **end-to-end encryption** with certificate validation.", "Risk": "Without **strict SSL mode**, traffic between Cloudflare and origin may use unvalidated or unencrypted connections.\n- **Confidentiality**: sensitive data can be intercepted in transit via man-in-the-middle attacks\n- **Integrity**: responses can be modified without detection between Cloudflare and origin\n- **Compliance**: may violate PCI-DSS, HIPAA, and other regulatory requirements for encrypted transport", "RelatedUrl": "", @@ -23,7 +24,7 @@ }, "Recommendation": { "Text": "Configure **SSL/TLS mode** to `Full (Strict)` and install a valid certificate on your origin server.\n- Use **Cloudflare Origin CA certificates** for seamless integration\n- Ensure origin server presents a valid certificate matching your domain\n- Enable **Authenticated Origin Pulls** for additional security", - "Url": "https://hub.prowler.com/checks/cloudflare/zones_ssl_strict" + "Url": "https://hub.prowler.com/checks/cloudflare/zone_ssl_strict" } }, "Categories": [ diff --git a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.py b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.py similarity index 91% rename from prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.py rename to prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.py index f51b35e065..346ef5b583 100644 --- a/prowler/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict.py +++ b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.py @@ -1,8 +1,8 @@ from prowler.lib.check.models import Check, CheckReportCloudflare -from prowler.providers.cloudflare.services.zones.zones_client import zones_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client -class zones_ssl_strict(Check): +class zone_ssl_strict(Check): """Ensure that SSL/TLS encryption mode is set to Full (Strict) for Cloudflare zones. The SSL/TLS encryption mode determines how Cloudflare connects to the origin @@ -26,7 +26,7 @@ class zones_ssl_strict(Check): less secure modes like 'off', 'flexible', or 'full'. """ findings = [] - for zone in zones_client.zones.values(): + for zone in zone_client.zones.values(): report = CheckReportCloudflare( metadata=self.metadata(), resource=zone, diff --git a/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.metadata.json new file mode 100644 index 0000000000..5dba1a8de8 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_tls_1_3_enabled", + "CheckTitle": "TLS 1.3 is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **TLS 1.3** configuration by checking if it is enabled to benefit from **improved security** through simplified cipher suites and **faster handshakes** with zero round-trip time resumption.", + "Risk": "Without **TLS 1.3**, connections use older TLS versions with less secure characteristics.\n- **Confidentiality**: legacy TLS versions have more complex cipher negotiations that may expose weaknesses\n- **Integrity**: older protocols lack protection against downgrade attacks that TLS 1.3 provides\n- **Performance**: slower handshakes impact user experience and increase latency", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/tls-13/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to TLS 1.3\n4. Toggle the setting to On\n5. Verify that your clients support TLS 1.3 (all modern browsers do)", + "Terraform": "```hcl\n# Enable TLS 1.3 for improved security and performance\nresource \"cloudflare_zone_settings_override\" \"tls_settings\" {\n zone_id = \"\"\n settings {\n tls_1_3 = \"on\" # Critical: enables most secure TLS version with faster handshakes\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **TLS 1.3** to provide the most secure and efficient TLS connections.\n- All modern browsers support TLS 1.3 ensuring broad compatibility\n- TLS 1.3 removes obsolete cryptographic algorithms\n- Zero round-trip time (0-RTT) resumption improves performance\n- Combine with minimum TLS version set to 1.2 or higher", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_tls_1_3_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "TLS 1.3 is enabled by default on Cloudflare but should be verified. It provides security improvements and performance benefits through simplified handshakes." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.py b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.py new file mode 100644 index 0000000000..7f9671b408 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_tls_1_3_enabled(Check): + """Ensure that TLS 1.3 is enabled for Cloudflare zones. + + TLS 1.3 provides improved security through simplified cipher suites and + faster handshakes with zero round-trip time (0-RTT) resumption. It removes + outdated cryptographic algorithms, reduces handshake latency, and provides + better forward secrecy compared to previous TLS versions. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the TLS 1.3 enabled check. + + Iterates through all Cloudflare zones and verifies that TLS 1.3 is + enabled. The check accepts both "on" (standard TLS 1.3) and "zrt" + (TLS 1.3 with 0-RTT) as valid enabled states. + + Returns: + A list of CheckReportCloudflare objects with PASS status if TLS 1.3 + is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + tls_1_3 = (zone.settings.tls_1_3 or "").lower() + if tls_1_3 in ["on", "zrt"]: + report.status = "PASS" + report.status_extended = f"TLS 1.3 is enabled for zone {zone.name}." + else: + report.status = "FAIL" + report.status_extended = f"TLS 1.3 is not enabled for zone {zone.name}." + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.metadata.json new file mode 100644 index 0000000000..fde6ef3322 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_universal_ssl_enabled", + "CheckTitle": "Universal SSL is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Universal SSL** configuration by checking if it is enabled to provide **free SSL/TLS certificates** for the domain and its subdomains, enabling secure HTTPS connections.", + "Risk": "Without **Universal SSL**, visitors cannot establish HTTPS connections to your site.\n- **Confidentiality**: all traffic is unencrypted and vulnerable to interception and eavesdropping\n- **Integrity**: HTTP responses can be modified in transit by attackers (content injection, malware)\n- **Trust**: browsers display security warnings degrading user trust and experience", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to Universal SSL\n4. Ensure the status shows Active\n5. If disabled, click Enable Universal SSL\n6. Wait for certificate issuance (typically within 24 hours for new domains)", + "Terraform": "```hcl\n# Note: Universal SSL is enabled by default on Cloudflare\n# To explicitly manage SSL settings, use zone_settings_override\nresource \"cloudflare_zone_settings_override\" \"ssl_settings\" {\n zone_id = \"\"\n settings {\n ssl = \"full\" # Critical: enables SSL/TLS encryption\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Universal SSL** to provide HTTPS capability for your domain.\n- Universal SSL is the foundation for transport security\n- Required before implementing HSTS or other HTTPS-dependent features\n- Cloudflare automatically renews certificates before expiration\n- Consider upgrading to Advanced Certificate Manager for additional control", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_universal_ssl_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Universal SSL is enabled by default for all Cloudflare zones. If it was previously disabled, re-enabling may take up to 24 hours for certificate issuance." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.py b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.py new file mode 100644 index 0000000000..30b0c80037 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_universal_ssl_enabled(Check): + """Ensure that Universal SSL is enabled for Cloudflare zones. + + Universal SSL provides free SSL/TLS certificates for the domain and its + subdomains, enabling secure HTTPS connections without requiring manual + certificate management. This feature automatically provisions and renews + certificates, ensuring continuous protection for web traffic. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Universal SSL enabled check. + + Iterates through all Cloudflare zones and verifies that Universal SSL + is enabled. Universal SSL provides automatic certificate provisioning + and management for the zone and its subdomains. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Universal + SSL is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + if zone.settings.universal_ssl_enabled: + report.status = "PASS" + report.status_extended = ( + f"Universal SSL is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Universal SSL is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.metadata.json new file mode 100644 index 0000000000..af32d367cd --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_waf_enabled", + "CheckTitle": "WAF is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Web Application Firewall (WAF)** configuration by checking if it is enabled to protect against common web vulnerabilities including **SQL injection**, **XSS**, and **OWASP Top 10** threats.", + "Risk": "Without **WAF**, web applications are exposed to common attack vectors.\n- **Confidentiality**: SQL injection attacks can exfiltrate sensitive database contents\n- **Integrity**: XSS attacks can modify page content and steal session tokens\n- **Availability**: application-layer attacks can cause service disruption", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > WAF\n3. Enable managed rulesets (OWASP, Cloudflare Managed Ruleset)\n4. Configure custom rules based on your application's specific threat model\n5. Monitor WAF analytics to tune rules and reduce false positives", + "Terraform": "```hcl\n# Enable WAF managed rulesets for comprehensive protection\nresource \"cloudflare_ruleset\" \"waf_managed\" {\n zone_id = \"\"\n name = \"WAF Managed Rules\"\n kind = \"zone\"\n phase = \"http_request_firewall_managed\"\n rules {\n action = \"execute\"\n action_parameters {\n id = \"efb7b8c949ac4650a09736fc376e9aee\" # Cloudflare Managed Ruleset\n }\n expression = \"true\"\n description = \"Execute Cloudflare Managed Ruleset\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **WAF** as a critical layer of defense for web applications.\n- Deploy managed rulesets for protection against OWASP Top 10 vulnerabilities\n- Create custom rules based on your application's specific threat model\n- Monitor WAF analytics to tune rules and reduce false positives\n- Combine with rate limiting and bot protection for comprehensive security", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_waf_enabled" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "WAF is available on Pro, Business, and Enterprise plans. Configure managed rulesets and create custom rules to match your application's specific security requirements." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py new file mode 100644 index 0000000000..64265c3985 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_waf_enabled(Check): + """Ensure that WAF is enabled for Cloudflare zones. + + The Web Application Firewall (WAF) protects against common web vulnerabilities + including SQL injection, cross-site scripting (XSS), and other OWASP Top 10 + threats. When enabled, it inspects HTTP requests and blocks malicious traffic + before it reaches the origin server. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the WAF enabled check. + + Iterates through all Cloudflare zones and verifies that the Web Application + Firewall is enabled. The WAF provides essential protection against common + web application attacks. + + Returns: + A list of CheckReportCloudflare objects with PASS status if WAF is + enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + waf_setting = (zone.settings.waf or "").lower() + + if waf_setting == "on": + report.status = "PASS" + report.status_extended = f"WAF is enabled for zone {zone.name}." + else: + report.status = "FAIL" + report.status_extended = f"WAF is not enabled for zone {zone.name}." + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled.metadata.json new file mode 100644 index 0000000000..17c15d69b8 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_waf_owasp_ruleset_enabled", + "CheckTitle": "Cloudflare Zone OWASP Managed WAF Rulesets Are Enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **OWASP managed rulesets** by checking if they are enabled to protect against common web application vulnerabilities including **SQL injection**, **XSS**, and other **OWASP Top 10** threats.", + "Risk": "Without **OWASP managed rulesets**, web applications are exposed to well-known attack vectors.\n- **Confidentiality**: SQL injection attacks can exfiltrate sensitive database contents\n- **Integrity**: XSS attacks can modify page content and steal session tokens\n- **Availability**: remote code execution can compromise server availability", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/managed-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > WAF > Managed rules\n3. Enable the Cloudflare OWASP Core Ruleset\n4. Review and configure rule sensitivity based on your application\n5. Monitor WAF analytics to tune rules and reduce false positives", + "Terraform": "```hcl\n# Enable OWASP managed WAF rulesets\nresource \"cloudflare_ruleset\" \"waf_owasp\" {\n zone_id = \"\"\n name = \"OWASP Managed Rules\"\n kind = \"zone\"\n phase = \"http_request_firewall_managed\"\n rules {\n action = \"execute\"\n action_parameters {\n id = \"4814384a9e5d4991b9815dcfc25d2f1f\" # Cloudflare OWASP Core Ruleset\n }\n expression = \"true\"\n description = \"Execute Cloudflare OWASP Core Ruleset\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **OWASP Core Ruleset** managed rules as part of a defense in depth strategy.\n- Protects against OWASP Top 10 vulnerabilities including SQLi and XSS\n- Regularly review and tune rule sensitivity based on application requirements\n- Monitor WAF analytics to identify and address false positives\n- Combine with custom rules for application-specific protection", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_waf_owasp_ruleset_enabled" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "OWASP managed rulesets are available on Pro, Business, and Enterprise plans. The Cloudflare OWASP Core Ruleset provides protection against common web application vulnerabilities." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled.py b/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled.py new file mode 100644 index 0000000000..1c7ad35e6d --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled.py @@ -0,0 +1,58 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_waf_owasp_ruleset_enabled(Check): + """Ensure that OWASP managed WAF rulesets are enabled for Cloudflare zones. + + The OWASP Core Ruleset provides protection against common web application + vulnerabilities including SQL injection, cross-site scripting (XSS), and other + OWASP Top 10 threats. These managed rulesets are essential for defense in depth + and protecting applications from well-known attack vectors. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the OWASP WAF ruleset enabled check. + + Iterates through all Cloudflare zones and verifies that OWASP managed + WAF rulesets are enabled. The check identifies OWASP rulesets by name + containing "owasp" or by the http_request_firewall_managed phase. + + Returns: + A list of CheckReportCloudflare objects with PASS status if OWASP + rulesets are enabled, or FAIL status if no OWASP protection exists. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # Find OWASP managed rulesets for this zone + # Only match rulesets that explicitly contain "owasp" in the name + # The phase check was too broad as it matched any managed ruleset + owasp_rulesets = [ + ruleset + for ruleset in zone.waf_rulesets + if "owasp" in (ruleset.name or "").lower() + ] + + if owasp_rulesets: + report.status = "PASS" + ruleset_descriptions = ", ".join( + ruleset.name for ruleset in owasp_rulesets + ) + report.status_extended = ( + f"Zone {zone.name} has OWASP managed WAF ruleset enabled: " + f"{ruleset_descriptions}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Zone {zone.name} does not have OWASP managed WAF ruleset enabled." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/cloudflare/services/zones/zones_client.py b/prowler/providers/cloudflare/services/zones/zones_client.py deleted file mode 100644 index 344f2285df..0000000000 --- a/prowler/providers/cloudflare/services/zones/zones_client.py +++ /dev/null @@ -1,4 +0,0 @@ -from prowler.providers.cloudflare.services.zones.zones_service import Zones -from prowler.providers.common.provider import Provider - -zones_client = Zones(Provider.get_global_provider()) diff --git a/prowler/providers/cloudflare/services/zones/zones_service.py b/prowler/providers/cloudflare/services/zones/zones_service.py deleted file mode 100644 index 7a3693b2fe..0000000000 --- a/prowler/providers/cloudflare/services/zones/zones_service.py +++ /dev/null @@ -1,243 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel, Field - -from prowler.lib.logger import logger -from prowler.providers.cloudflare.lib.service.service import CloudflareService -from prowler.providers.cloudflare.models import CloudflareAccount - - -class Zones(CloudflareService): - """Retrieve Cloudflare zones with security-relevant settings.""" - - def __init__(self, provider): - super().__init__(__class__.__name__, provider) - self.zones: dict[str, "CloudflareZone"] = {} - self._list_zones() - self._get_zones_settings() - self._get_zones_dnssec() - - def _list_zones(self) -> None: - """List all Cloudflare zones with their basic information.""" - logger.info("Zones - Listing zones...") - audited_accounts = self.provider.identity.audited_accounts - filter_zones = self.provider.filter_zones - seen_zone_ids: set[str] = set() - - try: - for zone in self.client.zones.list(): - zone_id = getattr(zone, "id", None) - # Prevent infinite loop - skip if we've seen this zone - if zone_id in seen_zone_ids: - break - seen_zone_ids.add(zone_id) - - zone_account = getattr(zone, "account", None) - account_id = getattr(zone_account, "id", None) if zone_account else None - - # Filter by audited accounts - if audited_accounts and account_id not in audited_accounts: - continue - - zone_name = getattr(zone, "name", None) - - # Apply zone filter if specified via --region - if ( - filter_zones - and zone_id not in filter_zones - and zone_name not in filter_zones - ): - continue - - zone_plan = getattr(zone, "plan", None) - self.zones[zone_id] = CloudflareZone( - id=zone_id, - name=zone_name, - status=getattr(zone, "status", None), - paused=getattr(zone, "paused", False), - account=( - CloudflareAccount( - id=account_id, - name=( - getattr(zone_account, "name", "") - if zone_account - else "" - ), - type=( - getattr(zone_account, "type", None) - if zone_account - else None - ), - ) - if zone_account - else None - ), - plan=getattr(zone_plan, "name", None) if zone_plan else None, - ) - - if not self.zones: - logger.warning( - "No Cloudflare zones discovered with current credentials." - ) - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - - def _get_zones_settings(self) -> None: - """Get settings for all zones.""" - logger.info("Zones - Getting zone settings...") - for zone in self.zones.values(): - try: - zone.settings = self._get_zone_settings(zone.id) - except Exception as error: - logger.error( - f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - - def _get_zones_dnssec(self) -> None: - """Get DNSSEC status for all zones.""" - logger.info("Zones - Getting DNSSEC status...") - for zone in self.zones.values(): - try: - dnssec = self.client.dns.dnssec.get(zone_id=zone.id) - zone.dnssec_status = getattr(dnssec, "status", None) - except Exception as error: - logger.error( - f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - - def _get_zone_setting(self, zone_id: str, setting_id: str): - """Get a single zone setting by ID.""" - try: - result = self.client.zones.settings.get( - setting_id=setting_id, zone_id=zone_id - ) - return getattr(result, "value", None) - except Exception: - return None - - def _get_zone_settings(self, zone_id: str) -> "CloudflareZoneSettings": - """Get all settings for a zone.""" - settings = { - setting_id: self._get_zone_setting(zone_id, setting_id) - for setting_id in [ - "always_use_https", - "min_tls_version", - "ssl", - "tls_1_3", - "automatic_https_rewrites", - "universal_ssl", - "security_header", - "waf", - "security_level", - "browser_check", - "challenge_ttl", - "ip_geolocation", - "email_obfuscation", - "server_side_exclude", - "hotlink_protection", - "development_mode", - "always_online", - ] - } - - return CloudflareZoneSettings( - always_use_https=settings.get("always_use_https"), - min_tls_version=str(settings.get("min_tls_version") or ""), - ssl_encryption_mode=settings.get("ssl"), - tls_1_3=settings.get("tls_1_3"), - automatic_https_rewrites=settings.get("automatic_https_rewrites"), - universal_ssl=settings.get("universal_ssl"), - strict_transport_security=self._get_strict_transport_security( - settings.get("security_header") - ), - waf=settings.get("waf"), - security_level=settings.get("security_level"), - browser_check=settings.get("browser_check"), - challenge_ttl=settings.get("challenge_ttl"), - ip_geolocation=settings.get("ip_geolocation"), - email_obfuscation=settings.get("email_obfuscation"), - server_side_exclude=settings.get("server_side_exclude"), - hotlink_protection=settings.get("hotlink_protection"), - development_mode=settings.get("development_mode"), - always_online=settings.get("always_online"), - ) - - def _get_strict_transport_security( - self, security_header - ) -> "StrictTransportSecurity": - """Parse HSTS settings from security_header.""" - if hasattr(security_header, "strict_transport_security"): - sts = security_header.strict_transport_security - sts_data = { - "enabled": getattr(sts, "enabled", False), - "max_age": getattr(sts, "max_age", 0), - "include_subdomains": getattr(sts, "include_subdomains", False), - "preload": getattr(sts, "preload", False), - "nosniff": getattr(sts, "nosniff", False), - } - elif isinstance(security_header, dict): - sts_data = security_header.get("strict_transport_security", {}) - else: - sts_data = {} - - return StrictTransportSecurity( - enabled=sts_data.get("enabled", False), - max_age=sts_data.get("max_age", 0), - include_subdomains=sts_data.get("include_subdomains", False), - preload=sts_data.get("preload", False), - nosniff=sts_data.get("nosniff", False), - ) - - -class StrictTransportSecurity(BaseModel): - """HTTP Strict Transport Security (HSTS) settings.""" - - enabled: bool = False - max_age: int = 0 - include_subdomains: bool = False - preload: bool = False - nosniff: bool = False - - -class CloudflareZoneSettings(BaseModel): - """Selected Cloudflare zone security settings.""" - - # TLS/SSL settings - always_use_https: Optional[str] = None - min_tls_version: Optional[str] = None - ssl_encryption_mode: Optional[str] = None - tls_1_3: Optional[str] = None - automatic_https_rewrites: Optional[str] = None - universal_ssl: Optional[str] = None - # HSTS settings - strict_transport_security: StrictTransportSecurity = Field( - default_factory=StrictTransportSecurity - ) - # Security settings - waf: Optional[str] = None - security_level: Optional[str] = None - browser_check: Optional[str] = None - challenge_ttl: Optional[int] = None - ip_geolocation: Optional[str] = None - # Scrape Shield settings - email_obfuscation: Optional[str] = None - server_side_exclude: Optional[str] = None - hotlink_protection: Optional[str] = None - # Zone state - development_mode: Optional[str] = None - always_online: Optional[str] = None - - -class CloudflareZone(BaseModel): - """Cloudflare zone representation used across services.""" - - id: str - name: str - status: Optional[str] = None - paused: bool = False - account: Optional[CloudflareAccount] = None - plan: Optional[str] = None - settings: CloudflareZoneSettings = Field(default_factory=CloudflareZoneSettings) - dnssec_status: Optional[str] = None diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 6455a21b69..c2395edd4d 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -251,6 +251,7 @@ class Provider(ABC): elif "cloudflare" in provider_class_name.lower(): provider_class( filter_zones=arguments.region, + filter_accounts=arguments.account_id, config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, diff --git a/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/__init__.py b/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate.metadata.json new file mode 100644 index 0000000000..fe8d76dac0 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "gcp", + "CheckID": "compute_instance_on_host_maintenance_migrate", + "CheckTitle": "Compute Engine VM instance has On Host Maintenance set to MIGRATE", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "compute.googleapis.com/Instance", + "ResourceGroup": "compute", + "Description": "**Compute Engine VM instances** should have their **On Host Maintenance** setting configured to `MIGRATE` for live migration during host maintenance events, ensuring continuous availability without downtime.", + "Risk": "VM instances configured with On Host Maintenance set to `TERMINATE` will be shut down during host maintenance events, causing **service interruptions** and **unplanned downtime**. This can impact application availability and may require manual intervention to restart services.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/configure-maintenance-behavior.html", + "https://cloud.google.com/compute/docs/instances/setting-instance-scheduling-options" + ], + "Remediation": { + "Code": { + "CLI": "gcloud compute instances set-scheduling --maintenance-policy=MIGRATE --zone=", + "NativeIaC": "", + "Other": "1. Open Google Cloud Console and navigate to Compute Engine > VM instances\n2. Click on the instance name to view details\n3. Click 'Edit' at the top of the page\n4. Under 'Availability policies', set 'On host maintenance' to 'Migrate VM instance (recommended)'\n5. Click 'Save' at the bottom of the page", + "Terraform": "```hcl\n# Example: configure On Host Maintenance to MIGRATE for a Compute Engine VM instance\nresource \"google_compute_instance\" \"example\" {\n name = var.instance_name\n machine_type = var.machine_type\n zone = var.zone\n\n scheduling {\n # Live migrate during host maintenance events\n on_host_maintenance = \"MIGRATE\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure VM instances to use **live migration** during host maintenance events to ensure continuous availability. This is the recommended setting for production workloads that require high availability.", + "Url": "https://hub.prowler.com/check/compute_instance_on_host_maintenance_migrate" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "compute_instance_automatic_restart_enabled" + ], + "Notes": "Preemptible and Spot VMs cannot use MIGRATE and will always be TERMINATE. The default value for this setting is MIGRATE." +} diff --git a/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate.py b/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate.py new file mode 100644 index 0000000000..a7be066c95 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_instance_on_host_maintenance_migrate(Check): + """ + Ensure Compute Engine VM instances have On Host Maintenance set to MIGRATE. + + This check evaluates whether VM instances are configured to live migrate during + host maintenance events, preventing downtime when Google performs maintenance. + + - PASS: VM instance has On Host Maintenance set to MIGRATE. + - FAIL: VM instance has On Host Maintenance set to TERMINATE. + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + for instance in compute_client.instances: + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + + if instance.on_host_maintenance == "MIGRATE": + report.status = "PASS" + report.status_extended = f"VM Instance {instance.name} has On Host Maintenance set to MIGRATE." + else: + report.status = "FAIL" + if instance.preemptible or instance.provisioning_model == "SPOT": + vm_type = "preemptible" if instance.preemptible else "Spot" + report.status_extended = ( + f"VM Instance {instance.name} is a {vm_type} VM and has On Host Maintenance set to TERMINATE. " + f"{vm_type.capitalize()} VMs cannot use MIGRATE and must always use TERMINATE. " + f"If high availability is required, consider using a non-preemptible VM instead." + ) + else: + report.status_extended = ( + f"VM Instance {instance.name} has On Host Maintenance set to " + f"{instance.on_host_maintenance} instead of MIGRATE." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/__init__.py b/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.metadata.json new file mode 100644 index 0000000000..d90b06b906 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "gcp", + "CheckID": "compute_instance_suspended_without_persistent_disks", + "CheckTitle": "Suspended VM instance does not have persistent disks attached", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "compute.googleapis.com/Instance", + "ResourceGroup": "compute", + "Description": "This check identifies VM instances in a **SUSPENDED** or **SUSPENDING** state with persistent disks still attached.\n\nPersistent disks on suspended VMs remain accessible through the GCP API and could contain **sensitive data** while the instance is inactive, potentially creating security blind spots in long-forgotten infrastructure.", + "Risk": "Persistent disks on suspended VM instances remain accessible through the GCP API and may contain **sensitive data**, creating potential security risks:\n\n- **Unauthorized data access** if credentials are compromised or permissions are misconfigured\n- **Data exposure** from forgotten infrastructure that is no longer actively monitored\n- **Security blind spots** where suspended resources are overlooked during security reviews and audits", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/icompute/docs/instances/suspend-resume-instance", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/persistent-disks-attached-to-suspended-vms.html" + ], + "Remediation": { + "Code": { + "CLI": "gcloud compute instances delete INSTANCE_NAME --zone=ZONE", + "NativeIaC": "", + "Other": "1. Open the Google Cloud Console\n2. Navigate to Compute Engine > VM instances\n3. Identify suspended instances with attached disks\n4. If the instance is no longer needed, select it and click DELETE\n5. If the instance will be resumed, take no action or resume it with: gcloud compute instances resume INSTANCE_NAME --zone=ZONE", + "Terraform": "```hcl\n# To remediate, either delete the suspended instance or resume it\n# Delete by removing the resource from your Terraform configuration\n# Or resume by changing the desired_status\nresource \"google_compute_instance\" \"example_resource\" {\n name = \"example-instance\"\n machine_type = \"e2-medium\"\n zone = \"us-central1-a\"\n\n # Set desired_status to RUNNING to resume the instance\n desired_status = \"RUNNING\"\n\n boot_disk {\n initialize_params {\n image = \"debian-cloud/debian-11\"\n }\n }\n\n network_interface {\n network = \"default\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Regularly review suspended VM instances to reduce your attack surface. Either **resume** instances if still needed, or **delete** them along with their attached disks to eliminate potential data exposure. Implement automated policies to detect and alert on long-suspended instances as part of your security monitoring.", + "Url": "https://hub.prowler.com/check/compute_instance_suspended_without_persistent_disks" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [ + "compute_instance_disk_auto_delete_disabled" + ], + "Notes": "This check is focused on security risks rather than cost optimization. Persistent disks on suspended VMs remain accessible and may contain sensitive data, creating potential unauthorized access risks." +} diff --git a/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.py b/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.py new file mode 100644 index 0000000000..a9307ec803 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_instance_suspended_without_persistent_disks(Check): + """ + Ensure that VM instances in SUSPENDED state do not have persistent disks attached. + + This check identifies VM instances that are in a SUSPENDED or SUSPENDING state + and have persistent disks still attached. Suspended VMs with attached disks + represent unused infrastructure that continues to incur storage costs. + + - PASS: VM instance is not in SUSPENDED/SUSPENDING state, or is suspended but has no disks attached. + - FAIL: VM instance is in SUSPENDED/SUSPENDING state with persistent disks attached. + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + for instance in compute_client.instances: + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + report.status = "PASS" + report.status_extended = f"VM Instance {instance.name} is not suspended." + + if instance.status in ("SUSPENDED", "SUSPENDING"): + attached_disks = [disk.name for disk in instance.disks] + + if attached_disks: + report.status = "FAIL" + report.status_extended = f"VM Instance {instance.name} is {instance.status.lower()} with {len(attached_disks)} persistent disk(s) attached: {', '.join(attached_disks)}." + else: + report.status_extended = f"VM Instance {instance.name} is {instance.status.lower()} but has no persistent disks attached." + + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/__init__.py b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.metadata.json b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.metadata.json new file mode 100644 index 0000000000..bec06a7838 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "gcp", + "CheckID": "compute_project_os_login_2fa_enabled", + "CheckTitle": "GCP project has OS Login with 2FA enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "compute.googleapis.com/Project", + "ResourceGroup": "governance", + "Description": "OS Login **Two-Factor Authentication (2FA)** requires users to verify their identity with a second factor when connecting via SSH to VM instances.\n\nThis provides an additional security layer beyond passwords or SSH keys, significantly reducing the risk of unauthorized access even if credentials are compromised.", + "Risk": "Without 2FA enforcement, compromised credentials (stolen SSH keys or passwords) grant immediate access to VM instances. Attackers could:\n\n- Gain unauthorized shell access to production systems\n- Exfiltrate sensitive data or deploy malware\n- Move laterally within the infrastructure\n\nThis single point of failure significantly increases the attack surface.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/compute/docs/oslogin/set-up-oslogin", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/enable-os-login-with-2fa-authentication.html" + ], + "Remediation": { + "Code": { + "CLI": "gcloud compute project-info add-metadata --metadata enable-oslogin=TRUE,enable-oslogin-2fa=TRUE", + "NativeIaC": "", + "Other": "1. Navigate to **Compute Engine** > **Metadata** in Google Cloud Console\n2. Click **Edit**\n3. Add or update metadata entry with key `enable-oslogin-2fa` and value `TRUE`\n4. Ensure `enable-oslogin` is also set to `TRUE`\n5. Click **Save**", + "Terraform": "```hcl\nresource \"google_compute_project_metadata\" \"example_resource\" {\n metadata = {\n enable-oslogin = \"TRUE\"\n enable-oslogin-2fa = \"TRUE\" # Enables 2FA for OS Login\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable OS Login with 2FA at the project level to enforce multi-factor authentication for all SSH connections. This adds a critical security layer by requiring users to complete a second verification step, protecting against credential theft and unauthorized access.", + "Url": "https://hub.prowler.com/check/compute_project_os_login_2fa_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [ + "compute_project_os_login_enabled" + ], + "RelatedTo": [ + "compute_project_os_login_enabled" + ], + "Notes": "OS Login 2FA requires OS Login to be enabled first. Users must have 2-Step Verification configured in their Google account. For organizations, 2FA can be enforced via Google Workspace or Cloud Identity policies." +} diff --git a/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.py b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.py new file mode 100644 index 0000000000..1451b2c5c6 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_project_os_login_2fa_enabled(Check): + """Ensure that OS Login with 2FA is enabled for a GCP project. + + This check verifies that OS Login Two-Factor Authentication (2FA) is enabled + at the project level to enforce an additional layer of security for SSH access + to VM instances. + + - PASS: Project has OS Login 2FA enabled (enable-oslogin-2fa=TRUE). + - FAIL: Project does not have OS Login 2FA enabled. + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + for project in compute_client.compute_projects: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=compute_client.projects[project.id], + project_id=project.id, + location=compute_client.region, + resource_name=( + compute_client.projects[project.id].name + if compute_client.projects[project.id].name + else "GCP Project" + ), + ) + report.status = "PASS" + report.status_extended = f"Project {project.id} has OS Login 2FA enabled." + if not project.enable_oslogin_2fa: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.id} does not have OS Login 2FA enabled." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/compute/compute_service.py b/prowler/providers/gcp/services/compute/compute_service.py index efe40dd765..0965142766 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from pydantic.v1 import BaseModel @@ -22,6 +23,7 @@ class Compute(GCPService): self.load_balancers = [] self.instance_groups = [] self.images = [] + self.snapshots = [] self._get_regions() self._get_projects() self._get_url_maps() @@ -36,6 +38,7 @@ class Compute(GCPService): self.__threading_call__(self._get_zonal_instance_groups, self.zones) self._associate_migs_with_load_balancers() self._get_images() + self._get_snapshots() def _get_regions(self): for project_id in self.project_ids: @@ -77,6 +80,7 @@ class Compute(GCPService): for project_id in self.project_ids: try: enable_oslogin = False + enable_oslogin_2fa = False response = ( self.client.projects() .get(project=project_id) @@ -85,8 +89,14 @@ class Compute(GCPService): for item in response["commonInstanceMetadata"].get("items", []): if item["key"] == "enable-oslogin" and item["value"] == "TRUE": enable_oslogin = True + if item["key"] == "enable-oslogin-2fa" and item["value"] == "TRUE": + enable_oslogin_2fa = True self.compute_projects.append( - Project(id=project_id, enable_oslogin=enable_oslogin) + Project( + id=project_id, + enable_oslogin=enable_oslogin, + enable_oslogin_2fa=enable_oslogin_2fa, + ) ) except Exception as error: logger.error( @@ -188,6 +198,10 @@ class Compute(GCPService): "deletionProtection", False ), network_interfaces=network_interfaces, + status=instance.get("status", "RUNNING"), + on_host_maintenance=instance.get("scheduling", {}).get( + "onHostMaintenance", "MIGRATE" + ), ) ) @@ -602,6 +616,57 @@ class Compute(GCPService): f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_snapshots(self) -> None: + for project_id in self.project_ids: + try: + request = self.client.snapshots().list(project=project_id) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for snapshot in response.get("items", []): + # Parse creation timestamp to datetime + creation_timestamp_str = snapshot.get("creationTimestamp", "") + creation_timestamp = None + if creation_timestamp_str: + try: + # GCP timestamps are in RFC 3339 format + creation_timestamp = datetime.fromisoformat( + creation_timestamp_str.replace("Z", "+00:00") + ) + except ValueError: + logger.error( + f"Could not parse timestamp {creation_timestamp_str} for snapshot {snapshot['name']}" + ) + + # Extract source disk name from the full URL + source_disk_url = snapshot.get("sourceDisk", "") + source_disk = ( + source_disk_url.split("/")[-1] if source_disk_url else "" + ) + + self.snapshots.append( + Snapshot( + name=snapshot["name"], + id=snapshot["id"], + project_id=project_id, + creation_timestamp=creation_timestamp, + source_disk=source_disk, + source_disk_id=snapshot.get("sourceDiskId"), + disk_size_gb=int(snapshot.get("diskSizeGb", 0)), + storage_bytes=int(snapshot.get("storageBytes", 0)), + storage_locations=snapshot.get("storageLocations", []), + status=snapshot.get("status", ""), + auto_created=snapshot.get("autoCreated", False), + ) + ) + + request = self.client.snapshots().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class NetworkInterface(BaseModel): name: str @@ -636,6 +701,8 @@ class Instance(BaseModel): provisioning_model: str = "STANDARD" deletion_protection: bool = False network_interfaces: list[NetworkInterface] = [] + status: str = "RUNNING" + on_host_maintenance: str = "MIGRATE" class Network(BaseModel): @@ -675,6 +742,7 @@ class Firewall(BaseModel): class Project(BaseModel): id: str enable_oslogin: bool + enable_oslogin_2fa: bool = False class LoadBalancer(BaseModel): @@ -708,3 +776,17 @@ class Image(BaseModel): id: str project_id: str publicly_shared: bool = False + + +class Snapshot(BaseModel): + name: str + id: str + project_id: str + creation_timestamp: Optional[datetime] = None + source_disk: str = "" + source_disk_id: Optional[str] = None + disk_size_gb: int = 0 + storage_bytes: int = 0 + storage_locations: list[str] = [] + status: str = "" + auto_created: bool = False diff --git a/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/__init__.py b/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated.metadata.json b/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated.metadata.json new file mode 100644 index 0000000000..ddf5920900 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "compute_snapshot_not_outdated", + "CheckTitle": "Compute Engine disk snapshot is not outdated", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "compute.googleapis.com/Snapshot", + "ResourceGroup": "storage", + "Description": "Compute Engine **disk snapshots** are evaluated against a configurable age threshold (default `90` days) to identify snapshots exceeding the organization's retention policy.", + "Risk": "Outdated snapshots containing **sensitive data** expand the **attack surface** and risk data exposure if compromised.\n\nStale snapshots may violate compliance requirements, complicate disaster recovery efforts, and introduce configuration drift that affects system **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/remove-old-disk-snapshots.html", + "https://cloud.google.com/compute/docs/disks/create-snapshots", + "https://cloud.google.com/compute/docs/disks/snapshot-best-practices" + ], + "Remediation": { + "Code": { + "CLI": "gcloud compute snapshots delete SNAPSHOT_NAME --project=PROJECT_ID", + "NativeIaC": "", + "Other": "1. Open Google Cloud Console and navigate to Compute Engine > Snapshots\n2. Identify snapshots older than your retention policy\n3. Select outdated snapshots and click **Delete**\n4. Confirm the deletion\n\nTo automate cleanup, create a snapshot schedule with auto-delete policies under Compute Engine > Snapshots > Snapshot schedules.", + "Terraform": "```hcl\nresource \"google_compute_resource_policy\" \"snapshot_schedule\" {\n name = \"snapshot-schedule-with-retention\"\n region = var.region\n\n snapshot_schedule_policy {\n schedule {\n daily_schedule {\n days_in_cycle = 1\n start_time = \"04:00\"\n }\n }\n\n # Automatically delete snapshots older than 90 days\n retention_policy {\n max_retention_days = 90\n on_source_disk_delete = \"KEEP_AUTO_SNAPSHOTS\"\n }\n }\n}\n\nresource \"google_compute_disk_resource_policy_attachment\" \"attachment\" {\n name = google_compute_resource_policy.snapshot_schedule.name\n disk = google_compute_disk.example.name\n zone = var.zone\n}\n```" + }, + "Recommendation": { + "Text": "Implement a snapshot lifecycle policy to automatically delete snapshots older than your organization's retention requirements. Regularly review and clean up outdated snapshots to reduce storage costs and minimize data exposure risks. Consider using scheduled snapshots with automatic deletion policies.", + "Url": "https://hub.prowler.com/check/compute_snapshot_not_outdated" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The age threshold is configurable via the `max_snapshot_age_days` parameter in the configuration file (default: 90 days). Snapshots without a creation timestamp will be flagged for manual review." +} diff --git a/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated.py b/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated.py new file mode 100644 index 0000000000..8537cd390e --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated.py @@ -0,0 +1,60 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_snapshot_not_outdated(Check): + """Check that Compute Engine disk snapshots are not outdated. + + This check ensures Compute Engine disk snapshots are within the configured + age threshold (default 90 days) to help control storage costs and limit + exposure from stale data. + + - PASS: Snapshot is not outdated (within the acceptable age threshold). + - FAIL: Snapshot is outdated (exceeds the configured age threshold). + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + + max_snapshot_age_days = compute_client.audit_config.get( + "max_snapshot_age_days", 90 + ) + + current_time = datetime.now(timezone.utc) + + for snapshot in compute_client.snapshots: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=snapshot, + location="global", + ) + + if snapshot.creation_timestamp is None: + report.status = "FAIL" + report.status_extended = ( + f"Disk snapshot {snapshot.name} timestamp could not be retrieved " + "and cannot be evaluated for age." + ) + findings.append(report) + continue + + snapshot_age = (current_time - snapshot.creation_timestamp).days + + if snapshot_age > max_snapshot_age_days: + report.status = "FAIL" + report.status_extended = ( + f"Disk snapshot {snapshot.name} is {snapshot_age} days old, " + f"exceeding the {max_snapshot_age_days} day threshold." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Disk snapshot {snapshot.name} is {snapshot_age} days old, " + f"within the {max_snapshot_age_days} day threshold." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 6abda172f9..81202b5a84 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -823,6 +823,52 @@ class M365PowerShell(PowerShellSession): "Get-SharingPolicy | ConvertTo-Json -Depth 10", json_parse=True ) + def get_teams_protection_policy(self) -> dict: + """ + Get Teams Protection Policy. + + Retrieves the Teams protection policy settings including Zero-hour auto purge (ZAP) configuration. + + Returns: + dict: Teams protection policy settings in JSON format. + + Example: + >>> get_teams_protection_policy() + { + "Identity": "Teams Protection Policy", + "ZapEnabled": True + } + """ + return self.execute( + "Get-TeamsProtectionPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) + + def get_shared_mailboxes(self) -> dict: + """ + Get Exchange Online Shared Mailboxes. + + Retrieves all shared mailboxes from Exchange Online with their external + directory object IDs for cross-referencing with Entra ID user accounts. + + Returns: + dict: Shared mailbox information in JSON format. + + Example: + >>> get_shared_mailboxes() + [ + { + "DisplayName": "Support Mailbox", + "UserPrincipalName": "support@contoso.com", + "ExternalDirectoryObjectId": "12345678-1234-1234-1234-123456789012", + "Identity": "support@contoso.com" + } + ] + """ + return self.execute( + "Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited | Select-Object DisplayName, UserPrincipalName, ExternalDirectoryObjectId, Identity | ConvertTo-Json -Depth 10", + json_parse=True, + ) + def get_user_account_status(self) -> dict: """ Get User Account Status. @@ -833,7 +879,7 @@ class M365PowerShell(PowerShellSession): dict: User account status settings in JSON format. """ return self.execute( - "$dict=@{}; Get-User -ResultSize Unlimited | ForEach-Object { $dict[$_.Id] = @{ AccountDisabled = $_.AccountDisabled } }; $dict | ConvertTo-Json -Depth 10", + "$dict=@{}; Get-User -ResultSize Unlimited | ForEach-Object { $dict[$_.ExternalDirectoryObjectId] = @{ AccountDisabled = $_.AccountDisabled } }; $dict | ConvertTo-Json -Depth 10", json_parse=True, ) diff --git a/prowler/providers/m365/services/defender/defender_service.py b/prowler/providers/m365/services/defender/defender_service.py index d3ede5734a..09aa39986d 100644 --- a/prowler/providers/m365/services/defender/defender_service.py +++ b/prowler/providers/m365/services/defender/defender_service.py @@ -8,7 +8,20 @@ from prowler.providers.m365.m365_provider import M365Provider class Defender(M365Service): + """ + Microsoft 365 Defender service implementation. + + Provides access to Microsoft Defender for Office 365 configurations including + malware policies, spam filtering, anti-phishing, and Teams protection settings. + """ + def __init__(self, provider: M365Provider): + """ + Initialize the Defender service. + + Args: + provider: The M365 provider instance. + """ super().__init__(provider) self.malware_policies = [] self.outbound_spam_policies = {} @@ -20,6 +33,7 @@ class Defender(M365Service): self.inbound_spam_policies = [] self.inbound_spam_rules = {} self.report_submission_policy = None + self.teams_protection_policy = None if self.powershell: if self.powershell.connect_exchange_online(): self.malware_policies = self._get_malware_filter_policy() @@ -33,6 +47,7 @@ class Defender(M365Service): self.inbound_spam_policies = self._get_inbound_spam_filter_policy() self.inbound_spam_rules = self._get_inbound_spam_filter_rule() self.report_submission_policy = self._get_report_submission_policy() + self.teams_protection_policy = self._get_teams_protection_policy() self.powershell.close() def _get_malware_filter_policy(self): @@ -350,6 +365,12 @@ class Defender(M365Service): return inbound_spam_rules def _get_report_submission_policy(self): + """ + Retrieve the Defender report submission policy. + + Returns: + ReportSubmissionPolicy: The report submission policy configuration. + """ logger.info("Microsoft365 - Getting Defender report submission policy...") report_submission_policy = None try: @@ -387,6 +408,28 @@ class Defender(M365Service): ) return report_submission_policy + def _get_teams_protection_policy(self): + """ + Retrieve the Teams protection policy including ZAP settings. + + Returns: + TeamsProtectionPolicy: The Teams protection policy configuration. + """ + logger.info("Microsoft365 - Getting Teams protection policy...") + teams_protection_policy = None + try: + policy = self.powershell.get_teams_protection_policy() + if policy: + teams_protection_policy = TeamsProtectionPolicy( + identity=policy.get("Identity", ""), + zap_enabled=policy.get("ZapEnabled", True), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return teams_protection_policy + class MalwarePolicy(BaseModel): enable_file_filter: bool @@ -470,6 +513,8 @@ class InboundSpamRule(BaseModel): class ReportSubmissionPolicy(BaseModel): + """Model for Defender report submission policy settings.""" + report_junk_to_customized_address: bool report_not_junk_to_customized_address: bool report_phish_to_customized_address: bool @@ -478,3 +523,10 @@ class ReportSubmissionPolicy(BaseModel): report_phish_addresses: list[str] report_chat_message_enabled: bool report_chat_message_to_customized_address_enabled: bool + + +class TeamsProtectionPolicy(BaseModel): + """Model for Teams protection policy settings including ZAP configuration.""" + + identity: str + zap_enabled: bool diff --git a/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/__init__.py b/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.metadata.json new file mode 100644 index 0000000000..d2df3f8892 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "defender_zap_for_teams_enabled", + "CheckTitle": "Zero-hour auto purge (ZAP) protects Microsoft Teams from malware and phishing", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Teams Protection Policy", + "ResourceGroup": "collaboration", + "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes **malware** and **high confidence phishing** in Teams messages.\n\nWhen ZAP blocks a message, it is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP can occur up to 48 hours after delivery.", + "Risk": "Without ZAP enabled, malicious content delivered to Teams chats remains accessible to users for up to 48 hours after delivery, even after being identified as harmful.\n\nThis extended exposure window could lead to:\n- **Malware infections** from weaponized attachments or links\n- **Phishing attacks** compromising user credentials and MFA tokens\n- **Lateral movement** as attackers exploit compromised accounts within the organization", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/zero-hour-auto-purge?view=o365-worldwide#zero-hour-auto-purge-zap-in-microsoft-teams", + "https://learn.microsoft.com/en-us/defender-office-365/mdo-support-teams-about?view=o365-worldwide" + ], + "Remediation": { + "Code": { + "CLI": "Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Defender https://security.microsoft.com/\n2. Click to expand System and select Settings > Email & collaboration > Microsoft Teams protection\n3. Set Zero-hour auto purge (ZAP) to On (Default)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Zero-hour auto purge (ZAP) for Microsoft Teams to ensure malicious content is automatically removed from chats after detection, even if it was delivered before being identified as harmful.", + "Url": "https://hub.prowler.com/check/defender_zap_for_teams_enabled" + } + }, + "Categories": [ + "email-security", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.py b/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.py new file mode 100644 index 0000000000..6287426dbf --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.py @@ -0,0 +1,53 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.defender.defender_client import defender_client + + +class defender_zap_for_teams_enabled(Check): + """Check if Zero-hour auto purge (ZAP) is enabled for Microsoft Teams. + + ZAP is a protection feature that retroactively detects and neutralizes malware + and high confidence phishing in Teams messages. + + - PASS: ZAP is enabled for Teams protection. + - FAIL: ZAP is not enabled for Teams protection. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the check for Teams ZAP protection status. + + This method checks if Zero-hour auto purge (ZAP) is enabled for Microsoft Teams + to ensure malicious content is automatically removed from chats after detection. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + teams_protection_policy = defender_client.teams_protection_policy + + if teams_protection_policy: + report = CheckReportM365( + metadata=self.metadata(), + resource=teams_protection_policy, + resource_name="Teams Protection Policy", + resource_id="teamsProtectionPolicy", + ) + + if teams_protection_policy.zap_enabled: + report.status = "PASS" + report.status_extended = ( + "Zero-hour auto purge (ZAP) is enabled for Microsoft Teams." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Zero-hour auto purge (ZAP) is not enabled for Microsoft Teams." + ) + + 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 e7751fdef9..4addbd07a2 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -402,18 +402,7 @@ class Entra(M365Service): for member in members: user_roles_map.setdefault(member.id, []).append(role_template_id) - try: - registration_details_list = ( - await self.client.reports.authentication_methods.user_registration_details.get() - ) - registration_details = { - detail.id: detail for detail in registration_details_list.value - } - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - registration_details = {} + registration_details = await self._get_user_registration_details() while users_response: for user in getattr(users_response, "value", []) or []: @@ -424,11 +413,7 @@ class Entra(M365Service): True if (user.on_premises_sync_enabled) else False ), directory_roles_ids=user_roles_map.get(user.id, []), - is_mfa_capable=( - registration_details.get(user.id, {}).is_mfa_capable - if registration_details.get(user.id, None) is not None - else False - ), + is_mfa_capable=(registration_details.get(user.id, False)), account_enabled=not self.user_accounts_status.get( user.id, {} ).get("AccountDisabled", False), @@ -444,6 +429,38 @@ class Entra(M365Service): ) return users + async def _get_user_registration_details(self): + registration_details = {} + try: + registration_builder = ( + self.client.reports.authentication_methods.user_registration_details + ) + registration_response = await registration_builder.get() + + while registration_response: + for detail in getattr(registration_response, "value", []) or []: + registration_details.update( + {detail.id: getattr(detail, "is_mfa_capable", False)} + ) + + next_link = getattr(registration_response, "odata_next_link", None) + if not next_link: + break + registration_response = await registration_builder.with_url( + next_link + ).get() + + except Exception as error: + if ( + error.__class__.__name__ == "ODataError" + and error.__dict__.get("response_status_code", None) == 403 + ): + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return registration_details + class ConditionalAccessPolicyState(Enum): ENABLED = "enabled" diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 1c8f1d1e87..978a9b2a10 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -9,7 +9,20 @@ from prowler.providers.m365.m365_provider import M365Provider class Exchange(M365Service): + """ + Exchange Online service for Microsoft 365. + + This service provides access to Exchange Online resources and configurations + including organization settings, mailboxes, transport rules, and policies. + """ + def __init__(self, provider: M365Provider): + """ + Initialize the Exchange service. + + Args: + provider: The M365Provider instance for authentication and configuration. + """ super().__init__(provider) self.organization_config = None self.mailboxes_config = [] @@ -19,6 +32,7 @@ class Exchange(M365Service): self.mailbox_policies = [] self.role_assignment_policies = [] self.mailbox_audit_properties = [] + self.shared_mailboxes = [] if self.powershell: if self.powershell.connect_exchange_online(): @@ -30,6 +44,7 @@ class Exchange(M365Service): self.mailbox_policies = self._get_mailbox_policy() self.role_assignment_policies = self._get_role_assignment_policies() self.mailbox_audit_properties = self._get_mailbox_audit_properties() + self.shared_mailboxes = self._get_shared_mailboxes() self.powershell.close() def _get_organization_config(self): @@ -211,6 +226,12 @@ class Exchange(M365Service): return role_assignment_policies def _get_mailbox_audit_properties(self): + """ + Get mailbox audit properties for all mailboxes. + + Returns: + list[MailboxAuditProperties]: List of mailbox audit property configurations. + """ logger.info("Microsoft365 - Getting mailbox audit properties...") mailbox_audit_properties = [] try: @@ -248,6 +269,44 @@ class Exchange(M365Service): ) return mailbox_audit_properties + def _get_shared_mailboxes(self): + """ + Get all shared mailboxes from Exchange Online. + + Retrieves shared mailboxes with their external directory object IDs + for cross-referencing with Entra ID user accounts. + + Returns: + list[SharedMailbox]: List of shared mailbox configurations. + """ + logger.info("Microsoft365 - Getting shared mailboxes...") + shared_mailboxes = [] + try: + shared_mailboxes_data = self.powershell.get_shared_mailboxes() + if not shared_mailboxes_data: + return shared_mailboxes + if isinstance(shared_mailboxes_data, dict): + shared_mailboxes_data = [shared_mailboxes_data] + for shared_mailbox in shared_mailboxes_data: + if shared_mailbox: + shared_mailboxes.append( + SharedMailbox( + name=shared_mailbox.get("DisplayName", ""), + user_principal_name=shared_mailbox.get( + "UserPrincipalName", "" + ), + external_directory_object_id=shared_mailbox.get( + "ExternalDirectoryObjectId", "" + ), + identity=shared_mailbox.get("Identity", ""), + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return shared_mailboxes + class Organization(BaseModel): name: str @@ -342,6 +401,8 @@ class AuditDelegate(Enum): class AuditOwner(Enum): + """Audit actions for mailbox owner operations.""" + APPLY_RECORD = "ApplyRecord" CREATE = "Create" HARD_DELETE = "HardDelete" @@ -353,3 +414,20 @@ class AuditOwner(Enum): UPDATE_CALENDAR_DELEGATION = "UpdateCalendarDelegation" UPDATE_FOLDER_PERMISSIONS = "UpdateFolderPermissions" UPDATE_INBOX_RULES = "UpdateInboxRules" + + +class SharedMailbox(BaseModel): + """ + Model for Exchange Online shared mailbox. + + Attributes: + name: Display name of the shared mailbox. + user_principal_name: User principal name (email) of the shared mailbox. + external_directory_object_id: The Entra ID object ID for cross-referencing. + identity: Identity of the shared mailbox in Exchange. + """ + + name: str + user_principal_name: str + external_directory_object_id: str + identity: str diff --git a/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/__init__.py b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.metadata.json new file mode 100644 index 0000000000..118868c158 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "exchange_shared_mailbox_sign_in_disabled", + "CheckTitle": "Shared mailbox has sign-in blocked", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Shared Mailbox", + "ResourceGroup": "IAM", + "Description": "Shared mailboxes are used for collaboration and should not permit direct sign-in. This check verifies that the **AccountEnabled** property is set to `false` in Entra ID for all shared mailboxes, preventing direct authentication.", + "Risk": "When sign-in is enabled on shared mailboxes, users with the password can bypass delegation controls and access the mailbox directly. This undermines **accountability** since actions cannot be attributed to individual users, and it increases the attack surface for credential-based attacks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/email/about-shared-mailboxes", + "https://learn.microsoft.com/en-us/microsoft-365/admin/email/create-a-shared-mailbox#block-sign-in-for-the-shared-mailbox-account" + ], + "Remediation": { + "Code": { + "CLI": "Get-EXOMailbox -RecipientTypeDetails SharedMailbox | ForEach-Object { Update-MgUser -UserId $_.ExternalDirectoryObjectId -AccountEnabled:$false }", + "NativeIaC": "", + "Other": "1. Navigate to Entra admin center (https://entra.microsoft.com/)\n2. Expand Identity > Users and select All users\n3. Search for and select the shared mailbox user account\n4. In the properties pane, go to Account status\n5. Uncheck 'Account enabled' and click Save\n6. Repeat for all shared mailbox accounts", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block sign-in for all shared mailboxes to ensure users can only access them through delegation. This enforces accountability and reduces security risks from shared credentials.", + "Url": "https://hub.prowler.com/check/exchange_shared_mailbox_sign_in_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.py b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.py new file mode 100644 index 0000000000..849731f7c8 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.py @@ -0,0 +1,59 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.exchange.exchange_client import exchange_client + + +class exchange_shared_mailbox_sign_in_disabled(Check): + """ + Verify that sign-in is blocked for all shared mailboxes. + + Shared mailboxes are designed for collaboration and should not permit direct + sign-in. Users should access shared mailboxes through delegation only, which + ensures accountability and proper access controls. + + - PASS: Shared mailbox has sign-in blocked (AccountEnabled = False in Entra ID). + - FAIL: Shared mailbox has sign-in enabled (AccountEnabled = True in Entra ID). + """ + + def execute(self) -> List[CheckReportM365]: + """ + Execute the check to verify shared mailbox sign-in status. + + Cross-references shared mailboxes from Exchange Online with user accounts + in Entra ID to determine if sign-in is blocked. + + Returns: + List[CheckReportM365]: A list of reports with the sign-in status for + each shared mailbox. + """ + findings = [] + + for shared_mailbox in exchange_client.shared_mailboxes: + report = CheckReportM365( + metadata=self.metadata(), + resource=shared_mailbox, + resource_name=shared_mailbox.name or shared_mailbox.user_principal_name, + resource_id=shared_mailbox.external_directory_object_id + or shared_mailbox.identity, + ) + + # Look up the user in Entra ID by their external directory object ID + entra_user = entra_client.users.get( + shared_mailbox.external_directory_object_id + ) + + if not entra_user: + report.status = "FAIL" + report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} could not be found in Entra ID for verification." + elif entra_user.account_enabled: + report.status = "FAIL" + report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} has sign-in enabled." + else: + report.status = "PASS" + report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} has sign-in blocked." + + findings.append(report) + + return findings diff --git a/pyproject.toml b/pyproject.toml index b9bbbd800e..01c19efcf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,8 @@ dependencies = [ "azure-monitor-query==2.0.0", "azure-storage-blob==12.24.1", "cloudflare==4.3.1", - "boto3==1.39.15", - "botocore==1.39.15", + "boto3==1.40.61", + "botocore==1.40.61", "colorama==0.4.6", "cryptography==44.0.1", "dash==3.1.1", @@ -65,7 +65,7 @@ dependencies = [ "pytz==2025.1", "schema==0.7.5", "shodan==1.31.0", - "slack-sdk==3.34.0", + "slack-sdk==3.39.0", "tabulate==0.9.0", "tzlocal==5.3.1", "py-iam-expand==0.1.0", @@ -91,7 +91,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">3.9.1,<3.13" -version = "5.17.0" +version = "5.18.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -128,7 +128,11 @@ pytest-cov = "6.0.0" pytest-env = "1.1.5" pytest-randomly = "3.16.0" pytest-xdist = "3.6.1" -safety = "3.2.9" +safety = "3.7.0" +filelock = [ + {version = "3.20.3", python = ">=3.10"}, + {version = "3.19.1", python = "<3.10"} +] vulture = "2.14" [tool.poetry-version-plugin] diff --git a/skills/django-drf/SKILL.md b/skills/django-drf/SKILL.md index df740a3e4d..93ea6219f1 100644 --- a/skills/django-drf/SKILL.md +++ b/skills/django-drf/SKILL.md @@ -2,185 +2,504 @@ name: django-drf description: > Django REST Framework patterns. - Trigger: When implementing generic DRF APIs (ViewSets, serializers, routers, permissions, filtersets). For Prowler API specifics (RLS/JSON:API), also use prowler-api. + Trigger: When implementing generic DRF APIs (ViewSets, serializers, routers, permissions, filtersets). For Prowler API specifics (RLS/RBAC/Providers), also use prowler-api. license: Apache-2.0 metadata: author: prowler-cloud - version: "1.0" + version: "1.2.0" scope: [root, api] - auto_invoke: "Generic DRF patterns" + auto_invoke: + - "Creating ViewSets, serializers, or filters in api/" + - "Implementing JSON:API endpoints" + - "Adding DRF pagination or permissions" allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task --- -## ViewSet Pattern +## Critical Patterns -```python -from rest_framework import viewsets, status -from rest_framework.response import Response -from rest_framework.decorators import action +- ALWAYS separate serializers by operation: Read / Create / Update / Include +- ALWAYS use `filterset_class` for complex filtering (not `filterset_fields`) +- ALWAYS validate unknown fields in write serializers (inherit `BaseWriteSerializer`) +- ALWAYS use `select_related`/`prefetch_related` in `get_queryset()` to avoid N+1 +- ALWAYS handle `swagger_fake_view` in `get_queryset()` for schema generation +- ALWAYS use `@extend_schema_field` for OpenAPI docs on `SerializerMethodField` +- NEVER put business logic in serializers - use services/utils +- NEVER use auto-increment PKs - use UUIDv4 or UUIDv7 +- NEVER use trailing slashes in URLs (`trailing_slash=False`) -class UserViewSet(viewsets.ModelViewSet): - queryset = User.objects.all() - serializer_class = UserSerializer - filterset_class = UserFilter - permission_classes = [IsAuthenticated] +> **Note:** `swagger_fake_view` is specific to **drf-spectacular** for OpenAPI schema generation. - def get_serializer_class(self): - if self.action == "create": - return UserCreateSerializer - if self.action in ["update", "partial_update"]: - return UserUpdateSerializer - return UserSerializer +--- - @action(detail=True, methods=["post"]) - def activate(self, request, pk=None): - user = self.get_object() - user.is_active = True - user.save() - return Response({"status": "activated"}) +## Implementation Checklist + +When implementing a new endpoint, review these patterns in order: + +| # | Pattern | Reference | Key Points | +|---|---------|-----------|------------| +| 1 | **Models** | `api/models.py` | UUID PK, `inserted_at`/`updated_at`, `JSONAPIMeta.resource_name` | +| 2 | **ViewSets** | `api/base_views.py`, `api/v1/views.py` | Inherit `BaseRLSViewSet`, `get_queryset()` with N+1 prevention | +| 3 | **Serializers** | `api/v1/serializers.py` | Separate Read/Create/Update/Include, inherit `BaseWriteSerializer` | +| 4 | **Filters** | `api/filters.py` | Use `filterset_class`, inherit base filter classes | +| 5 | **Permissions** | `api/base_views.py` | `required_permissions`, `set_required_permissions()` | +| 6 | **Pagination** | `api/pagination.py` | Custom pagination class if needed | +| 7 | **URL Routing** | `api/v1/urls.py` | `trailing_slash=False`, kebab-case paths | +| 8 | **OpenAPI Schema** | `api/v1/views.py` | `@extend_schema_view` with drf-spectacular | +| 9 | **Tests** | `api/tests/test_views.py` | JSON:API content type, fixture patterns | + +> **Full file paths**: See [references/file-locations.md](references/file-locations.md) + +--- + +## Decision Trees + +### Which Serializer? ``` +GET list/retrieve β†’ Serializer +POST create β†’ CreateSerializer +PATCH update β†’ UpdateSerializer +?include=... β†’ IncludeSerializer +``` + +### Which Base Serializer? +``` +Read-only serializer β†’ BaseModelSerializerV1 +Create with tenant_id β†’ RLSSerializer + BaseWriteSerializer (auto-injects tenant_id on create) +Update with validation β†’ BaseWriteSerializer (tenant_id already exists on object) +Non-model data β†’ BaseSerializerV1 +``` + +### Which Filter Base? +``` +Direct FK to Provider β†’ BaseProviderFilter +FK via Scan β†’ BaseScanProviderFilter +No provider relation β†’ FilterSet +``` + +### Which Base ViewSet? +``` +RLS-protected model β†’ BaseRLSViewSet (most common) +Tenant operations β†’ BaseTenantViewset +User operations β†’ BaseUserViewset +No RLS required β†’ BaseViewSet (rare) +``` + +### Resource Name Format? +``` +Single word model β†’ plural lowercase (Provider β†’ providers) +Multi-word model β†’ plural lowercase kebab (ProviderGroup β†’ provider-groups) +Through/join model β†’ parent-child pattern (UserRoleRelationship β†’ user-roles) +Aggregation/overview β†’ descriptive kebab plural (ComplianceOverview β†’ compliance-overviews) +``` + +--- ## Serializer Patterns +### Base Class Hierarchy + ```python -from rest_framework import serializers - -# Read Serializer -class UserSerializer(serializers.ModelSerializer): - full_name = serializers.SerializerMethodField() - +# Read serializer (most common) +class ProviderSerializer(RLSSerializer): class Meta: - model = User - fields = ["id", "email", "full_name", "created_at"] - read_only_fields = ["id", "created_at"] - - def get_full_name(self, obj): - return f"{obj.first_name} {obj.last_name}" - -# Create Serializer -class UserCreateSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True) + model = Provider + fields = ["id", "provider", "uid", "alias", "connected", "inserted_at"] +# Write serializer (validates unknown fields) +class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer): class Meta: - model = User - fields = ["email", "password", "first_name", "last_name"] + model = Provider + fields = ["provider", "uid", "alias"] - def create(self, validated_data): - password = validated_data.pop("password") - user = User(**validated_data) - user.set_password(password) - user.save() - return user - -# Update Serializer -class UserUpdateSerializer(serializers.ModelSerializer): +# Include serializer (sparse fields for ?include=) +class ProviderIncludeSerializer(RLSSerializer): class Meta: - model = User - fields = ["first_name", "last_name"] + model = Provider + fields = ["id", "alias"] # Minimal fields ``` -## Filters +### SerializerMethodField with OpenAPI ```python -from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema_field -class UserFilter(filters.FilterSet): - email = filters.CharFilter(lookup_expr="icontains") - is_active = filters.BooleanFilter() - created_after = filters.DateTimeFilter( - field_name="created_at", - lookup_expr="gte" - ) - created_before = filters.DateTimeFilter( - field_name="created_at", - lookup_expr="lte" - ) +class ProviderSerializer(RLSSerializer): + connection = serializers.SerializerMethodField(read_only=True) - class Meta: - model = User - fields = ["email", "is_active"] + @extend_schema_field({ + "type": "object", + "properties": { + "connected": {"type": "boolean"}, + "last_checked_at": {"type": "string", "format": "date-time"}, + }, + }) + def get_connection(self, obj): + return { + "connected": obj.connected, + "last_checked_at": obj.connection_last_checked_at, + } ``` -## Permissions +### Included Serializers (JSON:API) ```python -from rest_framework.permissions import BasePermission +class ScanSerializer(RLSSerializer): + included_serializers = { + "provider": "api.v1.serializers.ProviderIncludeSerializer", + } +``` -class IsOwner(BasePermission): +### Sensitive Data Masking + +```python +def to_representation(self, instance): + data = super().to_representation(instance) + # Mask by default, expose only on explicit request + fields_param = self.context.get("request").query_params.get("fields[my-model]", "") + if "api_key" in fields_param: + data["api_key"] = instance.api_key_decoded + else: + data["api_key"] = "****" if instance.api_key else None + return data +``` + +--- + +## ViewSet Patterns + +### get_queryset() with N+1 Prevention + +**Always combine** `swagger_fake_view` check with `select_related`/`prefetch_related`: + +```python +def get_queryset(self): + # REQUIRED: Return empty queryset for OpenAPI schema generation + if getattr(self, "swagger_fake_view", False): + return Provider.objects.none() + + # N+1 prevention: eager load relationships + return Provider.objects.select_related( + "tenant", + ).prefetch_related( + "provider_groups", + Prefetch("tags", queryset=ProviderTag.objects.filter(tenant_id=self.request.tenant_id)), + ) +``` + +> **Why swagger_fake_view?** drf-spectacular introspects ViewSets to generate OpenAPI schemas. Without this check, it executes real queries and can fail without request context. + +### Action-Specific Serializers + +```python +def get_serializer_class(self): + if self.action == "create": + return ProviderCreateSerializer + elif self.action == "partial_update": + return ProviderUpdateSerializer + elif self.action in ["connection", "destroy"]: + return TaskSerializer + return ProviderSerializer +``` + +### Dynamic Permissions per Action + +```python +class ProviderViewSet(BaseRLSViewSet): + required_permissions = [Permissions.MANAGE_PROVIDERS] + + def set_required_permissions(self): + if self.action in ["list", "retrieve"]: + self.required_permissions = [] # Read-only = no permission + else: + self.required_permissions = [Permissions.MANAGE_PROVIDERS] +``` + +### Cache Decorator + +```python +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control + +CACHE_DECORATOR = cache_control( + max_age=django_settings.CACHE_MAX_AGE, + stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE, +) + +@method_decorator(CACHE_DECORATOR, name="list") +@method_decorator(CACHE_DECORATOR, name="retrieve") +class ProviderViewSet(BaseRLSViewSet): + pass +``` + +### Custom Actions + +```python +# Detail action (operates on single object) +@action(detail=True, methods=["post"], url_name="connection") +def connection(self, request, pk=None): + instance = self.get_object() + # Process instance... + +# List action (operates on collection) +@action(detail=False, methods=["get"], url_name="metadata") +def metadata(self, request): + queryset = self.filter_queryset(self.get_queryset()) + # Aggregate over queryset... +``` + +--- + +## Filter Patterns + +### Base Filter Classes + +```python +class BaseProviderFilter(FilterSet): + """For models with direct FK to Provider""" + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") + provider_type = ChoiceFilter(field_name="provider__provider", choices=Provider.ProviderChoices.choices) + +class BaseScanProviderFilter(FilterSet): + """For models with FK to Scan (Scan has FK to Provider)""" + provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") +``` + +### Custom Multi-Value Filters + +```python +class UUIDInFilter(BaseInFilter, UUIDFilter): + pass + +class CharInFilter(BaseInFilter, CharFilter): + pass + +class ChoiceInFilter(BaseInFilter, ChoiceFilter): + pass +``` + +### ArrayField Filtering + +```python +# Single value contains +region = CharFilter(method="filter_region") + +def filter_region(self, queryset, name, value): + return queryset.filter(resource_regions__contains=[value]) + +# Multi-value overlap +region__in = CharInFilter(field_name="resource_regions", lookup_expr="overlap") +``` + +### Date Range Validation + +```python +def filter_queryset(self, queryset): + # Require date filter for performance + if not (date_filters_provided): + raise ValidationError([{ + "detail": "At least one date filter is required", + "status": 400, + "source": {"pointer": "/data/attributes/inserted_at"}, + "code": "required", + }]) + + # Validate max range + if date_range > settings.FINDINGS_MAX_DAYS_IN_RANGE: + raise ValidationError(...) + + return super().filter_queryset(queryset) +``` + +### Dynamic FilterSet Selection + +```python +def get_filterset_class(self): + if self.action in ["latest", "metadata_latest"]: + return LatestFindingFilter + return FindingFilter +``` + +### Enum Field Override + +```python +class Meta: + model = Finding + filter_overrides = { + FindingDeltaEnumField: {"filter_class": CharFilter}, + StatusEnumField: {"filter_class": CharFilter}, + SeverityEnumField: {"filter_class": CharFilter}, + } +``` + +--- + +## Performance Patterns + +### PaginateByPkMixin + +For large querysets with expensive joins: + +```python +class PaginateByPkMixin: + def paginate_by_pk(self, request, base_queryset, manager, + select_related=None, prefetch_related=None): + # 1. Get PKs only (cheap) + pk_list = base_queryset.values_list("id", flat=True) + page = self.paginate_queryset(pk_list) + + # 2. Fetch full objects for just the page + queryset = manager.filter(id__in=page) + if select_related: + queryset = queryset.select_related(*select_related) + if prefetch_related: + queryset = queryset.prefetch_related(*prefetch_related) + + # 3. Re-sort to preserve DB ordering + queryset = sorted(queryset, key=lambda obj: page.index(obj.id)) + return self.get_paginated_response(self.get_serializer(queryset, many=True).data) +``` + +### Prefetch in Serializers + +```python +def get_tags(self, obj): + # Use prefetched tags if available + if hasattr(obj, "prefetched_tags"): + return {tag.key: tag.value for tag in obj.prefetched_tags} + # Fallback (causes N+1 if not prefetched) + return obj.get_tags(self.context.get("tenant_id")) +``` + +--- + +## Naming Conventions + +| Entity | Pattern | Example | +|--------|---------|---------| +| Serializer (read) | `Serializer` | `ProviderSerializer` | +| Serializer (create) | `CreateSerializer` | `ProviderCreateSerializer` | +| Serializer (update) | `UpdateSerializer` | `ProviderUpdateSerializer` | +| Serializer (include) | `IncludeSerializer` | `ProviderIncludeSerializer` | +| Filter | `Filter` | `ProviderFilter` | +| ViewSet | `ViewSet` | `ProviderViewSet` | + +--- + +## OpenAPI Documentation + +```python +from drf_spectacular.utils import extend_schema, extend_schema_view + +@extend_schema_view( + list=extend_schema(tags=["Provider"], summary="List all providers"), + retrieve=extend_schema(tags=["Provider"], summary="Retrieve provider"), + create=extend_schema(tags=["Provider"], summary="Create provider"), +) +@extend_schema(tags=["Provider"]) +class ProviderViewSet(BaseRLSViewSet): + pass +``` + +--- + +## API Security Patterns + +> **Full examples**: See [assets/security_patterns.py](assets/security_patterns.py) + +| Pattern | Key Points | +|---------|------------| +| **Input Validation** | Use `validate_()` for sanitization, `validate()` for cross-field | +| **Prevent Mass Assignment** | ALWAYS use explicit `fields` list, NEVER `__all__` or `exclude` | +| **Object-Level Permissions** | Implement `has_object_permission()` for ownership checks | +| **Rate Limiting** | Configure `DEFAULT_THROTTLE_RATES`, use per-view throttles for sensitive endpoints | +| **Prevent Info Disclosure** | Generic error messages, return 404 not 403 for unauthorized (prevents enumeration) | +| **SQL Injection** | ALWAYS use ORM parameterization, NEVER string interpolation in raw SQL | + +### Quick Reference + +```python +# Input validation in serializer +def validate_uid(self, value): + value = value.strip().lower() + if not re.match(r'^[a-z0-9-]+$', value): + raise serializers.ValidationError("Invalid format") + return value + +# Explicit fields (prevent mass assignment) +class Meta: + fields = ["name", "email"] # GOOD: whitelist + read_only_fields = ["id", "inserted_at"] # System fields + +# Object permission +class IsOwnerOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True return obj.owner == request.user -class IsAdminOrReadOnly(BasePermission): - def has_permission(self, request, view): - if request.method in ["GET", "HEAD", "OPTIONS"]: - return True - return request.user.is_staff +# Throttling for sensitive endpoints +class BurstRateThrottle(UserRateThrottle): + rate = "10/minute" + +# Safe error messages (prevent enumeration) +def get_object(self): + try: + return super().get_object() + except Http404: + raise NotFound("Resource not found") # Generic, no internal IDs ``` -## Pagination - -```python -from rest_framework.pagination import PageNumberPagination - -class StandardPagination(PageNumberPagination): - page_size = 20 - page_size_query_param = "page_size" - max_page_size = 100 - -# settings.py -REST_FRAMEWORK = { - "DEFAULT_PAGINATION_CLASS": "api.pagination.StandardPagination", -} -``` - -## URL Routing - -```python -from rest_framework.routers import DefaultRouter - -router = DefaultRouter() -router.register(r"users", UserViewSet, basename="user") -router.register(r"posts", PostViewSet, basename="post") - -urlpatterns = [ - path("api/v1/", include(router.urls)), -] -``` - -## Testing - -```python -import pytest -from rest_framework import status -from rest_framework.test import APIClient - -@pytest.fixture -def api_client(): - return APIClient() - -@pytest.fixture -def authenticated_client(api_client, user): - api_client.force_authenticate(user=user) - return api_client - -@pytest.mark.django_db -class TestUserViewSet: - def test_list_users(self, authenticated_client): - response = authenticated_client.get("/api/v1/users/") - assert response.status_code == status.HTTP_200_OK - - def test_create_user(self, authenticated_client): - data = {"email": "new@test.com", "password": "pass123"} - response = authenticated_client.post("/api/v1/users/", data) - assert response.status_code == status.HTTP_201_CREATED -``` +--- ## Commands ```bash -python manage.py runserver -python manage.py makemigrations -python manage.py migrate -python manage.py createsuperuser -python manage.py shell +# Development +cd api && poetry run python src/backend/manage.py runserver +cd api && poetry run python src/backend/manage.py shell + +# Database +cd api && poetry run python src/backend/manage.py makemigrations +cd api && poetry run python src/backend/manage.py migrate + +# Testing +cd api && poetry run pytest -x --tb=short +cd api && poetry run make lint ``` + +--- + +## Resources + +### Local References +- **File Locations**: See [references/file-locations.md](references/file-locations.md) +- **JSON:API Conventions**: See [references/json-api-conventions.md](references/json-api-conventions.md) +- **Security Patterns**: See [assets/security_patterns.py](assets/security_patterns.py) + +### Context7 MCP (Recommended) + +**Prerequisite:** Install Context7 MCP server for up-to-date documentation lookup. + +When implementing or debugging, query these libraries via `mcp_context7_query-docs`: + +| Library | Context7 ID | Use For | +|---------|-------------|---------| +| **Django** | `/websites/djangoproject_en_5_2` | Models, ORM, migrations | +| **DRF** | `/websites/django-rest-framework` | ViewSets, serializers, permissions | +| **drf-spectacular** | `/tfranzel/drf-spectacular` | OpenAPI schema, `@extend_schema` | + +**Example queries:** +``` +mcp_context7_query-docs(libraryId="/websites/django-rest-framework", query="ViewSet get_queryset best practices") +mcp_context7_query-docs(libraryId="/tfranzel/drf-spectacular", query="extend_schema examples for custom actions") +mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_2", query="model constraints and indexes") +``` + +> **Note:** Use `mcp_context7_resolve-library-id` first if you need to find the correct library ID. + +### External Docs +- **DRF Docs**: https://www.django-rest-framework.org/ +- **DRF JSON:API**: https://django-rest-framework-json-api.readthedocs.io/ +- **drf-spectacular**: https://drf-spectacular.readthedocs.io/ +- **django-filter**: https://django-filter.readthedocs.io/ diff --git a/skills/django-drf/assets/security_patterns.py b/skills/django-drf/assets/security_patterns.py new file mode 100644 index 0000000000..17f453091a --- /dev/null +++ b/skills/django-drf/assets/security_patterns.py @@ -0,0 +1,159 @@ +# Example: DRF API Security Patterns +# Reference for django-drf skill + +import re + +from rest_framework import serializers, status, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated +from rest_framework.throttling import UserRateThrottle + + +# ============================================================================= +# INPUT VALIDATION +# ============================================================================= + + +class ProviderCreateSerializer(serializers.Serializer): + """Example: Input validation in serializers.""" + + uid = serializers.CharField(max_length=255) + provider = serializers.CharField() + + def validate_uid(self, value): + """Field-level validation with sanitization.""" + # Sanitize: strip whitespace, normalize + value = value.strip().lower() + # Validate format + if not re.match(r"^[a-z0-9-]+$", value): + raise serializers.ValidationError( + "UID must be alphanumeric with hyphens only" + ) + return value + + def validate(self, attrs): + """Cross-field validation.""" + if attrs.get("provider") == "aws" and len(attrs.get("uid", "")) != 12: + raise serializers.ValidationError( + {"uid": "AWS account ID must be 12 digits"} + ) + return attrs + + +# ============================================================================= +# PREVENT MASS ASSIGNMENT +# ============================================================================= + + +class UserUpdateSerializer(serializers.ModelSerializer): + """Example: Explicit field whitelist prevents mass assignment.""" + + class Meta: + # GOOD: Explicit whitelist + fields = ["name", "email"] + # BAD: fields = "__all__" # Exposes is_staff, is_superuser + # BAD: exclude = ["password"] # New fields auto-exposed + + +class ProviderSerializer(serializers.ModelSerializer): + """Example: Read-only fields for computed/system values.""" + + class Meta: + fields = ["id", "uid", "alias", "connected", "inserted_at"] + # Cannot be set via API - only read + read_only_fields = ["id", "connected", "inserted_at"] + + +# ============================================================================= +# OBJECT-LEVEL PERMISSIONS +# ============================================================================= + + +class IsOwnerOrReadOnly(BasePermission): + """Example: Object-level permission check.""" + + def has_object_permission(self, request, view, obj): + # Read permissions for any authenticated request + if request.method in SAFE_METHODS: + return True + # Write permissions only for owner + return obj.owner == request.user + + +class DocumentViewSet(viewsets.ModelViewSet): + """Example: ViewSet with object-level permissions.""" + + permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] + + +# ============================================================================= +# RATE LIMITING (THROTTLING) +# ============================================================================= + +# In settings.py: +# REST_FRAMEWORK = { +# "DEFAULT_THROTTLE_CLASSES": [ +# "rest_framework.throttling.AnonRateThrottle", +# "rest_framework.throttling.UserRateThrottle", +# ], +# "DEFAULT_THROTTLE_RATES": { +# "anon": "100/hour", +# "user": "1000/hour", +# }, +# } + + +class BurstRateThrottle(UserRateThrottle): + """Example: Custom throttle for sensitive endpoints.""" + + rate = "10/minute" + + +class PasswordResetViewSet(viewsets.ViewSet): + """Example: Per-view throttling for sensitive endpoints.""" + + throttle_classes = [BurstRateThrottle] + + +# ============================================================================= +# PREVENT INFORMATION DISCLOSURE +# ============================================================================= + + +class SecureViewSet(viewsets.ModelViewSet): + """Example: Prevent information disclosure patterns.""" + + def get_object(self): + try: + return super().get_object() + except Exception: + # GOOD: Generic message - doesn't leak internal IDs or tenant info + raise NotFound("Resource not found") + # BAD: raise NotFound(f"Provider {pk} not found in tenant {tenant_id}") + + def get_queryset(self): + # Use 404 not 403 for unauthorized access (prevents enumeration) + # Filter by tenant - unauthorized users get 404, not 403 + return self.queryset.filter(tenant_id=self.request.tenant_id) + + +# ============================================================================= +# SQL INJECTION PREVENTION +# ============================================================================= + + +def safe_query_examples(user_input): + """Example: SQL injection prevention patterns.""" + from django.db import connection + + # GOOD: Parameterized via ORM + # Provider.objects.filter(uid=user_input) + # Provider.objects.extra(where=["uid = %s"], params=[user_input]) + + # GOOD: If raw SQL unavoidable, use parameterized queries + with connection.cursor() as cursor: + cursor.execute("SELECT * FROM providers WHERE uid = %s", [user_input]) + + # BAD: String interpolation = SQL injection vulnerability + # Provider.objects.raw(f"SELECT * FROM providers WHERE uid = '{user_input}'") + # cursor.execute(f"SELECT * FROM providers WHERE uid = '{user_input}'") diff --git a/skills/django-drf/references/file-locations.md b/skills/django-drf/references/file-locations.md new file mode 100644 index 0000000000..30dab71550 --- /dev/null +++ b/skills/django-drf/references/file-locations.md @@ -0,0 +1,154 @@ +# Django-DRF File Locations + +## Core API Files + +| Pattern | File Path | Key Classes | +|---------|-----------|-------------| +| **Models** | `api/src/backend/api/models.py` | `Provider`, `Scan`, `Finding`, `Resource`, `StateChoices`, `StatusChoices` | +| **ViewSets** | `api/src/backend/api/v1/views.py` | `BaseViewSet`, `BaseRLSViewSet`, `BaseTenantViewset`, `BaseUserViewset` | +| **Serializers** | `api/src/backend/api/v1/serializers.py` | `BaseModelSerializerV1`, `BaseWriteSerializer`, `RLSSerializer` | +| **Filters** | `api/src/backend/api/filters.py` | `BaseProviderFilter`, `BaseScanProviderFilter`, `CommonFindingFilters` | +| **URL Routing** | `api/src/backend/api/v1/urls.py` | Router setup, nested routes | +| **Pagination** | `api/src/backend/api/pagination.py` | `LimitedJsonApiPageNumberPagination` | +| **Permissions** | `api/src/backend/api/decorators.py` | `HasPermissions`, `@check_permissions` | +| **RBAC** | `api/src/backend/api/rbac/permissions.py` | `Permissions` enum, `get_role()`, `get_providers()` | +| **Settings** | `api/src/backend/config/settings.py` | `REST_FRAMEWORK` config | + +## ViewSet Hierarchy + +``` +BaseViewSet (minimal - no RLS/auth) + β”‚ + β”œβ”€β”€ BaseRLSViewSet (+ tenant filtering, RLS-protected models) + β”‚ └── Most ViewSets inherit this + β”‚ + β”œβ”€β”€ BaseTenantViewset (+ Tenant-specific logic) + β”‚ └── TenantViewSet + β”‚ + └── BaseUserViewset (+ User-specific logic) + └── UserViewSet +``` + +## Serializer Hierarchy + +``` +BaseModelSerializerV1 (JSON:API defaults, read_only_fields) + β”‚ + β”œβ”€β”€ RLSSerializer (auto-injects tenant_id from request) + β”‚ └── Most model serializers inherit this + β”‚ + └── BaseWriteSerializer (rejects unknown fields) + └── Create/Update serializers + ++ Mixins: + - IncludedResourcesValidationMixin (validates ?include= param) + - JSONAPIRelatedLinksSerializerMixin (adds related links) +``` + +## Filter Hierarchy + +``` +FilterSet (django-filter) + β”‚ + β”œβ”€β”€ CommonFindingFilters (mixin for date ranges, delta, status) + β”‚ + β”œβ”€β”€ BaseProviderFilter (provider_type, provider_uid, provider_alias) + β”‚ β”‚ + β”‚ └── BaseScanProviderFilter (+ scan_id, scan filters) + β”‚ + └── Resource-specific filters (ProviderFilter, ScanFilter, etc.) + +Custom Filter Types: + - UUIDInFilter: Comma-separated UUIDs + - CharInFilter: Comma-separated strings + - DateFilter: ISO date parsing + - DateTimeFilter: ISO datetime parsing +``` + +## Testing Files + +| Pattern | File Path | Key Classes | +|---------|-----------|-------------| +| **ViewSet Tests** | `api/src/backend/api/tests/test_views.py` | Test patterns, fixtures | +| **RBAC Tests** | `api/src/backend/api/tests/test_rbac.py` | Permission tests | +| **Serializer Tests** | `api/src/backend/api/tests/test_serializers.py` | Validation tests | +| **Conftest** | `api/src/backend/conftest.py` | Shared fixtures | + +## Key Patterns + +### Filter Usage + +```python +# In filters.py +class ProviderFilter(BaseProviderFilter): + class Meta: + model = Provider + fields = { + "provider": ["exact", "in"], + "connected": ["exact"], + } + +# Custom filter method +def filter_severity(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(severity__in=value) +``` + +### Serializer Usage + +```python +# Read serializer +class ProviderSerializer(RLSSerializer): + class Meta: + model = Provider + fields = ["id", "provider", "uid", "alias", "connected"] + +# Write serializer +class ProviderCreateSerializer(BaseWriteSerializer, RLSSerializer): + class Meta: + model = Provider + fields = ["provider", "uid", "alias"] +``` + +### ViewSet Action Pattern + +```python +@action(detail=True, methods=["post"], url_path="scan") +def trigger_scan(self, request, pk=None): + provider = self.get_object() + task = perform_scan_task.delay(...) + return Response(status=status.HTTP_202_ACCEPTED) +``` + +## REST_FRAMEWORK Settings + +Located in `api/src/backend/config/settings.py`: + +```python +REST_FRAMEWORK = { + "PAGE_SIZE": 10, + "DEFAULT_PAGINATION_CLASS": "api.pagination.LimitedJsonApiPageNumberPagination", + "DEFAULT_PARSER_CLASSES": [ + "rest_framework_json_api.parsers.JSONParser", + "rest_framework.parsers.JSONParser", + ], + "DEFAULT_FILTER_BACKENDS": [ + "rest_framework_json_api.filters.QueryParameterValidationFilter", + "rest_framework_json_api.filters.OrderingFilter", + "rest_framework_json_api.django_filters.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", + ], + "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", + # ... more settings +} +``` + +## JSON:API Resource Names + +Find all `JSONAPIMeta` declarations: +```bash +rg "resource_name" api/src/backend/api/models.py +``` + +Convention: kebab-case, plural (e.g., `provider-groups`, `mute-rules`) diff --git a/skills/django-drf/references/json-api-conventions.md b/skills/django-drf/references/json-api-conventions.md new file mode 100644 index 0000000000..c51326b0b2 --- /dev/null +++ b/skills/django-drf/references/json-api-conventions.md @@ -0,0 +1,116 @@ +# JSON:API Conventions + +## Content Type + +``` +Content-Type: application/vnd.api+json +Accept: application/vnd.api+json +``` + +## Query Parameters + +| Feature | Format | Example | +|---------|--------|---------| +| **Pagination** | `page[number]`, `page[size]` | `?page[number]=2&page[size]=20` | +| **Filtering** | `filter[field]`, `filter[field__lookup]` | `?filter[status]=FAIL&filter[inserted_at__gte]=2024-01-01` | +| **Sorting** | `sort` (prefix `-` for desc) | `?sort=-inserted_at,name` | +| **Sparse fields** | `fields[type]` | `?fields[providers]=id,alias,uid` | +| **Includes** | `include` | `?include=provider,scan` | +| **Search** | `filter[search]` | `?filter[search]=production` | + +## Filter Naming + +| Lookup | Django Filter | JSON:API Query | +|--------|--------------|----------------| +| Exact | `field` | `filter[field]=value` | +| Contains | `field__icontains` | `filter[field__icontains]=val` | +| In list | `field__in` | `filter[field__in]=a,b,c` | +| Greater/equal | `field__gte` | `filter[field__gte]=2024-01-01` | +| Less/equal | `field__lte` | `filter[field__lte]=2024-12-31` | +| Related field | `relation__field` | `filter[provider_id]=uuid` | + +## Request Format + +```json +{ + "data": { + "type": "providers", + "attributes": { + "provider": "aws", + "uid": "123456789012", + "alias": "Production" + } + } +} +``` + +## Response Format + +```json +{ + "data": { + "type": "providers", + "id": "550e8400-e29b-41d4-a716-446655440000", + "attributes": { + "provider": "aws", + "uid": "123456789012", + "alias": "Production", + "inserted_at": "2024-01-15T10:30:00Z" + }, + "relationships": { + "provider_groups": { + "data": [{"type": "provider-groups", "id": "..."}] + } + }, + "links": { + "self": "/api/v1/providers/550e8400-e29b-41d4-a716-446655440000" + } + }, + "meta": { + "version": "v1" + } +} +``` + +## Error Response Format + +```json +{ + "errors": [ + { + "detail": "Error message here", + "status": "400", + "source": {"pointer": "/data/attributes/field_name"}, + "code": "error_code" + } + ] +} +``` + +## Resource Naming Rules + +- Use **lowercase kebab-case** (hyphens, not underscores) +- Use **plural nouns** for collections +- Resource name in `JSONAPIMeta` MUST match URL path segment + +| Model | resource_name | URL Path | +|-------|---------------|----------| +| `Provider` | `providers` | `/api/v1/providers` | +| `ProviderGroup` | `provider-groups` | `/api/v1/provider-groups` | +| `ProviderSecret` | `provider-secrets` | `/api/v1/providers/secrets` | +| `ComplianceOverview` | `compliance-overviews` | `/api/v1/compliance-overviews` | +| `AttackPathsScan` | `attack-paths-scans` | `/api/v1/attack-paths-scans` | +| `TenantAPIKey` | `api-keys` | `/api/v1/api-keys` | +| `MuteRule` | `mute-rules` | `/api/v1/mute-rules` | + +## URL Endpoints + +| Operation | Method | URL Pattern | +|-----------|--------|-------------| +| List | GET | `/{resources}` | +| Create | POST | `/{resources}` | +| Retrieve | GET | `/{resources}/{id}` | +| Update | PATCH | `/{resources}/{id}` | +| Delete | DELETE | `/{resources}/{id}` | +| Relationship | * | `/{resources}/{id}/relationships/{relation}` | +| Nested list | GET | `/{parent}/{parent_id}/{resources}` | diff --git a/skills/jsonapi/SKILL.md b/skills/jsonapi/SKILL.md new file mode 100644 index 0000000000..a8959a2199 --- /dev/null +++ b/skills/jsonapi/SKILL.md @@ -0,0 +1,271 @@ +--- +name: jsonapi +description: > + Strict JSON:API v1.1 specification compliance. + Trigger: When creating or modifying API endpoints, reviewing API responses, or validating JSON:API compliance. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0.0" + scope: [root, api] + auto_invoke: + - "Creating API endpoints" + - "Modifying API responses" + - "Reviewing JSON:API compliance" +--- + +## Use With django-drf + +This skill focuses on **spec compliance**. For **implementation patterns** (ViewSets, Serializers, Filters), use `django-drf` skill together with this one. + +| Skill | Focus | +|-------|-------| +| `jsonapi` | What the spec requires (MUST/MUST NOT rules) | +| `django-drf` | How to implement it in DRF (code patterns) | + +**When creating/modifying endpoints, invoke BOTH skills.** + +--- + +## Before Implementing/Reviewing + +**ALWAYS validate against the latest spec** before creating or modifying endpoints: + +### Option 1: Context7 MCP (Preferred) + +If Context7 MCP is available, query the JSON:API spec directly: + +``` +mcp_context7_resolve-library-id(query="jsonapi specification") +mcp_context7_query-docs(libraryId="", query="[specific topic: relationships, errors, etc.]") +``` + +### Option 2: WebFetch (Fallback) + +If Context7 is not available, fetch from the official spec: + +``` +WebFetch(url="https://jsonapi.org/format/", prompt="Extract rules for [specific topic]") +``` + +This ensures compliance with the latest JSON:API version, even after spec updates. + +--- + +## Critical Rules (NEVER Break) + +### Document Structure +- NEVER include both `data` and `errors` in the same response +- ALWAYS include at least one of: `data`, `errors`, `meta` +- ALWAYS use `type` and `id` (string) in resource objects +- NEVER include `id` when creating resources (server generates it) + +### Content-Type +- ALWAYS use `Content-Type: application/vnd.api+json` +- ALWAYS use `Accept: application/vnd.api+json` +- NEVER add parameters to media type without `ext`/`profile` + +### Resource Objects +- ALWAYS use **string** for `id` (even if UUID) +- ALWAYS use **lowercase kebab-case** for `type` +- NEVER put `id` or `type` inside `attributes` +- NEVER include foreign keys in `attributes` - use `relationships` + +### Relationships +- ALWAYS include at least one of: `links`, `data`, or `meta` +- ALWAYS use resource linkage format: `{"type": "...", "id": "..."}` +- NEVER use raw IDs in relationships - always use linkage objects + +### Error Objects +- ALWAYS return errors as array: `{"errors": [...]}` +- ALWAYS include `status` as **string** (e.g., `"400"`, not `400`) +- ALWAYS include `source.pointer` for field-specific errors + +--- + +## HTTP Status Codes (Mandatory) + +| Operation | Success | Async | Conflict | Not Found | Forbidden | Bad Request | +|-----------|---------|-------|----------|-----------|-----------|-------------| +| **GET** | `200` | - | - | `404` | `403` | `400` | +| **POST** | `201` | `202` | `409` | `404` | `403` | `400` | +| **PATCH** | `200` | `202` | `409` | `404` | `403` | `400` | +| **DELETE** | `200`/`204` | `202` | - | `404` | `403` | - | + +### When to Use Each + +| Code | Use When | +|------|----------| +| `200 OK` | Successful GET, PATCH with response body, DELETE with response | +| `201 Created` | POST created resource (MUST include `Location` header) | +| `202 Accepted` | Async operation started (return task reference) | +| `204 No Content` | Successful DELETE, PATCH with no response body | +| `400 Bad Request` | Invalid query params, malformed request, unknown fields | +| `403 Forbidden` | Authentication ok but no permission, client-generated ID rejected | +| `404 Not Found` | Resource doesn't exist OR RLS hides it (never reveal which) | +| `409 Conflict` | Duplicate ID, type mismatch, relationship conflict | +| `415 Unsupported` | Wrong Content-Type header | + +--- + +## Document Structure + +### Success Response (Single) + +```json +{ + "data": { + "type": "providers", + "id": "550e8400-e29b-41d4-a716-446655440000", + "attributes": { + "alias": "Production", + "connected": true + }, + "relationships": { + "tenant": { + "data": {"type": "tenants", "id": "..."} + } + }, + "links": { + "self": "/api/v1/providers/550e8400-..." + } + }, + "links": { + "self": "/api/v1/providers/550e8400-..." + } +} +``` + +### Success Response (List) + +```json +{ + "data": [ + {"type": "providers", "id": "...", "attributes": {...}}, + {"type": "providers", "id": "...", "attributes": {...}} + ], + "links": { + "self": "/api/v1/providers?page[number]=1", + "first": "/api/v1/providers?page[number]=1", + "last": "/api/v1/providers?page[number]=5", + "prev": null, + "next": "/api/v1/providers?page[number]=2" + }, + "meta": { + "pagination": {"count": 100, "pages": 5} + } +} +``` + +### Error Response + +```json +{ + "errors": [ + { + "status": "400", + "code": "invalid", + "title": "Invalid attribute", + "detail": "UID must be 12 digits for AWS accounts", + "source": {"pointer": "/data/attributes/uid"} + } + ] +} +``` + +--- + +## Query Parameters + +| Family | Format | Example | +|--------|--------|---------| +| `page` | `page[number]`, `page[size]` | `?page[number]=2&page[size]=25` | +| `filter` | `filter[field]`, `filter[field__op]` | `?filter[status]=FAIL` | +| `sort` | Comma-separated, `-` for desc | `?sort=-inserted_at,name` | +| `fields` | `fields[type]` | `?fields[providers]=id,alias` | +| `include` | Comma-separated paths | `?include=provider,scan.task` | + +### Rules + +- MUST return `400` for unsupported query parameters +- MUST return `400` for unsupported `include` paths +- MUST return `400` for unsupported `sort` fields +- MUST NOT include extra fields when `fields[type]` is specified + +--- + +## Common Violations (AVOID) + +| Violation | Wrong | Correct | +|-----------|-------|---------| +| ID as integer | `"id": 123` | `"id": "123"` | +| Type as camelCase | `"type": "providerGroup"` | `"type": "provider-groups"` | +| FK in attributes | `"tenant_id": "..."` | `"relationships": {"tenant": {...}}` | +| Errors not array | `{"error": "..."}` | `{"errors": [{"detail": "..."}]}` | +| Status as number | `"status": 400` | `"status": "400"` | +| Data + errors | `{"data": ..., "errors": ...}` | Only one or the other | +| Missing pointer | `{"detail": "Invalid"}` | `{"detail": "...", "source": {"pointer": "..."}}` | + +--- + +## Relationship Updates + +### To-One Relationship + +```http +PATCH /api/v1/providers/123/relationships/tenant +Content-Type: application/vnd.api+json + +{"data": {"type": "tenants", "id": "456"}} +``` + +To clear: `{"data": null}` + +### To-Many Relationship + +| Operation | Method | Body | +|-----------|--------|------| +| Replace all | PATCH | `{"data": [{...}, {...}]}` | +| Add members | POST | `{"data": [{...}]}` | +| Remove members | DELETE | `{"data": [{...}]}` | + +--- + +## Compound Documents (`include`) + +When using `?include=provider`: + +```json +{ + "data": { + "type": "scans", + "id": "...", + "relationships": { + "provider": { + "data": {"type": "providers", "id": "prov-123"} + } + } + }, + "included": [ + { + "type": "providers", + "id": "prov-123", + "attributes": {"alias": "Production"} + } + ] +} +``` + +### Rules + +- Every included resource MUST be reachable via relationship chain from primary data +- MUST NOT include orphan resources +- MUST NOT duplicate resources (same type+id) + +--- + +## Spec Reference + +- **Full Specification**: https://jsonapi.org/format/ +- **Implementation**: Use `django-drf` skill for DRF-specific patterns +- **Testing**: Use `prowler-test-api` skill for test patterns diff --git a/skills/prowler-api/SKILL.md b/skills/prowler-api/SKILL.md index a0202bd783..1e654a3e03 100644 --- a/skills/prowler-api/SKILL.md +++ b/skills/prowler-api/SKILL.md @@ -1,29 +1,205 @@ --- name: prowler-api description: > - Prowler API patterns: JSON:API, RLS, RBAC, providers, Celery tasks. - Trigger: When working in api/ on models/serializers/viewsets/filters/tasks involving tenant isolation (RLS), RBAC, JSON:API, or provider lifecycle. + Prowler API patterns: RLS, RBAC, providers, Celery tasks. + Trigger: When working in api/ on models/serializers/viewsets/filters/tasks involving tenant isolation (RLS), RBAC, or provider lifecycle. license: Apache-2.0 metadata: author: prowler-cloud - version: "1.0" + version: "1.2.0" scope: [root, api] auto_invoke: "Creating/modifying models, views, serializers" allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task --- +## When to Use + +Use this skill for **Prowler-specific** patterns: +- Row-Level Security (RLS) / tenant isolation +- RBAC permissions and role checks +- Provider lifecycle and validation +- Celery tasks with tenant context +- Multi-database architecture (4-database setup) + +For **generic DRF patterns** (ViewSets, Serializers, Filters, JSON:API), use `django-drf` skill. + +--- + ## Critical Rules - ALWAYS use `rls_transaction(tenant_id)` when querying outside ViewSet context - ALWAYS use `get_role()` before checking permissions (returns FIRST role only) -- NEVER access `Provider.objects` without RLS context in Celery tasks - ALWAYS use `@set_tenant` then `@handle_provider_deletion` decorator order +- ALWAYS use explicit through models for M2M relationships (required for RLS) +- NEVER access `Provider.objects` without RLS context in Celery tasks +- NEVER bypass RLS by using raw SQL or `connection.cursor()` +- NEVER use Django's default M2M - RLS requires through models with `tenant_id` + +> **Note**: `rls_transaction()` accepts both UUID objects and strings - it converts internally via `str(value)`. --- -## 1. Providers (10 Supported) +## Architecture Overview -UID validation is dynamic: `getattr(self, f"validate_{self.provider}_uid")(self.uid)` +### 4-Database Architecture + +| Database | Alias | Purpose | RLS | +|----------|-------|---------|-----| +| `default` | `prowler_user` | Standard API queries | **Yes** | +| `admin` | `admin` | Migrations, auth bypass | No | +| `replica` | `prowler_user` | Read-only queries | **Yes** | +| `admin_replica` | `admin` | Admin read replica | No | + +```python +# When to use admin (bypasses RLS) +from api.db_router import MainRouter +User.objects.using(MainRouter.admin_db).get(id=user_id) # Auth lookups + +# Standard queries use default (RLS enforced) +Provider.objects.filter(connected=True) # Requires rls_transaction context +``` + +### RLS Transaction Flow + +``` +Request β†’ Authentication β†’ BaseRLSViewSet.initial() + β”‚ + β”œβ”€ Extract tenant_id from JWT + β”œβ”€ SET api.tenant_id = 'uuid' (PostgreSQL) + └─ All queries now tenant-scoped +``` + +--- + +## Implementation Checklist + +When implementing Prowler-specific API features: + +| # | Pattern | Reference | Key Points | +|---|---------|-----------|------------| +| 1 | **RLS Models** | `api/rls.py` | Inherit `RowLevelSecurityProtectedModel`, add constraint | +| 2 | **RLS Transactions** | `api/db_utils.py` | Use `rls_transaction(tenant_id)` context manager | +| 3 | **RBAC Permissions** | `api/rbac/permissions.py` | `get_role()`, `get_providers()`, `Permissions` enum | +| 4 | **Provider Validation** | `api/models.py` | `validate__uid()` methods on `Provider` model | +| 5 | **Celery Tasks** | `tasks/tasks.py`, `api/decorators.py`, `config/celery.py` | Task definitions, decorators (`@set_tenant`, `@handle_provider_deletion`), `RLSTask` base | +| 6 | **RLS Serializers** | `api/v1/serializers.py` | Inherit `RLSSerializer` to auto-inject `tenant_id` | +| 7 | **Through Models** | `api/models.py` | ALL M2M must use explicit through with `tenant_id` | + +> **Full file paths**: See [references/file-locations.md](references/file-locations.md) + +--- + +## Decision Trees + +### Which Base Model? +``` +Tenant-scoped data β†’ RowLevelSecurityProtectedModel +Global/shared data β†’ models.Model + BaseSecurityConstraint (rare) +Partitioned time-series β†’ PostgresPartitionedModel + RowLevelSecurityProtectedModel +Soft-deletable β†’ Add is_deleted + ActiveProviderManager +``` + +### Which Manager? +``` +Normal queries β†’ Model.objects (excludes deleted) +Include deleted records β†’ Model.all_objects +Celery task context β†’ Must use rls_transaction() first +``` + +### Which Database? +``` +Standard API queries β†’ default (automatic via ViewSet) +Read-only operations β†’ replica (automatic for GET in BaseRLSViewSet) +Auth/admin operations β†’ MainRouter.admin_db +Cross-tenant lookups β†’ MainRouter.admin_db (use sparingly!) +``` + +### Celery Task Decorator Order? +``` +@shared_task(base=RLSTask, name="...", queue="...") +@set_tenant # First: sets tenant context +@handle_provider_deletion # Second: handles deleted providers +def my_task(tenant_id, provider_id): + pass +``` + +--- + +## RLS Model Pattern + +```python +from api.rls import RowLevelSecurityProtectedModel, RowLevelSecurityConstraint + +class MyModel(RowLevelSecurityProtectedModel): + # tenant FK inherited from parent + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField(max_length=255) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "my_models" + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + class JSONAPIMeta: + resource_name = "my-models" +``` + +### M2M Relationships (MUST use through models) + +```python +class Resource(RowLevelSecurityProtectedModel): + tags = models.ManyToManyField( + ResourceTag, + through="ResourceTagMapping", # REQUIRED for RLS + ) + +class ResourceTagMapping(RowLevelSecurityProtectedModel): + # Through model MUST have tenant_id for RLS + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + tag = models.ForeignKey(ResourceTag, on_delete=models.CASCADE) + + class Meta: + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] +``` + +--- + +## Async Task Response Pattern (202 Accepted) + +For long-running operations, return 202 with task reference: + +```python +@action(detail=True, methods=["post"], url_name="connection") +def connection(self, request, pk=None): + with transaction.atomic(): + task = check_provider_connection_task.delay( + provider_id=pk, tenant_id=self.request.tenant_id + ) + prowler_task = Task.objects.get(id=task.id) + serializer = TaskSerializer(prowler_task) + return Response( + data=serializer.data, + status=status.HTTP_202_ACCEPTED, + headers={"Content-Location": reverse("task-detail", kwargs={"pk": prowler_task.id})} + ) +``` + +--- + +## Providers (11 Supported) | Provider | UID Format | Example | |----------|-----------|---------| @@ -42,98 +218,288 @@ UID validation is dynamic: `getattr(self, f"validate_{self.provider}_uid")(self. --- -## 2. Row-Level Security (RLS) +## RBAC Permissions + +| Permission | Controls | +|------------|----------| +| `MANAGE_USERS` | User CRUD, role assignments | +| `MANAGE_ACCOUNT` | Tenant settings | +| `MANAGE_BILLING` | Billing/subscription | +| `MANAGE_PROVIDERS` | Provider CRUD | +| `MANAGE_INTEGRATIONS` | Integration config | +| `MANAGE_SCANS` | Scan execution | +| `UNLIMITED_VISIBILITY` | See all providers (bypasses provider_groups) | + +### RBAC Visibility Pattern ```python -from api.db_utils import rls_transaction - -with rls_transaction(tenant_id): - providers = Provider.objects.filter(connected=True) - # PostgreSQL enforces tenant_id automatically -``` - -Models inherit from `RowLevelSecurityProtectedModel` with `RowLevelSecurityConstraint`. - ---- - -## 3. Managers - -```python -Provider.objects.all() # Only is_deleted=False -Provider.all_objects.all() # All including deleted -Finding.objects.all() # Only from active providers +def get_queryset(self): + user_role = get_role(self.request.user) + if user_role.unlimited_visibility: + return Model.objects.filter(tenant_id=self.request.tenant_id) + else: + # Filter by provider_groups assigned to role + return Model.objects.filter(provider__in=get_providers(user_role)) ``` --- -## 4. RBAC +## Celery Queues -```python -from api.rbac.permissions import get_role, get_providers, Permissions - -user_role = get_role(self.request.user) # Returns FIRST role only - -if user_role.unlimited_visibility: - queryset = Provider.objects.filter(tenant_id=tenant_id) -else: - queryset = get_providers(user_role) # Filtered by provider_groups -``` - -**Permissions**: `MANAGE_USERS`, `MANAGE_ACCOUNT`, `MANAGE_BILLING`, `MANAGE_PROVIDERS`, `MANAGE_INTEGRATIONS`, `MANAGE_SCANS`, `UNLIMITED_VISIBILITY` +| Queue | Purpose | +|-------|---------| +| `scans` | Prowler scan execution | +| `overview` | Dashboard aggregations (severity, attack surface) | +| `compliance` | Compliance report generation | +| `integrations` | External integrations (Jira, S3, Security Hub) | +| `deletion` | Provider/tenant deletion (async) | +| `backfill` | Historical data backfill operations | +| `scan-reports` | Output generation (CSV, JSON, HTML, PDF) | --- -## 5. Celery Tasks +## Task Composition (Canvas) + +Use Celery's Canvas primitives for complex workflows: + +| Primitive | Use For | +|-----------|---------| +| `chain()` | Sequential execution: A β†’ B β†’ C | +| `group()` | Parallel execution: A, B, C simultaneously | +| Combined | Chain with nested groups for complex workflows | + +> **Note:** Use `.si()` (signature immutable) to prevent result passing. Use `.s()` if you need to pass results. + +> **Examples:** See [assets/celery_patterns.py](assets/celery_patterns.py) for chain, group, and combined patterns. + +--- + +## Beat Scheduling (Periodic Tasks) + +| Operation | Key Points | +|-----------|------------| +| **Create schedule** | `IntervalSchedule.objects.get_or_create(every=24, period=HOURS)` | +| **Create periodic task** | Use task name (not function), `kwargs=json.dumps(...)` | +| **Delete scheduled task** | `PeriodicTask.objects.filter(name=...).delete()` | +| **Avoid race conditions** | Use `countdown=5` to wait for DB commit | + +> **Examples:** See [assets/celery_patterns.py](assets/celery_patterns.py) for schedule_provider_scan pattern. + +--- + +## Advanced Task Patterns + +### `@set_tenant` Behavior + +| Mode | `tenant_id` in kwargs | `tenant_id` passed to function | +|------|----------------------|-------------------------------| +| `@set_tenant` (default) | Popped (removed) | NO - function doesn't receive it | +| `@set_tenant(keep_tenant=True)` | Read but kept | YES - function receives it | + +### Key Patterns + +| Pattern | Description | +|---------|-------------| +| `bind=True` | Access `self.request.id`, `self.request.retries` | +| `get_task_logger(__name__)` | Proper logging in Celery tasks | +| `SoftTimeLimitExceeded` | Catch to save progress before hard kill | +| `countdown=30` | Defer execution by N seconds | +| `eta=datetime(...)` | Execute at specific time | + +> **Examples:** See [assets/celery_patterns.py](assets/celery_patterns.py) for all advanced patterns. + +--- + +## Celery Configuration + +| Setting | Value | Purpose | +|---------|-------|---------| +| `BROKER_VISIBILITY_TIMEOUT` | `86400` (24h) | Prevent re-queue for long tasks | +| `CELERY_RESULT_BACKEND` | `django-db` | Store results in PostgreSQL | +| `CELERY_TASK_TRACK_STARTED` | `True` | Track when tasks start | +| `soft_time_limit` | Task-specific | Raises `SoftTimeLimitExceeded` | +| `time_limit` | Task-specific | Hard kill (SIGKILL) | + +> **Full config:** See [assets/celery_patterns.py](assets/celery_patterns.py) and actual files at `config/celery.py`, `config/settings/celery.py`. + +--- + +## UUIDv7 for Partitioned Tables + +`Finding` and `ResourceFindingMapping` use UUIDv7 for time-based partitioning: ```python -@shared_task(base=RLSTask, name="task-name", queue="scans") +from uuid6 import uuid7 +from api.uuid_utils import uuid7_start, uuid7_end, datetime_to_uuid7 + +# Partition-aware filtering +start = uuid7_start(datetime_to_uuid7(date_from)) +end = uuid7_end(datetime_to_uuid7(date_to), settings.FINDINGS_TABLE_PARTITION_MONTHS) +queryset.filter(id__gte=start, id__lt=end) +``` + +**Why UUIDv7?** Time-ordered UUIDs enable PostgreSQL to prune partitions during range queries. + +--- + +## Batch Operations with RLS + +```python +from api.db_utils import batch_delete, create_objects_in_batches, update_objects_in_batches + +# Delete in batches (RLS-aware) +batch_delete(tenant_id, queryset, batch_size=1000) + +# Bulk create with RLS +create_objects_in_batches(tenant_id, Finding, objects, batch_size=500) + +# Bulk update with RLS +update_objects_in_batches(tenant_id, Finding, objects, fields=["status"], batch_size=500) +``` + +--- + +## Security Patterns + +> **Full examples**: See [assets/security_patterns.py](assets/security_patterns.py) + +### Tenant Isolation Summary + +| Pattern | Rule | +|---------|------| +| **RLS in ViewSets** | Automatic via `BaseRLSViewSet` - tenant_id from JWT | +| **RLS in Celery** | MUST use `@set_tenant` + `rls_transaction(tenant_id)` | +| **Cross-tenant validation** | Defense-in-depth: verify `obj.tenant_id == request.tenant_id` | +| **Never trust user input** | Use `request.tenant_id` from JWT, never `request.data.get("tenant_id")` | +| **Admin DB bypass** | Only for cross-tenant admin ops - exposes ALL tenants' data | + +### Celery Task Security Summary + +| Pattern | Rule | +|---------|------| +| **Named tasks only** | NEVER use dynamic task names from user input | +| **Validate arguments** | Check UUID format before database queries | +| **Safe queuing** | Use `transaction.on_commit()` to enqueue AFTER commit | +| **Modern retries** | Use `autoretry_for`, `retry_backoff`, `retry_jitter` | +| **Time limits** | Set `soft_time_limit` and `time_limit` to prevent hung tasks | +| **Idempotency** | Use `update_or_create` or idempotency keys | + +### Quick Reference + +```python +# Safe task queuing - task only enqueued after transaction commits +with transaction.atomic(): + provider = Provider.objects.create(**data) + transaction.on_commit( + lambda: verify_provider_connection.delay( + tenant_id=str(request.tenant_id), + provider_id=str(provider.id) + ) + ) + +# Modern retry pattern +@shared_task( + base=RLSTask, + bind=True, + autoretry_for=(ConnectionError, TimeoutError, OperationalError), + retry_backoff=True, + retry_backoff_max=600, + retry_jitter=True, + max_retries=5, + soft_time_limit=300, + time_limit=360, +) @set_tenant -@handle_provider_deletion -def my_task(tenant_id: str, provider_id: str): - pass -``` +def sync_provider_data(self, tenant_id, provider_id): + with rls_transaction(tenant_id): + # ... task logic + pass -**Queues**: Check `tasks/tasks.py`. Common: `scans`, `overview`, `compliance`, `integrations`. - -**Orchestration**: Use `chain()` for sequential, `group()` for parallel. - ---- - -## 6. JSON:API Format - -```python -content_type = "application/vnd.api+json" - -# Request -{"data": {"type": "providers", "attributes": {"provider": "aws", "uid": "123456789012"}}} - -# Response access -response.json()["data"]["attributes"]["alias"] +# Idempotent task - safe to retry +@shared_task(base=RLSTask, acks_late=True) +@set_tenant +def process_finding(tenant_id, finding_uid, data): + with rls_transaction(tenant_id): + Finding.objects.update_or_create(uid=finding_uid, defaults=data) ``` --- -## 7. Serializers +## Production Deployment Checklist -| Pattern | Usage | -|---------|-------| -| `ProviderSerializer` | Read (list/retrieve) | -| `ProviderCreateSerializer` | POST | -| `ProviderUpdateSerializer` | PATCH | -| `RLSSerializer` | Auto-injects tenant_id | +> **Full settings**: See [references/production-settings.md](references/production-settings.md) + +Run before every production deployment: + +```bash +cd api && poetry run python src/backend/manage.py check --deploy +``` + +### Critical Settings + +| Setting | Production Value | Risk if Wrong | +|---------|-----------------|---------------| +| `DEBUG` | `False` | Exposes stack traces, settings, SQL queries | +| `SECRET_KEY` | Env var, rotated | Session hijacking, CSRF bypass | +| `ALLOWED_HOSTS` | Explicit list | Host header attacks | +| `SECURE_SSL_REDIRECT` | `True` | Credentials sent over HTTP | +| `SESSION_COOKIE_SECURE` | `True` | Session cookies over HTTP | +| `CSRF_COOKIE_SECURE` | `True` | CSRF tokens over HTTP | +| `SECURE_HSTS_SECONDS` | `31536000` (1 year) | Downgrade attacks | +| `CONN_MAX_AGE` | `60` or higher | Connection pool exhaustion | --- ## Commands ```bash -cd api && poetry run python manage.py migrate # Run migrations -cd api && poetry run python manage.py shell # Django shell -cd api && poetry run celery -A config.celery worker -l info # Start worker +# Development +cd api && poetry run python src/backend/manage.py runserver +cd api && poetry run python src/backend/manage.py shell + +# Celery +cd api && poetry run celery -A config.celery worker -l info -Q scans,overview +cd api && poetry run celery -A config.celery beat -l info + +# Testing +cd api && poetry run pytest -x --tb=short + +# Production checks +cd api && poetry run python src/backend/manage.py check --deploy ``` --- ## Resources -- **Documentation**: See [references/api-docs.md](references/api-docs.md) for local file paths and documentation +### Local References +- **File Locations**: See [references/file-locations.md](references/file-locations.md) +- **Modeling Decisions**: See [references/modeling-decisions.md](references/modeling-decisions.md) +- **Configuration**: See [references/configuration.md](references/configuration.md) +- **Production Settings**: See [references/production-settings.md](references/production-settings.md) +- **Security Patterns**: See [assets/security_patterns.py](assets/security_patterns.py) + +### Related Skills +- **Generic DRF Patterns**: Use `django-drf` skill +- **API Testing**: Use `prowler-test-api` skill + +### Context7 MCP (Recommended) + +**Prerequisite:** Install Context7 MCP server for up-to-date documentation lookup. + +When implementing or debugging Prowler-specific patterns, query these libraries via `mcp_context7_query-docs`: + +| Library | Context7 ID | Use For | +|---------|-------------|---------| +| **Celery** | `/websites/celeryq_dev_en_stable` | Task patterns, queues, error handling | +| **django-celery-beat** | `/celery/django-celery-beat` | Periodic task scheduling | +| **Django** | `/websites/djangoproject_en_5_2` | Models, ORM, constraints, indexes | + +**Example queries:** +``` +mcp_context7_query-docs(libraryId="/websites/celeryq_dev_en_stable", query="shared_task decorator retry patterns") +mcp_context7_query-docs(libraryId="/celery/django-celery-beat", query="periodic task database scheduler") +mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_2", query="model constraints CheckConstraint UniqueConstraint") +``` + +> **Note:** Use `mcp_context7_resolve-library-id` first if you need to find the correct library ID. diff --git a/skills/prowler-api/assets/celery_patterns.py b/skills/prowler-api/assets/celery_patterns.py new file mode 100644 index 0000000000..700e63891b --- /dev/null +++ b/skills/prowler-api/assets/celery_patterns.py @@ -0,0 +1,319 @@ +# Prowler API - Celery Patterns Reference +# Reference for prowler-api skill + +from datetime import datetime, timedelta, timezone +import json + +from celery import chain, group, shared_task +from celery.exceptions import SoftTimeLimitExceeded +from celery.utils.log import get_task_logger +from django.db import OperationalError, transaction +from django_celery_beat.models import IntervalSchedule, PeriodicTask + +from api.db_utils import rls_transaction +from api.decorators import handle_provider_deletion, set_tenant +from api.models import Provider, Scan +from config.celery import RLSTask + +logger = get_task_logger(__name__) + + +# ============================================================================= +# DECORATOR ORDER - CRITICAL +# ============================================================================= +# @shared_task() must be first +# @set_tenant must be second (sets RLS context) +# @handle_provider_deletion must be third (handles deleted providers) + + +# ============================================================================= +# @set_tenant BEHAVIOR +# ============================================================================= + + +# Example: @set_tenant (default) - tenant_id NOT in function signature +# The decorator pops tenant_id from kwargs after setting RLS context +@shared_task(base=RLSTask, name="provider-connection-check") +@set_tenant +def check_provider_connection_task(provider_id: str): + """Task receives NO tenant_id param - decorator pops it from kwargs.""" + # RLS context already set by decorator + with rls_transaction(): # Context already established + provider = Provider.objects.get(pk=provider_id) + return {"connected": provider.connected} + + +# Example: @set_tenant(keep_tenant=True) - tenant_id IN function signature +@shared_task(base=RLSTask, name="scan-report", queue="scan-reports") +@set_tenant(keep_tenant=True) +def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): + """Task receives tenant_id param - use when function needs it.""" + # Can use tenant_id in function body + with rls_transaction(tenant_id): + scan = Scan.objects.get(pk=scan_id) + # ... generate outputs + return {"scan_id": scan_id, "tenant_id": tenant_id} + + +# ============================================================================= +# TASK COMPOSITION (CANVAS) +# ============================================================================= + + +# Chain: Sequential execution - A β†’ B β†’ C +def example_chain(tenant_id: str): + """Tasks run one after another.""" + chain( + task_a.si(tenant_id=tenant_id), + task_b.si(tenant_id=tenant_id), + task_c.si(tenant_id=tenant_id), + ).apply_async() + + +# Group: Parallel execution - A, B, C simultaneously +def example_group(tenant_id: str): + """Tasks run at the same time.""" + group( + task_a.si(tenant_id=tenant_id), + task_b.si(tenant_id=tenant_id), + task_c.si(tenant_id=tenant_id), + ).apply_async() + + +# Combined: Real pattern from Prowler (post-scan workflow) +def post_scan_workflow(tenant_id: str, scan_id: str, provider_id: str): + """Chain with nested groups for complex workflows.""" + chain( + # First: Summary + perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id), + # Then: Parallel aggregation + outputs + group( + aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id), + generate_outputs_task.si( + scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id + ), + ), + # Finally: Parallel compliance + integrations + group( + generate_compliance_reports_task.si( + tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id + ), + check_integrations_task.si( + tenant_id=tenant_id, provider_id=provider_id, scan_id=scan_id + ), + ), + ).apply_async() + + +# Note: Use .si() (signature immutable) to prevent result passing. +# Use .s() if you need to pass results between tasks. + + +# ============================================================================= +# BEAT SCHEDULING (PERIODIC TASKS) +# ============================================================================= + + +def schedule_provider_scan(provider_id: str, tenant_id: str): + """Create a periodic task that runs every 24 hours.""" + # 1. Create or get the schedule + schedule, _ = IntervalSchedule.objects.get_or_create( + every=24, + period=IntervalSchedule.HOURS, + ) + + # 2. Create the periodic task + PeriodicTask.objects.create( + interval=schedule, + name=f"scan-perform-scheduled-{provider_id}", # Unique name + task="scan-perform-scheduled", # Task name (not function name) + kwargs=json.dumps( + { + "tenant_id": str(tenant_id), + "provider_id": str(provider_id), + } + ), + one_off=False, + start_time=datetime.now(timezone.utc) + timedelta(hours=24), + ) + + +def delete_scheduled_scan(provider_id: str): + """Remove a periodic task.""" + PeriodicTask.objects.filter(name=f"scan-perform-scheduled-{provider_id}").delete() + + +# Avoiding race conditions with countdown +def schedule_with_countdown(provider_id: str, tenant_id: str): + """Use countdown to ensure DB transaction commits before task runs.""" + perform_scheduled_scan_task.apply_async( + kwargs={"tenant_id": tenant_id, "provider_id": provider_id}, + countdown=5, # Wait 5 seconds + ) + + +# ============================================================================= +# ADVANCED TASK PATTERNS +# ============================================================================= + + +# bind=True - Access task metadata +@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans") +@set_tenant(keep_tenant=True) +def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): + """bind=True provides access to self.request for task metadata.""" + task_id = self.request.id # Current task ID + retries = self.request.retries # Number of retries so far + + with rls_transaction(tenant_id): + scan = Scan.objects.create( + provider_id=provider_id, + task_id=task_id, # Track which task started this scan + ) + return {"scan_id": str(scan.id), "task_id": task_id} + + +# get_task_logger - Proper logging in Celery tasks +@shared_task(base=RLSTask, name="my-task") +@set_tenant +def my_task_with_logging(provider_id: str): + """Always use get_task_logger for Celery task logging.""" + logger.info(f"Processing provider {provider_id}") + logger.warning("Potential issue detected") + logger.error("Failed to process") + + # Called with tenant_id in kwargs (decorator handles it) + # my_task_with_logging.delay(provider_id="...", tenant_id="...") + + +# SoftTimeLimitExceeded - Graceful timeout handling +@shared_task( + base=RLSTask, + soft_time_limit=300, # 5 minutes - raises SoftTimeLimitExceeded + time_limit=360, # 6 minutes - hard kill (SIGKILL) +) +@set_tenant(keep_tenant=True) +def long_running_task(tenant_id: str, scan_id: str): + """Handle soft time limits gracefully to save progress.""" + try: + with rls_transaction(tenant_id): + for batch in get_large_dataset(): + process_batch(batch) + except SoftTimeLimitExceeded: + logger.warning(f"Task soft limit exceeded for scan {scan_id}, saving progress...") + save_partial_progress(scan_id) + raise # Re-raise to mark task as failed + + +# Deferred execution - countdown and eta +def deferred_examples(): + """Execute tasks at specific times.""" + # Execute after 30 seconds + my_task.apply_async(kwargs={"provider_id": "..."}, countdown=30) + + # Execute at specific time + my_task.apply_async( + kwargs={"provider_id": "..."}, + eta=datetime(2024, 1, 15, 10, 0, tzinfo=timezone.utc), + ) + + +# ============================================================================= +# CELERY CONFIGURATION (config/celery.py) +# ============================================================================= + +# Example configuration - see actual file for full config +""" +from celery import Celery + +celery_app = Celery("tasks") +celery_app.config_from_object("django.conf:settings", namespace="CELERY") + +# Visibility timeout - CRITICAL for long-running tasks +# If task takes longer than this, broker assumes worker died and re-queues +BROKER_VISIBILITY_TIMEOUT = 86400 # 24 hours for scan tasks + +celery_app.conf.broker_transport_options = { + "visibility_timeout": BROKER_VISIBILITY_TIMEOUT +} +celery_app.conf.result_backend_transport_options = { + "visibility_timeout": BROKER_VISIBILITY_TIMEOUT +} + +# Result settings +celery_app.conf.update( + result_extended=True, # Store additional task metadata + result_expires=None, # Never expire results (we manage cleanup) +) +""" + +# Django settings (config/settings/celery.py) +""" +CELERY_BROKER_URL = f"redis://{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}" +CELERY_RESULT_BACKEND = "django-db" # Store results in PostgreSQL +CELERY_TASK_TRACK_STARTED = True # Track when tasks start +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True + +# Global time limits (optional) +CELERY_TASK_SOFT_TIME_LIMIT = 3600 # 1 hour soft limit +CELERY_TASK_TIME_LIMIT = 3660 # 1 hour + 1 minute hard limit +""" + + +# ============================================================================= +# ASYNC TASK RESPONSE PATTERN (202 Accepted) +# ============================================================================= + + +class ProviderViewSetExample: + """Example: Return 202 for long-running operations.""" + + def connection(self, request, pk=None): + """Trigger async connection check, return 202 with task location.""" + from django.urls import reverse + from rest_framework import status + from rest_framework.response import Response + + from api.models import Task + from api.v1.serializers import TaskSerializer + + with transaction.atomic(): + task = check_provider_connection_task.delay( + provider_id=pk, tenant_id=self.request.tenant_id + ) + prowler_task = Task.objects.get(id=task.id) + serializer = TaskSerializer(prowler_task) + return Response( + data=serializer.data, + status=status.HTTP_202_ACCEPTED, + headers={ + "Content-Location": reverse("task-detail", kwargs={"pk": prowler_task.id}) + }, + ) + + +# ============================================================================= +# PLACEHOLDERS (would exist in real codebase) +# ============================================================================= + +task_a = None +task_b = None +task_c = None +perform_scan_summary_task = None +aggregate_daily_severity_task = None +generate_compliance_reports_task = None +check_integrations_task = None +perform_scheduled_scan_task = None +my_task = None + + +def get_large_dataset(): + return [] + + +def process_batch(batch): + pass + + +def save_partial_progress(scan_id): + pass diff --git a/skills/prowler-api/assets/security_patterns.py b/skills/prowler-api/assets/security_patterns.py new file mode 100644 index 0000000000..981d78afbd --- /dev/null +++ b/skills/prowler-api/assets/security_patterns.py @@ -0,0 +1,207 @@ +# Example: Prowler API Security Patterns +# Reference for prowler-api skill + +import uuid + +from celery import shared_task +from celery.exceptions import SoftTimeLimitExceeded +from django.db import OperationalError, transaction +from rest_framework.exceptions import PermissionDenied + +from api.db_utils import rls_transaction +from api.decorators import handle_provider_deletion, set_tenant +from api.models import Finding, Provider +from api.rls import Tenant +from tasks.base import RLSTask + +# ============================================================================= +# TENANT ISOLATION (RLS) +# ============================================================================= + + +class ProviderViewSet: + """Example: RLS context set automatically by BaseRLSViewSet.""" + + def get_queryset(self): + # RLS already filters by tenant_id from JWT + # All queries are automatically tenant-scoped + return Provider.objects.all() + + +@shared_task(base=RLSTask) +@set_tenant +def process_scan_good(tenant_id, scan_id): + """GOOD: Explicit RLS context in Celery tasks.""" + with rls_transaction(tenant_id): + # RLS enforced - only sees tenant's data + scan = Scan.objects.get(id=scan_id) + return scan + + +def dangerous_function(provider_id): + """BAD: Bypassing RLS with admin database - exposes ALL tenants' data!""" + # NEVER do this unless absolutely necessary for cross-tenant admin ops + provider = Provider.objects.using("admin").get(id=provider_id) + return provider + + +# ============================================================================= +# CROSS-TENANT DATA LEAKAGE PREVENTION +# ============================================================================= + + +class SecureViewSet: + """Example: Defense-in-depth tenant validation.""" + + def get_object(self): + obj = super().get_object() + # Defense-in-depth: verify tenant even though RLS should filter + if obj.tenant_id != self.request.tenant_id: + raise PermissionDenied("Access denied") + return obj + + def create_good(self, request): + """GOOD: Use tenant from authenticated JWT.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(tenant_id=request.tenant_id) + + def create_bad(self, request): + """BAD: Trust user input for tenant_id.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + # NEVER trust user-provided tenant_id! + serializer.save(tenant_id=request.data.get("tenant_id")) + + +# ============================================================================= +# CELERY TASK SECURITY +# ============================================================================= + + +@shared_task(base=RLSTask) +@set_tenant +def process_provider(tenant_id, provider_id): + """Example: Validate task arguments before processing.""" + # Validate UUID format before database query + try: + uuid.UUID(provider_id) + except ValueError: + # Log and return - don't expose error details + return {"error": "Invalid provider_id format"} + + with rls_transaction(tenant_id): + # Now safe to query + provider = Provider.objects.get(id=provider_id) + return {"provider": str(provider.id)} + + +def send_task_bad(user_provided_task_name, args): + """BAD: Dynamic task names from user input = arbitrary code execution.""" + from celery import current_app + + # NEVER do this! + current_app.send_task(user_provided_task_name, args=args) + + +# ============================================================================= +# SAFE TASK QUEUING WITH TRANSACTIONS +# ============================================================================= + + +def create_provider_good(request, data): + """GOOD: Task only enqueued AFTER transaction commits.""" + with transaction.atomic(): + provider = Provider.objects.create(**data) + # Task enqueued only if transaction succeeds + transaction.on_commit( + lambda: verify_provider_connection.delay( + tenant_id=str(request.tenant_id), provider_id=str(provider.id) + ) + ) + return provider + + +def create_provider_bad(request, data): + """BAD: Task enqueued before transaction commits - race condition!""" + with transaction.atomic(): + provider = Provider.objects.create(**data) + # Task might run before transaction commits! + # If transaction rolls back, task processes non-existent data + verify_provider_connection.delay(provider_id=str(provider.id)) + return provider + + +# ============================================================================= +# MODERN CELERY RETRY PATTERNS +# ============================================================================= + + +@shared_task( + base=RLSTask, + bind=True, + # Automatic retry for transient errors + autoretry_for=(ConnectionError, TimeoutError, OperationalError), + retry_backoff=True, # Exponential: 1s, 2s, 4s, 8s... + retry_backoff_max=600, # Cap at 10 minutes + retry_jitter=True, # Randomize to prevent thundering herd + max_retries=5, + # Time limits prevent hung tasks + soft_time_limit=300, # 5 min: raises SoftTimeLimitExceeded + time_limit=360, # 6 min: hard kill +) +@set_tenant +def sync_provider_data(self, tenant_id, provider_id): + """Example: Modern retry pattern with time limits.""" + try: + with rls_transaction(tenant_id): + provider = Provider.objects.get(id=provider_id) + # ... sync logic + return {"status": "synced", "provider": str(provider.id)} + except SoftTimeLimitExceeded: + # Cleanup and exit gracefully + return {"status": "timeout", "provider": provider_id} + + +# ============================================================================= +# IDEMPOTENT TASK DESIGN +# ============================================================================= + + +@shared_task(base=RLSTask, acks_late=True) +@set_tenant +def process_finding_good(tenant_id, finding_uid, data): + """GOOD: Idempotent - safe to retry, uses upsert pattern.""" + with rls_transaction(tenant_id): + # update_or_create is idempotent - retry won't create duplicates + Finding.objects.update_or_create(uid=finding_uid, defaults=data) + + +@shared_task(base=RLSTask) +@set_tenant +def create_notification_bad(tenant_id, message): + """BAD: Non-idempotent - retry creates duplicates.""" + with rls_transaction(tenant_id): + # No dedup key - every retry creates a new notification! + Notification.objects.create(message=message) + + +@shared_task(base=RLSTask, acks_late=True) +@set_tenant +def send_notification_good(tenant_id, idempotency_key, message): + """GOOD: Idempotency key for non-upsertable operations.""" + with rls_transaction(tenant_id): + # Check if already processed + if ProcessedTask.objects.filter(key=idempotency_key).exists(): + return {"status": "already_processed"} + + Notification.objects.create(message=message) + ProcessedTask.objects.create(key=idempotency_key) + return {"status": "sent"} + + +# Placeholder for imports that would exist in real codebase +verify_provider_connection = None +Scan = None +Notification = None +ProcessedTask = None diff --git a/skills/prowler-api/references/api-docs.md b/skills/prowler-api/references/api-docs.md deleted file mode 100644 index 72bc7990fd..0000000000 --- a/skills/prowler-api/references/api-docs.md +++ /dev/null @@ -1,21 +0,0 @@ -# API Documentation - -## Local Documentation - -For API-related patterns, see: - -- `api/src/backend/api/models.py` - Models, Providers, UID validation -- `api/src/backend/api/v1/views.py` - ViewSets, RBAC patterns -- `api/src/backend/api/v1/serializers.py` - Serializers -- `api/src/backend/api/rbac/permissions.py` - RBAC functions -- `api/src/backend/tasks/tasks.py` - Celery tasks -- `api/src/backend/api/db_utils.py` - rls_transaction - -## Contents - -The documentation covers: -- Row-Level Security (RLS) implementation -- RBAC permission system -- Provider validation patterns -- Celery task orchestration -- JSON:API serialization format diff --git a/skills/prowler-api/references/configuration.md b/skills/prowler-api/references/configuration.md new file mode 100644 index 0000000000..dac5c735bf --- /dev/null +++ b/skills/prowler-api/references/configuration.md @@ -0,0 +1,282 @@ +# Prowler API Configuration Reference + +## Settings File Structure + +``` +api/src/backend/config/ +β”œβ”€β”€ django/ +β”‚ β”œβ”€β”€ base.py # Base settings (all environments) +β”‚ β”œβ”€β”€ devel.py # Development overrides +β”‚ β”œβ”€β”€ production.py # Production settings +β”‚ └── testing.py # Test settings +β”œβ”€β”€ settings/ +β”‚ β”œβ”€β”€ celery.py # Celery broker/backend config +β”‚ β”œβ”€β”€ partitions.py # Table partitioning settings +β”‚ β”œβ”€β”€ sentry.py # Error tracking + exception filtering +β”‚ └── social_login.py # OAuth/SAML providers +β”œβ”€β”€ celery.py # Celery app instance + RLSTask +β”œβ”€β”€ custom_logging.py # NDJSON/Human-readable formatters +β”œβ”€β”€ env.py # django-environ setup +└── urls.py # Root URL config +``` + +--- + +## REST Framework Configuration + +### Complete `REST_FRAMEWORK` Settings + +```python +REST_FRAMEWORK = { + # Schema Generation (JSON:API compatible) + "DEFAULT_SCHEMA_CLASS": "drf_spectacular_jsonapi.schemas.openapi.JsonApiAutoSchema", + + # Authentication (JWT + API Key) + "DEFAULT_AUTHENTICATION_CLASSES": ( + "api.authentication.CombinedJWTOrAPIKeyAuthentication", + ), + + # Pagination + "PAGE_SIZE": 10, + "DEFAULT_PAGINATION_CLASS": "drf_spectacular_jsonapi.schemas.pagination.JsonApiPageNumberPagination", + + # Custom exception handler (JSON:API format) + "EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler", + + # Parsers (JSON:API compatible) + "DEFAULT_PARSER_CLASSES": ( + "rest_framework_json_api.parsers.JSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", + ), + + # Custom renderer with RLS context support + "DEFAULT_RENDERER_CLASSES": ("api.renderers.APIJSONRenderer",), + + # Metadata + "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", + + # Filter Backends + "DEFAULT_FILTER_BACKENDS": ( + "rest_framework_json_api.filters.QueryParameterValidationFilter", + "rest_framework_json_api.filters.OrderingFilter", + "rest_framework_json_api.django_filters.backends.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", + ), + + # JSON:API search parameter + "SEARCH_PARAM": "filter[search]", + + # Test settings + "TEST_REQUEST_RENDERER_CLASSES": ("rest_framework_json_api.renderers.JSONRenderer",), + "TEST_REQUEST_DEFAULT_FORMAT": "vnd.api+json", + + # Uniform exception format + "JSON_API_UNIFORM_EXCEPTIONS": True, + + # Throttling + "DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"], + "DEFAULT_THROTTLE_RATES": { + "token-obtain": env("DJANGO_THROTTLE_TOKEN_OBTAIN", default=None), + "dj_rest_auth": None, + }, +} +``` + +### Throttling Configuration + +| Scope | Environment Variable | Default | Format | +|-------|---------------------|---------|--------| +| `token-obtain` | `DJANGO_THROTTLE_TOKEN_OBTAIN` | `None` (disabled) | `"X/minute"`, `"X/hour"`, `"X/day"` | +| `dj_rest_auth` | N/A | `None` (disabled) | Same | + +**To enable throttling:** +```bash +DJANGO_THROTTLE_TOKEN_OBTAIN="10/minute" # Limit token endpoint to 10 requests/minute +``` + +--- + +## JWT Configuration (SIMPLE_JWT) + +```python +SIMPLE_JWT = { + # Token Lifetimes + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), # DJANGO_ACCESS_TOKEN_LIFETIME + "REFRESH_TOKEN_LIFETIME": timedelta(minutes=1440), # DJANGO_REFRESH_TOKEN_LIFETIME (24h) + + # Token Rotation + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + + # Cryptographic Settings + "ALGORITHM": "RS256", # Asymmetric (requires key pair) + "SIGNING_KEY": env.str("DJANGO_TOKEN_SIGNING_KEY", ""), + "VERIFYING_KEY": env.str("DJANGO_TOKEN_VERIFYING_KEY", ""), + + # JWT Claims + "TOKEN_TYPE_CLAIM": "typ", + "JTI_CLAIM": "jti", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "sub", + + # Issuer/Audience + "AUDIENCE": env.str("DJANGO_JWT_AUDIENCE", "https://api.prowler.com"), + "ISSUER": env.str("DJANGO_JWT_ISSUER", "https://api.prowler.com"), + + # Custom Serializers + "TOKEN_OBTAIN_SERIALIZER": "api.serializers.TokenSerializer", + "TOKEN_REFRESH_SERIALIZER": "api.serializers.TokenRefreshSerializer", +} +``` + +--- + +## Database Configuration + +### 4-Database Architecture + +```python +DATABASES = { + "default": {...}, # Alias to prowler_user (RLS enabled) + "prowler_user": {...}, # RLS-enabled connection + "admin": {...}, # Admin connection (bypasses RLS) + "replica": {...}, # Read replica (RLS enabled) + "admin_replica": {...}, # Admin on replica + "neo4j": {...}, # Graph database (attack paths) +} +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `POSTGRES_DB` | `prowler_db` | Database name | +| `POSTGRES_USER` | `prowler_user` | API user (RLS-constrained) | +| `POSTGRES_PASSWORD` | - | API user password | +| `POSTGRES_HOST` | `postgres-db` | Database host | +| `POSTGRES_PORT` | `5432` | Database port | +| `POSTGRES_ADMIN_USER` | `prowler` | Admin user (migrations) | +| `POSTGRES_ADMIN_PASSWORD` | - | Admin password | +| `POSTGRES_REPLICA_HOST` | - | Replica host (optional) | +| `POSTGRES_REPLICA_MAX_ATTEMPTS` | `3` | Retry attempts before fallback | +| `POSTGRES_REPLICA_RETRY_BASE_DELAY` | `0.5` | Base delay for exponential backoff | + +--- + +## Celery Configuration + +### Broker/Backend + +```python +VALKEY_HOST = env("VALKEY_HOST", default="valkey") +VALKEY_PORT = env("VALKEY_PORT", default="6379") +VALKEY_DB = env("VALKEY_DB", default="0") + +CELERY_BROKER_URL = f"redis://{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}" +CELERY_RESULT_BACKEND = "django-db" # Store results in PostgreSQL +CELERY_TASK_TRACK_STARTED = True +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True +``` + +### Task Visibility + +| Variable | Default | Description | +|----------|---------|-------------| +| `DJANGO_BROKER_VISIBILITY_TIMEOUT` | `86400` (24h) | Task visibility timeout | +| `DJANGO_CELERY_DEADLOCK_ATTEMPTS` | `5` | Deadlock retry attempts | + +--- + +## Partitioning Configuration + +```python +PSQLEXTRA_PARTITIONING_MANAGER = "api.partitions.manager" +FINDINGS_TABLE_PARTITION_MONTHS = env.int("FINDINGS_TABLE_PARTITION_MONTHS", 1) +FINDINGS_TABLE_PARTITION_COUNT = env.int("FINDINGS_TABLE_PARTITION_COUNT", 7) +FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS = env.int("...", None) # Optional cleanup +``` + +--- + +## Application Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `DJANGO_DEBUG` | `False` | Debug mode | +| `DJANGO_ALLOWED_HOSTS` | `["localhost"]` | Allowed hosts | +| `DJANGO_CACHE_MAX_AGE` | `3600` | HTTP cache max-age | +| `DJANGO_STALE_WHILE_REVALIDATE` | `60` | Stale-while-revalidate time | +| `DJANGO_FINDINGS_MAX_DAYS_IN_RANGE` | `7` | Max days for findings date filter | +| `DJANGO_TMP_OUTPUT_DIRECTORY` | `/tmp/prowler_api_output` | Temp output directory | +| `DJANGO_FINDINGS_BATCH_SIZE` | `1000` | Batch size for findings export | +| `DJANGO_DELETION_BATCH_SIZE` | `5000` | Batch size for deletions | +| `DJANGO_LOGGING_LEVEL` | `INFO` | Log level | +| `DJANGO_LOGGING_FORMATTER` | `ndjson` | Log format (`ndjson` or `human_readable`) | + +--- + +## Social Login (OAuth/SAML) + +| Variable | Description | +|----------|-------------| +| `SOCIAL_GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID | +| `SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth secret | +| `SOCIAL_GITHUB_OAUTH_CLIENT_ID` | GitHub OAuth client ID | +| `SOCIAL_GITHUB_OAUTH_CLIENT_SECRET` | GitHub OAuth secret | + +--- + +## Monitoring + +| Variable | Description | +|----------|-------------| +| `DJANGO_SENTRY_DSN` | Sentry DSN for error tracking | + +--- + +## Middleware Stack (Order Matters) + +```python +MIDDLEWARE = [ + "django_guid.middleware.guid_middleware", # 1. Transaction ID + "django.middleware.security.SecurityMiddleware", # 2. Security headers + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", # 4. CORS (before Common) + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "api.middleware.APILoggingMiddleware", # 10. Custom API logging + "allauth.account.middleware.AccountMiddleware", +] +``` + +--- + +## Security Headers + +| Setting | Value | Description | +|---------|-------|-------------| +| `SECURE_PROXY_SSL_HEADER` | `("HTTP_X_FORWARDED_PROTO", "https")` | Trust X-Forwarded-Proto | +| `SECURE_CONTENT_TYPE_NOSNIFF` | `True` | X-Content-Type-Options: nosniff | +| `X_FRAME_OPTIONS` | `"DENY"` | Prevent framing | +| `CSRF_COOKIE_SECURE` | `True` | HTTPS-only CSRF cookie | +| `SESSION_COOKIE_SECURE` | `True` | HTTPS-only session cookie | + +--- + +## Password Validators + +| Validator | Options | +|-----------|---------| +| `UserAttributeSimilarityValidator` | Default | +| `MinimumLengthValidator` | `min_length=12` | +| `MaximumLengthValidator` | `max_length=72` (bcrypt limit) | +| `CommonPasswordValidator` | Default | +| `NumericPasswordValidator` | Default | +| `SpecialCharactersValidator` | `min_special_characters=1` | +| `UppercaseValidator` | `min_uppercase=1` | +| `LowercaseValidator` | `min_lowercase=1` | +| `NumericValidator` | `min_numeric=1` | diff --git a/skills/prowler-api/references/file-locations.md b/skills/prowler-api/references/file-locations.md new file mode 100644 index 0000000000..8b1eff65e3 --- /dev/null +++ b/skills/prowler-api/references/file-locations.md @@ -0,0 +1,128 @@ +# Prowler API File Locations + +## Configuration + +| Purpose | File Path | Key Items | +|---------|-----------|-----------| +| **Django Settings** | `api/src/backend/config/settings.py` | REST_FRAMEWORK, SIMPLE_JWT, DATABASES | +| **Celery Config** | `api/src/backend/config/celery.py` | Celery app, queues, task routing | +| **URL Routing** | `api/src/backend/config/urls.py` | Main URL patterns | +| **Database Router** | `api/src/backend/api/db_router.py` | `MainRouter` (4-database architecture) | + +## RLS (Row-Level Security) + +| Pattern | File Path | Key Classes/Functions | +|---------|-----------|----------------------| +| **RLS Base Model** | `api/src/backend/api/rls.py` | `RowLevelSecurityProtectedModel`, `RowLevelSecurityConstraint` | +| **RLS Transaction** | `api/src/backend/api/db_utils.py` | `rls_transaction()` context manager | +| **RLS Serializer** | `api/src/backend/api/v1/serializers.py` | `RLSSerializer` - auto-injects tenant_id | +| **Tenant Model** | `api/src/backend/api/rls.py` | `Tenant` model | +| **Partitioning** | `api/src/backend/api/partitions.py` | `PartitionManager`, UUIDv7 partitioning | + +## RBAC (Role-Based Access Control) + +| Pattern | File Path | Key Classes/Functions | +|---------|-----------|----------------------| +| **Permissions** | `api/src/backend/api/rbac/permissions.py` | `Permissions` enum, `get_role()`, `get_providers()` | +| **Role Model** | `api/src/backend/api/models.py` | `Role`, `UserRoleRelationship`, `RoleProviderGroupRelationship` | +| **Permission Decorator** | `api/src/backend/api/decorators.py` | `@check_permissions`, `HasPermissions` | +| **Visibility Filter** | `api/src/backend/api/rbac/` | Provider group visibility filtering | + +## Providers + +| Pattern | File Path | Key Classes/Functions | +|---------|-----------|----------------------| +| **Provider Model** | `api/src/backend/api/models.py` | `Provider`, `ProviderChoices` | +| **UID Validation** | `api/src/backend/api/models.py` | `validate__uid()` staticmethods | +| **Provider Secret** | `api/src/backend/api/models.py` | `ProviderSecret` model | +| **Provider Groups** | `api/src/backend/api/models.py` | `ProviderGroup`, `ProviderGroupMembership` | + +## Serializers + +| Pattern | File Path | Key Classes/Functions | +|---------|-----------|----------------------| +| **Base Serializers** | `api/src/backend/api/v1/serializers.py` | `BaseModelSerializerV1`, `RLSSerializer`, `BaseWriteSerializer` | +| **ViewSet Helpers** | `api/src/backend/api/v1/serializers.py` | `get_serializer_class_for_view()` | + +## ViewSets + +| Pattern | File Path | Key Classes/Functions | +|---------|-----------|----------------------| +| **Base ViewSets** | `api/src/backend/api/v1/views.py` | `BaseViewSet`, `BaseRLSViewSet`, `BaseTenantViewset`, `BaseUserViewset` | +| **Custom Actions** | `api/src/backend/api/v1/views.py` | `@action(detail=True)` patterns | +| **Filters** | `api/src/backend/api/filters.py` | `BaseProviderFilter`, `BaseScanProviderFilter`, `CommonFindingFilters` | + +## Celery Tasks + +| Pattern | File Path | Key Classes/Functions | +|---------|-----------|----------------------| +| **Task Definitions** | `api/src/backend/tasks/tasks.py` | All `@shared_task` definitions | +| **RLS Task Base** | `api/src/backend/config/celery.py` | `RLSTask` base class (creates APITask on dispatch) | +| **Task Decorators** | `api/src/backend/api/decorators.py` | `@set_tenant`, `@handle_provider_deletion` | +| **Celery Config** | `api/src/backend/config/celery.py` | Celery app, broker settings, visibility timeout | +| **Django Settings** | `api/src/backend/config/settings/celery.py` | `CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND` | +| **Beat Schedule** | `api/src/backend/tasks/beat.py` | `schedule_provider_scan()`, `PeriodicTask` creation | +| **Task Utilities** | `api/src/backend/tasks/utils.py` | `batched()`, `get_next_execution_datetime()` | + +### Task Jobs (Business Logic) + +| Job File | Purpose | +|----------|---------| +| `tasks/jobs/scan.py` | `perform_prowler_scan()`, `aggregate_findings()`, `aggregate_attack_surface()` | +| `tasks/jobs/deletion.py` | `delete_provider()`, `delete_tenant()` | +| `tasks/jobs/backfill.py` | Historical data backfill operations | +| `tasks/jobs/export.py` | Output file generation (CSV, JSON, HTML) | +| `tasks/jobs/report.py` | PDF report generation (ThreatScore, ENS, NIS2) | +| `tasks/jobs/connection.py` | Provider/integration connection checks | +| `tasks/jobs/integrations.py` | S3, Security Hub, Jira uploads | +| `tasks/jobs/muting.py` | Historical findings muting | +| `tasks/jobs/attack_paths/` | Attack paths scan (Neo4j/Cartography) | + +## Key Line References + +### RLS Transaction (api/src/backend/api/db_utils.py) +```python +# Usage pattern +from api.db_utils import rls_transaction + +with rls_transaction(tenant_id): + # All queries here are tenant-scoped + providers = Provider.objects.filter(connected=True) +``` + +### RBAC Check (api/src/backend/api/rbac/permissions.py) +```python +# Usage pattern +from api.rbac.permissions import get_role, get_providers, Permissions + +user_role = get_role(request.user) # Returns FIRST role only +if user_role.unlimited_visibility: + queryset = Provider.objects.all() +else: + queryset = get_providers(user_role) +``` + +### Celery Task (api/src/backend/tasks/tasks.py) +```python +# Usage pattern +@shared_task(base=RLSTask, name="task-name", queue="scans") +@set_tenant +@handle_provider_deletion +def my_task(tenant_id: str, provider_id: str): + with rls_transaction(tenant_id): + provider = Provider.objects.get(pk=provider_id) +``` + +## Tests + +| Type | Path | +|------|------| +| **Central Fixtures** | `api/src/backend/conftest.py` | +| **API Tests** | `api/src/backend/api/tests/` | +| **Integration Tests** | `api/src/backend/api/tests/integration/` | +| **Task Tests** | `api/src/backend/tasks/tests/` | + +## Related Skills + +- **Generic DRF patterns**: Use `django-drf` skill for ViewSets, Serializers, Filters, JSON:API +- **API Testing**: Use `prowler-test-api` skill for testing patterns diff --git a/skills/prowler-api/references/modeling-decisions.md b/skills/prowler-api/references/modeling-decisions.md new file mode 100644 index 0000000000..68923c4ef4 --- /dev/null +++ b/skills/prowler-api/references/modeling-decisions.md @@ -0,0 +1,274 @@ +# Django Model Design Decisions + +## When to Use What + +### Primary Keys + +| Pattern | When to Use | Example | +|---------|-------------|---------| +| `uuid4` | Default for most models | `id = models.UUIDField(primary_key=True, default=uuid4)` | +| `uuid7` | Time-ordered data (findings, scans) | `id = models.UUIDField(primary_key=True, default=uuid7)` | + +**Why uuid7 for time-series?** UUIDv7 includes timestamp, enabling efficient range queries and partitioning. + +### Timestamps + +| Field | Pattern | Purpose | +|-------|---------|---------| +| `inserted_at` | `auto_now_add=True, editable=False` | Creation time, never changes | +| `updated_at` | `auto_now=True, editable=False` | Last modification time | + +### Soft Delete + +```python +# Model +is_deleted = models.BooleanField(default=False) + +# Custom manager (excludes deleted by default) +class ActiveProviderManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_deleted=False) + +# Usage +objects = ActiveProviderManager() # Normal queries +all_objects = models.Manager() # Include deleted +``` + +### TextChoices Enums + +```python +class StateChoices(models.TextChoices): + AVAILABLE = "available", _("Available") + SCHEDULED = "scheduled", _("Scheduled") + EXECUTING = "executing", _("Executing") + COMPLETED = "completed", _("Completed") + FAILED = "failed", _("Failed") +``` + +### Constraints + +| Constraint | When to Use | +|------------|-------------| +| `UniqueConstraint` | Prevent duplicates within tenant scope | +| `UniqueConstraint + condition` | Unique only for non-deleted records | +| `RowLevelSecurityConstraint` | ALL RLS-protected models (mandatory) | + +```python +constraints = [ + # Unique provider UID per tenant (only for active providers) + models.UniqueConstraint( + fields=("tenant_id", "provider", "uid"), + condition=Q(is_deleted=False), + name="unique_provider_uids", + ), + # RLS constraint (REQUIRED for all tenant-scoped models) + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), +] +``` + +### Indexes + +| Index Type | When to Use | Example | +|------------|-------------|---------| +| `models.Index` | Frequent queries | `fields=["tenant_id", "provider_id"]` | +| `GinIndex` | Full-text search, ArrayField | `fields=["text_search"]` | +| Conditional Index | Specific query patterns | `condition=Q(state="completed")` | +| Covering Index | Avoid table lookups | `include=["id", "name"]` | + +```python +indexes = [ + # Common query pattern + models.Index( + fields=["tenant_id", "provider_id", "-inserted_at"], + name="scans_prov_ins_desc_idx", + ), + # Conditional: only completed scans + models.Index( + fields=["tenant_id", "provider_id", "-inserted_at"], + condition=Q(state=StateChoices.COMPLETED), + name="scans_completed_idx", + ), + # Covering: include extra columns to avoid table lookup + models.Index( + fields=["tenant_id", "provider_id"], + include=["id", "graph_database"], + name="aps_active_graph_idx", + ), + # Full-text search + GinIndex(fields=["text_search"], name="gin_resources_search_idx"), +] +``` + +### Full-Text Search + +```python +from django.contrib.postgres.search import SearchVector, SearchVectorField + +text_search = models.GeneratedField( + expression=SearchVector("uid", weight="A", config="simple") + + SearchVector("name", weight="B", config="simple"), + output_field=SearchVectorField(), + db_persist=True, + null=True, + editable=False, +) +``` + +### ArrayField + +```python +from django.contrib.postgres.fields import ArrayField + +groups = ArrayField( + models.CharField(max_length=100), + blank=True, + null=True, + help_text="Groups for categorization", +) +``` + +### JSONField + +```python +# Structured data with defaults +metadata = models.JSONField(default=dict, blank=True) +scanner_args = models.JSONField(default=dict, blank=True) +``` + +### Encrypted Fields + +```python +# Binary field for encrypted data +_secret = models.BinaryField(db_column="secret") + +@property +def secret(self): + # Decrypt on read + decrypted_data = fernet.decrypt(self._secret) + return json.loads(decrypted_data.decode()) + +@secret.setter +def secret(self, value): + # Encrypt on write + self._secret = fernet.encrypt(json.dumps(value).encode()) +``` + +### Foreign Keys + +| on_delete | When to Use | +|-----------|-------------| +| `CASCADE` | Child cannot exist without parent (Finding β†’ Scan) | +| `SET_NULL` | Optional relationship, keep child (Task β†’ PeriodicTask) | +| `PROTECT` | Prevent deletion if children exist | + +```python +# Required relationship +provider = models.ForeignKey( + Provider, + on_delete=models.CASCADE, + related_name="scans", + related_query_name="scan", +) + +# Optional relationship +scheduler_task = models.ForeignKey( + PeriodicTask, + on_delete=models.SET_NULL, + null=True, + blank=True, +) +``` + +### Many-to-Many with Through Table + +```python +# On the model +tags = models.ManyToManyField( + ResourceTag, + through="ResourceTagMapping", + related_name="resources", +) + +# Through table (for RLS + extra fields) +class ResourceTagMapping(RowLevelSecurityProtectedModel): + id = models.UUIDField(primary_key=True, default=uuid4) + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + tag = models.ForeignKey(ResourceTag, on_delete=models.CASCADE) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "resource_id", "tag_id"), + name="unique_resource_tag_mappings", + ), + RowLevelSecurityConstraint(...), + ] +``` + +### Partitioned Tables + +```python +from psqlextra.models import PostgresPartitionedModel +from psqlextra.types import PostgresPartitioningMethod + +class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): + class PartitioningMeta: + method = PostgresPartitioningMethod.RANGE + key = ["id"] # UUIDv7 for time-based partitioning +``` + +**Use for:** High-volume, time-series data (findings, resource mappings) + +### Model Validation + +```python +def clean(self): + super().clean() + # Dynamic validation based on field value + getattr(self, f"validate_{self.provider}_uid")(self.uid) + +def save(self, *args, **kwargs): + self.full_clean() # Always validate before save + super().save(*args, **kwargs) +``` + +### JSONAPIMeta + +```python +class JSONAPIMeta: + resource_name = "provider-groups" # kebab-case, plural +``` + +--- + +## Decision Tree: New Model + +``` +Is it tenant-scoped data? +β”œβ”€β”€ Yes β†’ Inherit RowLevelSecurityProtectedModel +β”‚ Add RowLevelSecurityConstraint +β”‚ Consider: soft-delete? partitioning? +└── No β†’ Regular models.Model (rare in Prowler) + +Does it need time-ordering for queries? +β”œβ”€β”€ Yes β†’ Use uuid7 for primary key +└── No β†’ Use uuid4 (default) + +Is it high-volume time-series data? +β”œβ”€β”€ Yes β†’ Use PostgresPartitionedModel +β”‚ Partition by id (uuid7) +└── No β†’ Regular model + +Does it reference Provider? +β”œβ”€β”€ Yes β†’ Add ActiveProviderManager +β”‚ Use CASCADE or filter is_deleted +└── No β†’ Standard manager + +Needs full-text search? +β”œβ”€β”€ Yes β†’ Add SearchVectorField + GinIndex +└── No β†’ Skip +``` diff --git a/skills/prowler-api/references/production-settings.md b/skills/prowler-api/references/production-settings.md new file mode 100644 index 0000000000..ea9b59c328 --- /dev/null +++ b/skills/prowler-api/references/production-settings.md @@ -0,0 +1,180 @@ +# Production Settings Reference + +## Django Deployment Checklist Command + +```bash +cd api && poetry run python src/backend/manage.py check --deploy +``` + +This command checks for common deployment issues and missing security settings. + +--- + +## Critical Settings Table + +| Setting | Production Value | Risk if Wrong | +|---------|-----------------|---------------| +| `DEBUG` | `False` | Exposes stack traces, settings, SQL queries | +| `SECRET_KEY` | Env var, rotated | Session hijacking, CSRF bypass | +| `ALLOWED_HOSTS` | Explicit list | Host header attacks | +| `SECURE_SSL_REDIRECT` | `True` | Credentials sent over HTTP | +| `SESSION_COOKIE_SECURE` | `True` | Session cookies over HTTP | +| `CSRF_COOKIE_SECURE` | `True` | CSRF tokens over HTTP | +| `SECURE_HSTS_SECONDS` | `31536000` (1 year) | Downgrade attacks | +| `CONN_MAX_AGE` | `60` or higher | Connection pool exhaustion | + +--- + +## Full Production Settings Example + +```python +# settings/production.py +import environ + +env = environ.Env() + +# ============================================================================= +# CORE SECURITY +# ============================================================================= + +DEBUG = False # NEVER True in production + +# Load from environment - NEVER hardcode +SECRET_KEY = env("SECRET_KEY") + +# Explicit list - no wildcards +ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") +# Example: ALLOWED_HOSTS=api.prowler.com,prowler.com + +# ============================================================================= +# HTTPS ENFORCEMENT +# ============================================================================= + +# Redirect all HTTP to HTTPS +SECURE_SSL_REDIRECT = True + +# Trust X-Forwarded-Proto header from reverse proxy (nginx, ALB, etc.) +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# ============================================================================= +# SECURE COOKIES +# ============================================================================= + +# Only send session cookie over HTTPS +SESSION_COOKIE_SECURE = True + +# Only send CSRF cookie over HTTPS +CSRF_COOKIE_SECURE = True + +# Prevent JavaScript access to session cookie (XSS protection) +SESSION_COOKIE_HTTPONLY = True + +# SameSite attribute for CSRF protection +CSRF_COOKIE_SAMESITE = "Strict" +SESSION_COOKIE_SAMESITE = "Strict" + +# ============================================================================= +# HTTP STRICT TRANSPORT SECURITY (HSTS) +# ============================================================================= + +# Tell browsers to always use HTTPS for this domain +SECURE_HSTS_SECONDS = 31536000 # 1 year + +# Apply HSTS to all subdomains +SECURE_HSTS_INCLUDE_SUBDOMAINS = True + +# Allow browser preload lists (requires domain submission) +SECURE_HSTS_PRELOAD = True + +# ============================================================================= +# CONTENT SECURITY +# ============================================================================= + +# Prevent clickjacking - deny all framing +X_FRAME_OPTIONS = "DENY" + +# Prevent MIME type sniffing +SECURE_CONTENT_TYPE_NOSNIFF = True + +# Enable XSS filter in older browsers +SECURE_BROWSER_XSS_FILTER = True + +# ============================================================================= +# DATABASE +# ============================================================================= + +# Connection pooling - reuse connections for 60 seconds +# Reduces connection overhead for frequent requests +CONN_MAX_AGE = 60 + +# For high-traffic: consider connection pooler like PgBouncer +# CONN_MAX_AGE = None # Let PgBouncer manage connections + +# ============================================================================= +# LOGGING +# ============================================================================= + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", # WARNING in production to reduce noise + }, + "loggers": { + "django.security": { + "handlers": ["console"], + "level": "WARNING", + "propagate": False, + }, + }, +} +``` + +--- + +## Environment Variables Checklist + +Required environment variables for production: + +```bash +# Core +SECRET_KEY= +ALLOWED_HOSTS=api.example.com,example.com +DEBUG=False + +# Database +DATABASE_URL= +# Or individual vars: +POSTGRES_HOST=... +POSTGRES_PORT=5432 +POSTGRES_DB=... +POSTGRES_USER=... +POSTGRES_PASSWORD=... + +# Redis (for Celery) +REDIS_URL=redis://host:6379/0 + +# Optional +SENTRY_DSN=https://...@sentry.io/... +``` + +--- + +## References + +- [Django Deployment Checklist](https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/) +- [Django Security Settings](https://docs.djangoproject.com/en/5.2/topics/security/) +- [OWASP Secure Headers](https://owasp.org/www-project-secure-headers/) diff --git a/skills/prowler-changelog/SKILL.md b/skills/prowler-changelog/SKILL.md new file mode 100644 index 0000000000..85a7fa4ba0 --- /dev/null +++ b/skills/prowler-changelog/SKILL.md @@ -0,0 +1,221 @@ +--- +name: prowler-changelog +description: > + Manages changelog entries for Prowler components following keepachangelog.com format. + Trigger: When creating PRs, adding changelog entries, or working with any CHANGELOG.md file in ui/, api/, mcp_server/, or prowler/. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, ui, api, sdk, mcp_server] + auto_invoke: + - "Add changelog entry for a PR or feature" + - "Update CHANGELOG.md in any component" + - "Create PR that requires changelog entry" + - "Review changelog format and conventions" +allowed-tools: Read, Edit, Write, Glob, Grep, Bash +--- + +## Changelog Locations + +| Component | File | Version Prefix | Current Version | +|-----------|------|----------------|-----------------| +| UI | `ui/CHANGELOG.md` | None | 1.x.x | +| API | `api/CHANGELOG.md` | None | 1.x.x | +| MCP Server | `mcp_server/CHANGELOG.md` | None | 0.x.x | +| SDK | `prowler/CHANGELOG.md` | None | 5.x.x | + +## Format Rules (keepachangelog.com) + +### Section Order (ALWAYS this order) + +```markdown +## [X.Y.Z] (Prowler vA.B.C) OR (Prowler UNRELEASED) + +### Added +### Changed +### Deprecated +### Removed +### Fixed +### Security +``` + +### Emoji Prefixes (REQUIRED for ALL components) + +| Section | Emoji | Usage | +|---------|-------|-------| +| Added | `### πŸš€ Added` | New features, checks, endpoints | +| Changed | `### πŸ”„ Changed` | Modifications to existing functionality | +| Deprecated | `### ⚠️ Deprecated` | Features marked for removal | +| Removed | `### ❌ Removed` | Deleted features | +| Fixed | `### 🐞 Fixed` | Bug fixes | +| Security | `### πŸ” Security` | Security patches, CVE fixes | + +### Entry Format + +```markdown +### Added + +- Existing entry one [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +- Existing entry two [(#YYYY)](https://github.com/prowler-cloud/prowler/pull/YYYY) +- NEW ENTRY GOES HERE at the BOTTOM [(#ZZZZ)](https://github.com/prowler-cloud/prowler/pull/ZZZZ) + +### Changed + +- Existing change [(#AAAA)](https://github.com/prowler-cloud/prowler/pull/AAAA) +- NEW CHANGE ENTRY at BOTTOM [(#BBBB)](https://github.com/prowler-cloud/prowler/pull/BBBB) +``` + +**Rules:** +- **ADD NEW ENTRIES AT THE BOTTOM of each section** (before next section header or `---`) +- **Blank line after section header** before first entry +- **Blank line between sections** +- Be specific: what changed, not why (that's in the PR) +- One entry per PR (can link multiple PRs for related changes) +- No period at the end +- Do NOT start with redundant verbs (section header already provides the action) + +### Semantic Versioning Rules + +Prowler follows [semver.org](https://semver.org/): + +| Change Type | Version Bump | Example | +|-------------|--------------|---------| +| Bug fixes, patches | PATCH (x.y.**Z**) | 1.16.1 β†’ 1.16.2 | +| New features (backwards compatible) | MINOR (x.**Y**.0) | 1.16.2 β†’ 1.17.0 | +| Breaking changes, removals | MAJOR (**X**.0.0) | 1.17.0 β†’ 2.0.0 | + +**CRITICAL:** `### ❌ Removed` entries MUST only appear in MAJOR version releases. Removing features is a breaking change. + +### Released Versions Are Immutable + +**NEVER modify already released versions.** Once a version is released (has a Prowler version tag like `v5.16.0`), its changelog section is frozen. + +**Common issue:** A PR is created during release cycle X, includes a changelog entry, but merges after release. The entry is now in the wrong section. + +```markdown +## [1.16.0] (Prowler v5.16.0) ← RELEASED, DO NOT MODIFY + +### Added +- Feature from merged PR [(#9999)] ← WRONG! PR merged after release + +## [1.17.0] (Prowler UNRELEASED) ← Move entry HERE +``` + +**Fix:** Move the entry from the released version to the UNRELEASED section. + +### Version Header Format + +```markdown +## [1.17.0] (Prowler UNRELEASED) # For unreleased changes +## [1.16.0] (Prowler v5.16.0) # For released versions + +--- # Horizontal rule between versions +``` + +## Adding a Changelog Entry + +### Step 1: Determine Affected Component(s) + +```bash +# Check which files changed +git diff main...HEAD --name-only +``` + +| Path Pattern | Component | +|--------------|-----------| +| `ui/**` | UI | +| `api/**` | API | +| `mcp_server/**` | MCP Server | +| `prowler/**` | SDK | +| Multiple | Update ALL affected changelogs | + +### Step 2: Determine Change Type + +| Change | Section | +|--------|---------| +| New feature, check, endpoint | πŸš€ Added | +| Behavior change, refactor | πŸ”„ Changed | +| Bug fix | 🐞 Fixed | +| CVE patch, security improvement | πŸ” Security | +| Feature removal | ❌ Removed | +| Deprecation notice | ⚠️ Deprecated | + +### Step 3: Add Entry at BOTTOM of Appropriate Section + +**CRITICAL:** Add new entries at the BOTTOM of each section, NOT at the top. + +```markdown +## [1.17.0] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Existing fix one [(#9997)](https://github.com/prowler-cloud/prowler/pull/9997) +- Existing fix two [(#9998)](https://github.com/prowler-cloud/prowler/pull/9998) +- Button alignment in dashboard header [(#9999)](https://github.com/prowler-cloud/prowler/pull/9999) ← NEW ENTRY AT BOTTOM + +### πŸ” Security +``` + +This maintains chronological order within each section (oldest at top, newest at bottom). + +## Examples + +### Good Entries + +```markdown +### πŸš€ Added +- Search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) + +### 🐞 Fixed +- OCI update credentials form failing silently due to missing provider UID [(#9746)](https://github.com/prowler-cloud/prowler/pull/9746) + +### πŸ” Security +- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) +``` + +### Bad Entries + +```markdown +- Fixed bug. # Too vague, has period +- Added new feature for users # Missing PR link, redundant verb +- Add search bar [(#123)] # Redundant verb (section already says "Added") +- This PR adds a cool new thing (#123) # Wrong link format, conversational +``` + +## PR Changelog Gate + +The `pr-check-changelog.yml` workflow enforces changelog entries: + +1. **REQUIRED**: PRs touching `ui/`, `api/`, `mcp_server/`, or `prowler/` MUST update the corresponding changelog +2. **SKIP**: Add `no-changelog` label to bypass (use sparingly for docs-only, CI-only changes) + +## Commands + +```bash +# Check which changelogs need updates based on changed files +git diff main...HEAD --name-only | grep -E '^(ui|api|mcp_server|prowler)/' | cut -d/ -f1 | sort -u + +# View current UNRELEASED section +head -50 ui/CHANGELOG.md +head -50 api/CHANGELOG.md +head -50 mcp_server/CHANGELOG.md +head -50 prowler/CHANGELOG.md +``` + +## Migration Note + +**API, MCP Server, and SDK changelogs currently lack emojis.** When editing these files, add emoji prefixes to section headers as you update them: + +```markdown +# Before (legacy) +### Added + +# After (standardized) +### πŸš€ Added +``` + +## Resources + +- **Templates**: See [assets/](assets/) for entry templates +- **keepachangelog.com**: https://keepachangelog.com/en/1.1.0/ diff --git a/skills/prowler-changelog/assets/entry-templates.md b/skills/prowler-changelog/assets/entry-templates.md new file mode 100644 index 0000000000..dbca5bf25f --- /dev/null +++ b/skills/prowler-changelog/assets/entry-templates.md @@ -0,0 +1,97 @@ +# Changelog Entry Templates + +## Entry Placement Rule + +**CRITICAL:** Always add new entries at the **BOTTOM** of each section (before the next section header or `---`). + +This maintains chronological order: oldest entries at top, newest at bottom. + +## Section Headers + +```markdown +### πŸš€ Added +### πŸ”„ Changed +### ⚠️ Deprecated +### ❌ Removed +### 🐞 Fixed +### πŸ” Security +``` + +## Entry Patterns + +> **Note:** Section headers already provide the verb. Entries describe WHAT, not the action. + +### Feature Addition (πŸš€ Added) +```markdown +- Search bar when adding a provider [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +- `{check_id}` check for {provider} provider [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +- `/api/v1/{endpoint}` endpoint to {description} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +``` + +### Behavior Change (πŸ”„ Changed) +```markdown +- Lighthouse AI MCP tool filtering from blacklist to whitelist approach [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +- {package} from {old} to {new} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +``` + +### Bug Fix (🐞 Fixed) +```markdown +- OCI update credentials form failing silently due to missing provider UID [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +- {What was broken} in {component} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +``` + +### Security Patch (πŸ” Security) +```markdown +- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +- {package} to version {version} (CVE-XXXX-XXXXX) [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +``` + +### Removal (❌ Removed) +```markdown +- Deprecated {feature} from {location} [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) +``` + +## Version Header Templates + +### Unreleased +```markdown +## [X.Y.Z] (Prowler UNRELEASED) +``` + +### Released +```markdown +## [X.Y.Z] (Prowler vA.B.C) + +--- +``` + +## Full Entry Example + +```markdown +## [1.17.0] (Prowler UNRELEASED) + +### πŸš€ Added + +- Search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) +- New findings table UI with new design system components [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699) +- YOUR NEW ENTRY GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) + +### πŸ”„ Changed + +- Lighthouse AI MCP tool filtering from blacklist to whitelist approach [(#9802)](https://github.com/prowler-cloud/prowler/pull/9802) +- YOUR NEW CHANGE GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) + +### 🐞 Fixed + +- OCI update credentials form failing silently due to missing provider UID [(#9746)](https://github.com/prowler-cloud/prowler/pull/9746) +- YOUR NEW FIX GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) + +### πŸ” Security + +- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) +- YOUR NEW SECURITY FIX GOES HERE AT BOTTOM [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX) + +--- +``` + +> **Remember:** Each new entry is added at the BOTTOM of its section to maintain chronological order. diff --git a/skills/prowler-ci/SKILL.md b/skills/prowler-ci/SKILL.md index 1bed87d74e..b673178c8c 100644 --- a/skills/prowler-ci/SKILL.md +++ b/skills/prowler-ci/SKILL.md @@ -44,7 +44,31 @@ Use this skill whenever you are: 3. If it's a title check: verify PR title matches Conventional Commits. 4. If it's changelog: verify the right `CHANGELOG.md` is updated OR apply `no-changelog` label. 5. If it's conflict checker: remove `<<<<<<<`, `=======`, `>>>>>>>` markers. -6. If it's secrets: remove credentials and rotate anything leaked. +6. If it's secrets (TruffleHog): see section below. + +## TruffleHog Secret Scanning + +TruffleHog scans for leaked secrets. Common false positives in test files: + +**Patterns that trigger TruffleHog:** +- `sk-*T3BlbkFJ*` - OpenAI API keys +- `AKIA[A-Z0-9]{16}` - AWS Access Keys +- `ghp_*` / `gho_*` - GitHub tokens +- Base64-encoded strings that look like credentials + +**Fix for test files:** +```python +# BAD - looks like real OpenAI key +api_key = "sk-test1234567890T3BlbkFJtest1234567890" + +# GOOD - obviously fake +api_key = "sk-fake-test-key-for-unit-testing-only" +``` + +**If TruffleHog flags a real secret:** +1. Remove the secret from the code immediately +2. Rotate the credential (it's now in git history) +3. Consider using `.trufflehog-ignore` for known false positives (rarely needed) ## Notes diff --git a/skills/prowler-commit/SKILL.md b/skills/prowler-commit/SKILL.md new file mode 100644 index 0000000000..0f67cbe882 --- /dev/null +++ b/skills/prowler-commit/SKILL.md @@ -0,0 +1,180 @@ +--- +name: prowler-commit +description: > + Creates professional git commits following conventional-commits format. + Trigger: When creating commits, after completing code changes, when user asks to commit. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.1.0" + scope: [root, api, ui, prowler, mcp_server] + auto_invoke: + - "Creating a git commit" + - "Committing changes" +--- + +## Critical Rules + +- ALWAYS use conventional-commits format: `type(scope): description` +- ALWAYS keep the first line under 72 characters +- ALWAYS ask for user confirmation before committing +- NEVER be overly specific (avoid counts like "6 subsections", "3 files") +- NEVER include implementation details in the title +- NEVER use `-n` flag unless user explicitly requests it +- NEVER use `git push --force` or `git push -f` (destructive, rewrites history) +- NEVER proactively offer to commit - wait for user to explicitly request it + +--- + +## Commit Format + +``` +type(scope): concise description + +- Key change 1 +- Key change 2 +- Key change 3 +``` + +### Types + +| Type | Use When | +|------|----------| +| `feat` | New feature or functionality | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `chore` | Maintenance, dependencies, configs | +| `refactor` | Code change without feature/fix | +| `test` | Adding or updating tests | +| `perf` | Performance improvement | +| `style` | Formatting, no code change | + +### Scopes + +| Scope | When | +|-------|------| +| `api` | Changes in `api/` | +| `ui` | Changes in `ui/` | +| `sdk` | Changes in `prowler/` | +| `mcp` | Changes in `mcp_server/` | +| `skills` | Changes in `skills/` | +| `ci` | Changes in `.github/` | +| `docs` | Changes in `docs/` | +| *omit* | Multiple scopes or root-level | + +--- + +## Good vs Bad Examples + +### Title Line + +``` +# GOOD - Concise and clear +feat(api): add provider connection retry logic +fix(ui): resolve dashboard loading state +chore(skills): add Celery documentation +docs: update installation guide + +# BAD - Too specific or verbose +feat(api): add provider connection retry logic with exponential backoff and jitter (3 retries max) +chore(skills): add comprehensive Celery documentation covering 8 topics +fix(ui): fix the bug in dashboard component on line 45 +``` + +### Body (Bullet Points) + +``` +# GOOD - High-level changes +- Add retry mechanism for failed connections +- Document task composition patterns +- Expand configuration reference + +# BAD - Too detailed +- Add retry with max_retries=3, backoff=True, jitter=True +- Add 6 subsections covering chain, group, chord +- Update lines 45-67 in dashboard.tsx +``` + +--- + +## Workflow + +1. **Analyze changes** + ```bash + git status + git diff --stat HEAD + git log -3 --oneline # Check recent commit style + ``` + +2. **Draft commit message** + - Choose appropriate type and scope + - Write concise title (< 72 chars) + - Add 2-5 bullet points for significant changes + +3. **Present to user for confirmation** + - Show files to be committed + - Show proposed message + - Wait for explicit confirmation + +4. **Execute commit** + ```bash + git add + git commit -m "$(cat <<'EOF' + type(scope): description + + - Change 1 + - Change 2 + EOF + )" + ``` + +--- + +## Decision Tree + +``` +Single file changed? +β”œβ”€ Yes β†’ May omit body, title only +└─ No β†’ Include body with key changes + +Multiple scopes affected? +β”œβ”€ Yes β†’ Omit scope: `feat: description` +└─ No β†’ Include scope: `feat(api): description` + +Fixing a bug? +β”œβ”€ User-facing β†’ fix(scope): description +└─ Internal/dev β†’ chore(scope): fix description + +Adding documentation? +β”œβ”€ Code docs (docstrings) β†’ Part of feat/fix +└─ Standalone docs β†’ docs: or docs(scope): +``` + +--- + +## Commands + +```bash +# Check current state +git status +git diff --stat HEAD + +# Standard commit +git add +git commit -m "type(scope): description" + +# Multi-line commit +git commit -m "$(cat <<'EOF' +type(scope): description + +- Change 1 +- Change 2 +EOF +)" + +# Amend last commit (same message) +git commit --amend --no-edit + +# Amend with new message +git commit --amend -m "new message" +``` diff --git a/skills/prowler-compliance/SKILL.md b/skills/prowler-compliance/SKILL.md index 0a1a14541d..1853d23d8b 100644 --- a/skills/prowler-compliance/SKILL.md +++ b/skills/prowler-compliance/SKILL.md @@ -34,6 +34,7 @@ Frameworks are JSON files located in: `prowler/compliance/{provider}/{framework_ - `github` - GitHub - `m365` - Microsoft 365 - `alibabacloud` - Alibaba Cloud +- `cloudflare` - Cloudflare - `oraclecloud` - Oracle Cloud - `oci` - Oracle Cloud Infrastructure - `nhn` - NHN Cloud diff --git a/skills/prowler-mcp/SKILL.md b/skills/prowler-mcp/SKILL.md index b56a692e04..af3c597771 100644 --- a/skills/prowler-mcp/SKILL.md +++ b/skills/prowler-mcp/SKILL.md @@ -8,7 +8,7 @@ license: Apache-2.0 metadata: author: prowler-cloud version: "1.0" - scope: [root] + scope: [root, mcp_server] auto_invoke: "Working on MCP server tools" allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task --- diff --git a/skills/prowler-provider/SKILL.md b/skills/prowler-provider/SKILL.md index 3c8a0f5433..994d134776 100644 --- a/skills/prowler-provider/SKILL.md +++ b/skills/prowler-provider/SKILL.md @@ -117,6 +117,7 @@ Current providers: - M365 (Microsoft 365) - OracleCloud (Oracle Cloud Infrastructure) - AlibabaCloud +- Cloudflare - MongoDB Atlas - NHN (NHN Cloud) - LLM (Language Model providers) diff --git a/skills/prowler-test-api/SKILL.md b/skills/prowler-test-api/SKILL.md index edda0b9c6d..fc00ed31cc 100644 --- a/skills/prowler-test-api/SKILL.md +++ b/skills/prowler-test-api/SKILL.md @@ -6,7 +6,7 @@ description: > license: Apache-2.0 metadata: author: prowler-cloud - version: "1.0" + version: "1.1.0" scope: [root, api] auto_invoke: - "Writing Prowler API tests" @@ -17,93 +17,136 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## Critical Rules - ALWAYS use `response.json()["data"]` not `response.data` -- ALWAYS use `content_type = "application/vnd.api+json"` in requests -- ALWAYS test cross-tenant isolation with `other_tenant_provider` fixture +- ALWAYS use `content_type = "application/vnd.api+json"` for PATCH/PUT requests +- ALWAYS use `format="vnd.api+json"` for POST requests +- ALWAYS test cross-tenant isolation - RLS returns 404, NOT 403 - NEVER skip RLS isolation tests when adding new endpoints +- NEVER use realistic-looking API keys in tests (TruffleHog will flag them) +- ALWAYS mock BOTH `.delay()` AND `Task.objects.get` for async task tests --- -## 1. JSON:API Format (Critical) +## 1. Fixture Dependency Chain -```python -content_type = "application/vnd.api+json" - -payload = { - "data": { - "type": "providers", # Plural, kebab-case - "id": str(resource.id), # Required for PATCH - "attributes": {"alias": "updated"}, - } -} - -response.json()["data"]["attributes"]["alias"] +``` +create_test_user (session) ─► tenants_fixture (function) ─► authenticated_client + β”‚ + └─► providers_fixture ─► scans_fixture ─► findings_fixture ``` ---- - -## 2. RLS Isolation Tests - -```python -def test_cross_tenant_access_denied(self, authenticated_client, other_tenant_provider): - """User cannot see resources from other tenants.""" - response = authenticated_client.get( - reverse("provider-detail", args=[other_tenant_provider.id]) - ) - assert response.status_code == status.HTTP_404_NOT_FOUND -``` - ---- - -## 3. RBAC Tests - -```python -def test_unlimited_visibility_sees_all(self, authenticated_client_admin, providers_fixture): - response = authenticated_client_admin.get(reverse("provider-list")) - assert len(response.json()["data"]) == len(providers_fixture) - -def test_limited_visibility_sees_only_assigned(self, authenticated_client_limited): - # User with unlimited_visibility=False sees only providers in their provider_groups - pass - -def test_permission_required(self, authenticated_client_readonly): - response = authenticated_client_readonly.post(reverse("provider-list"), ...) - assert response.status_code == status.HTTP_403_FORBIDDEN -``` - ---- - -## 4. Managers (objects vs all_objects) - -```python -def test_objects_excludes_deleted(self): - deleted_provider = Provider.objects.create(..., is_deleted=True) - assert deleted_provider not in Provider.objects.all() - assert deleted_provider in Provider.all_objects.all() -``` - ---- - -## 5. Celery Task Tests - -```python -@patch("tasks.tasks.perform_prowler_scan") -def test_task_success(self, mock_scan): - mock_scan.return_value = {"findings_count": 100} - result = perform_scan_task(tenant_id="...", scan_id="...", provider_id="...") - assert result["findings_count"] == 100 -``` - ---- - -## 6. Key Fixtures +### Key Fixtures | Fixture | Description | |---------|-------------| -| `create_test_user` | Session user (dev@prowler.com) | -| `tenants_fixture` | 3 tenants (2 with membership, 1 isolated) | -| `providers_fixture` | Providers in tenant 1 | -| `other_tenant_provider` | Provider in isolated tenant (RLS tests) | -| `authenticated_client` | Client with JWT for tenant 1 | +| `create_test_user` | Session user (`dev@prowler.com`) | +| `tenants_fixture` | 3 tenants: [0],[1] have membership, [2] isolated | +| `authenticated_client` | JWT client for tenant[0] | +| `providers_fixture` | 9 providers in tenant[0] | +| `tasks_fixture` | 2 Celery tasks with TaskResult | + +### RBAC Fixtures + +| Fixture | Permissions | +|---------|-------------| +| `authenticated_client_rbac` | All permissions (admin) | +| `authenticated_client_rbac_noroles` | Membership but NO roles | +| `authenticated_client_no_permissions_rbac` | All permissions = False | + +--- + +## 2. JSON:API Requests + +### POST (Create) +```python +response = client.post( + reverse("provider-list"), + data={"data": {"type": "providers", "attributes": {...}}}, + format="vnd.api+json", # NOT content_type! +) +``` + +### PATCH (Update) +```python +response = client.patch( + reverse("provider-detail", kwargs={"pk": provider.id}), + data={"data": {"type": "providers", "id": str(provider.id), "attributes": {...}}}, + content_type="application/vnd.api+json", # NOT format! +) +``` + +### Reading Responses +```python +data = response.json()["data"] +attrs = data["attributes"] +errors = response.json()["errors"] # For 400 responses +``` + +--- + +## 3. RLS Isolation (Cross-Tenant) + +**RLS returns 404, NOT 403** - the resource is invisible, not forbidden. + +```python +def test_cross_tenant_access_denied(self, authenticated_client, tenants_fixture): + other_tenant = tenants_fixture[2] # Isolated tenant + foreign_provider = Provider.objects.create(tenant_id=other_tenant.id, ...) + + response = authenticated_client.get(reverse("provider-detail", args=[foreign_provider.id])) + assert response.status_code == status.HTTP_404_NOT_FOUND # NOT 403! +``` + +--- + +## 4. Celery Task Testing + +### Testing Strategies + +| Strategy | Use For | +|----------|---------| +| Mock `.delay()` + `Task.objects.get` | Testing views that trigger tasks | +| `task.apply()` | Synchronous task logic testing | +| Mock `chain`/`group` | Testing Canvas orchestration | +| Mock `connection` | Testing `@set_tenant` decorator | +| Mock `apply_async` | Testing Beat scheduled tasks | + +### Why NOT `task_always_eager` + +| Problem | Impact | +|---------|--------| +| No task serialization | Misses argument type errors | +| No broker interaction | Hides connection issues | +| Different execution context | `self.request` behaves differently | + +**Instead, use:** `task.apply()` for sync execution, mocking for isolation. + +> **Full examples:** See [assets/api_test.py](assets/api_test.py) for `TestCeleryTaskLogic`, `TestCeleryCanvas`, `TestSetTenantDecorator`, `TestBeatScheduling`. + +--- + +## 5. Fake Secrets (TruffleHog) + +```python +# BAD - TruffleHog flags these: +api_key = "sk-test1234567890T3BlbkFJtest1234567890" + +# GOOD - obviously fake: +api_key = "sk-fake-test-key-for-unit-testing-only" +``` + +--- + +## 6. Response Status Codes + +| Scenario | Code | +|----------|------| +| Successful GET | 200 | +| Successful POST | 201 | +| Async operation (DELETE/scan trigger) | 202 | +| Sync DELETE | 204 | +| Validation error | 400 | +| Missing permission (RBAC) | 403 | +| RLS isolation / not found | 404 | --- @@ -112,11 +155,13 @@ def test_task_success(self, mock_scan): ```bash cd api && poetry run pytest -x --tb=short cd api && poetry run pytest -k "test_provider" -cd api && poetry run pytest -k "TestRBAC" +cd api && poetry run pytest api/src/backend/api/tests/test_rbac.py ``` --- ## Resources -- **Documentation**: See [references/test-api-docs.md](references/test-api-docs.md) for local file paths and documentation +- **Full Examples**: See [assets/api_test.py](assets/api_test.py) for complete test patterns +- **Fixture Reference**: See [references/test-api-docs.md](references/test-api-docs.md) +- **Fixture Source**: `api/src/backend/conftest.py` diff --git a/skills/prowler-test-api/assets/api_test.py b/skills/prowler-test-api/assets/api_test.py new file mode 100644 index 0000000000..0b70cd599d --- /dev/null +++ b/skills/prowler-test-api/assets/api_test.py @@ -0,0 +1,371 @@ +# Example: Prowler API Test Patterns +# Source: api/src/backend/api/tests/test_views.py + +from unittest.mock import Mock, patch + +import pytest +from conftest import ( + API_JSON_CONTENT_TYPE, + TEST_PASSWORD, + TEST_USER, + get_api_tokens, + get_authorization_header, +) +from django.urls import reverse +from rest_framework import status + +from api.models import Provider, Scan, StateChoices +from api.rls import Tenant + + +@pytest.mark.django_db +class TestProviderViewSet: + """Example API tests for Provider endpoints.""" + + def test_list_providers(self, authenticated_client, providers_fixture): + """GET list returns all providers for authenticated tenant.""" + response = authenticated_client.get(reverse("provider-list")) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == len(providers_fixture) + + def test_create_provider(self, authenticated_client): + """POST with JSON:API format creates provider.""" + response = authenticated_client.post( + reverse("provider-list"), + data={ + "data": { + "type": "providers", + "attributes": { + "provider": "aws", + "uid": "123456789012", + "alias": "my-aws-account", + }, + } + }, + format="vnd.api+json", # Use format= for POST + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["data"]["attributes"]["uid"] == "123456789012" + + def test_update_provider(self, authenticated_client, providers_fixture): + """PATCH with JSON:API format updates provider.""" + provider = providers_fixture[0] + + payload = { + "data": { + "type": "providers", + "id": str(provider.id), # ID required for PATCH + "attributes": {"alias": "updated-alias"}, + } + } + + response = authenticated_client.patch( + reverse("provider-detail", kwargs={"pk": provider.id}), + data=payload, + content_type="application/vnd.api+json", # Use content_type= for PATCH + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"]["attributes"]["alias"] == "updated-alias" + + +@pytest.mark.django_db +class TestRLSIsolation: + """Example RLS cross-tenant isolation tests.""" + + def test_cross_tenant_access_returns_404( + self, authenticated_client, tenants_fixture + ): + """User cannot see resources from other tenants - returns 404 NOT 403.""" + # Create resource in tenant user has NO access to (tenant[2] is isolated) + other_tenant = tenants_fixture[2] + foreign_provider = Provider.objects.create( + provider="aws", + uid="999888777666", + alias="foreign_provider", + tenant_id=other_tenant.id, + ) + + # Try to access - should get 404 (not 403!) + response = authenticated_client.get( + reverse("provider-detail", args=[foreign_provider.id]) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_list_excludes_other_tenants( + self, authenticated_client, providers_fixture, tenants_fixture + ): + """List endpoints only return resources from user's tenants.""" + # Create provider in isolated tenant + other_tenant = tenants_fixture[2] + Provider.objects.create( + provider="aws", + uid="foreign123", + tenant_id=other_tenant.id, + ) + + response = authenticated_client.get(reverse("provider-list")) + assert response.status_code == status.HTTP_200_OK + + # Should only see providers_fixture (9 providers in tenant[0]) + assert len(response.json()["data"]) == len(providers_fixture) + + +@pytest.mark.django_db +class TestRBACPermissions: + """Example RBAC permission tests.""" + + def test_requires_permission(self, authenticated_client_no_permissions_rbac): + """Users without manage_providers cannot create providers.""" + response = authenticated_client_no_permissions_rbac.post( + reverse("provider-list"), + data={ + "data": { + "type": "providers", + "attributes": {"provider": "aws", "uid": "123456789012"}, + } + }, + format="vnd.api+json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_user_with_no_roles_denied(self, authenticated_client_rbac_noroles): + """User with membership but no roles gets 403.""" + response = authenticated_client_rbac_noroles.get(reverse("user-list")) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_admin_sees_all(self, authenticated_client_rbac, providers_fixture): + """Admin with unlimited_visibility=True sees all providers.""" + response = authenticated_client_rbac.get(reverse("provider-list")) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +class TestAsyncOperations: + """Example async task tests - mock BOTH .delay() AND Task.objects.get.""" + + @patch("api.v1.views.Task.objects.get") + @patch("api.v1.views.delete_provider_task.delay") + def test_delete_provider_returns_202( + self, + mock_delete_task, + mock_task_get, + authenticated_client, + providers_fixture, + tasks_fixture, + ): + """DELETE returns 202 Accepted with Content-Location header.""" + provider = providers_fixture[0] + prowler_task = tasks_fixture[0] + + # Mock the Celery task + task_mock = Mock() + task_mock.id = prowler_task.id + mock_delete_task.return_value = task_mock + mock_task_get.return_value = prowler_task + + response = authenticated_client.delete( + reverse("provider-detail", kwargs={"pk": provider.id}) + ) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert "Content-Location" in response.headers + assert f"/api/v1/tasks/{prowler_task.id}" in response.headers["Content-Location"] + + # Verify task was called + mock_delete_task.assert_called_once() + + @patch("api.v1.views.Task.objects.get") + @patch("api.v1.views.perform_scan_task.delay") + def test_trigger_scan_returns_202( + self, + mock_scan_task, + mock_task_get, + authenticated_client, + providers_fixture, + tasks_fixture, + ): + """POST to scan trigger returns 202 with task location.""" + provider = providers_fixture[0] + prowler_task = tasks_fixture[0] + + task_mock = Mock() + task_mock.id = prowler_task.id + mock_scan_task.return_value = task_mock + mock_task_get.return_value = prowler_task + + response = authenticated_client.post( + reverse("provider-scan", kwargs={"pk": provider.id}), + format="vnd.api+json", + ) + + assert response.status_code == status.HTTP_202_ACCEPTED + + +@pytest.mark.django_db +class TestJSONAPIResponses: + """Example JSON:API response handling.""" + + def test_read_single_resource(self, authenticated_client, providers_fixture): + """Read data from single resource response.""" + provider = providers_fixture[0] + response = authenticated_client.get( + reverse("provider-detail", kwargs={"pk": provider.id}) + ) + + data = response.json()["data"] + attrs = data["attributes"] + resource_id = data["id"] + + assert resource_id == str(provider.id) + assert attrs["provider"] == provider.provider + + def test_read_list_response(self, authenticated_client, providers_fixture): + """Read data from list response.""" + response = authenticated_client.get(reverse("provider-list")) + + items = response.json()["data"] + assert len(items) == len(providers_fixture) + + def test_read_relationships(self, authenticated_client, scans_fixture): + """Read relationship data.""" + scan = scans_fixture[0] + response = authenticated_client.get( + reverse("scan-detail", kwargs={"pk": scan.id}) + ) + + data = response.json()["data"] + relationships = data["relationships"] + provider_rel = relationships["provider"]["data"] + + assert provider_rel["type"] == "providers" + assert provider_rel["id"] == str(scan.provider_id) + + def test_error_response(self, authenticated_client): + """Read error response structure.""" + response = authenticated_client.post( + reverse("user-list"), + data={"email": "invalid"}, # Missing required fields + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + errors = response.json()["errors"] + # Error has source.pointer and detail + assert "source" in errors[0] + assert "detail" in errors[0] + + +@pytest.mark.django_db +class TestSoftDelete: + """Example soft-delete manager tests.""" + + def test_objects_excludes_soft_deleted(self, providers_fixture): + """Default manager excludes soft-deleted records.""" + provider = providers_fixture[0] + provider.is_deleted = True + provider.save() + + # objects manager excludes deleted + assert provider not in Provider.objects.all() + + # all_objects includes deleted + assert provider in Provider.all_objects.all() + + +# ============================================================================= +# CELERY TASK TESTING +# ============================================================================= + + +@pytest.mark.django_db +class TestCeleryTaskLogic: + """Example: Testing Celery task logic directly with apply().""" + + def test_task_logic_directly(self, tenants_fixture, providers_fixture): + """Use apply() for synchronous execution without Celery worker.""" + from tasks.tasks import check_provider_connection_task + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + # Execute task synchronously (no broker needed) + result = check_provider_connection_task.apply( + kwargs={"tenant_id": str(tenant.id), "provider_id": str(provider.id)} + ) + + assert result.successful() + assert result.result["connected"] is True + + +@pytest.mark.django_db +class TestCeleryCanvas: + """Example: Testing Canvas (chain/group) task orchestration.""" + + @patch("tasks.tasks.chain") + @patch("tasks.tasks.group") + def test_post_scan_workflow(self, mock_group, mock_chain, tenants_fixture): + """Mock chain/group to verify task orchestration.""" + from tasks.tasks import _perform_scan_complete_tasks + + tenant = tenants_fixture[0] + + # Mock chain.apply_async + mock_chain_instance = Mock() + mock_chain.return_value = mock_chain_instance + + _perform_scan_complete_tasks(str(tenant.id), "scan-123", "provider-456") + + # Verify chain was called + assert mock_chain.called + mock_chain_instance.apply_async.assert_called() + + +@pytest.mark.django_db +class TestSetTenantDecorator: + """Example: Testing @set_tenant decorator behavior.""" + + @patch("api.decorators.connection") + def test_sets_rls_context(self, mock_conn, tenants_fixture, providers_fixture): + """Verify @set_tenant sets RLS context via SET_CONFIG_QUERY.""" + from tasks.tasks import check_provider_connection_task + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + # Call task with tenant_id - decorator sets RLS and pops it + check_provider_connection_task.apply( + kwargs={"tenant_id": str(tenant.id), "provider_id": str(provider.id)} + ) + + # Verify SET_CONFIG_QUERY was executed + mock_conn.cursor.return_value.__enter__.return_value.execute.assert_called() + + +@pytest.mark.django_db +class TestBeatScheduling: + """Example: Testing Beat scheduled task creation.""" + + @patch("tasks.beat.perform_scheduled_scan_task.apply_async") + def test_schedule_provider_scan(self, mock_apply, providers_fixture): + """Verify periodic task is created with correct settings.""" + from django_celery_beat.models import PeriodicTask + + from tasks.beat import schedule_provider_scan + + provider = providers_fixture[0] + mock_apply.return_value = Mock(id="task-123") + + schedule_provider_scan(provider) + + # Verify periodic task created + assert PeriodicTask.objects.filter( + name=f"scan-perform-scheduled-{provider.id}" + ).exists() + + # Verify immediate execution with countdown + mock_apply.assert_called_once() + call_kwargs = mock_apply.call_args + assert call_kwargs.kwargs.get("countdown") == 5 diff --git a/skills/prowler-test-api/references/test-api-docs.md b/skills/prowler-test-api/references/test-api-docs.md index 2aa3e0134f..0e7d02821a 100644 --- a/skills/prowler-test-api/references/test-api-docs.md +++ b/skills/prowler-test-api/references/test-api-docs.md @@ -1,18 +1,214 @@ -# API Test Documentation +# API Test Documentation Reference -## Local Documentation +## File Locations -For API testing patterns, see: +| Type | Path | +|------|------| +| Central fixtures | `api/src/backend/conftest.py` | +| API unit tests | `api/src/backend/api/tests/` | +| Integration tests | `api/src/backend/api/tests/integration/` | +| Task tests | `api/src/backend/tasks/tests/` | +| Dev fixtures (JSON) | `api/src/backend/api/fixtures/dev/` | -- `api/src/backend/conftest.py` - All fixtures -- `api/src/backend/api/tests/` - API tests -- `api/src/backend/tasks/tests/` - Task tests +--- -## Contents +## Fixture Dependency Graph -The documentation covers: -- JSON:API format for requests/responses -- RLS isolation test patterns -- RBAC permission tests -- Celery task mocking -- Test fixtures and their usage +``` +create_test_user (session) + β”‚ + └─► tenants_fixture (function) + β”‚ + β”œβ”€β–Ί set_user_admin_roles_fixture + β”‚ β”‚ + β”‚ └─► authenticated_client + β”‚ └─► (most API tests use this) + β”‚ + β”œβ”€β–Ί providers_fixture + β”‚ └─► scans_fixture + β”‚ └─► findings_fixture + β”‚ + └─► RBAC fixtures (create their own tenants/users): + β”œβ”€β–Ί create_test_user_rbac + β”‚ └─► authenticated_client_rbac + β”‚ + β”œβ”€β–Ί create_test_user_rbac_no_roles + β”‚ └─► authenticated_client_rbac_noroles + β”‚ + β”œβ”€β–Ί create_test_user_rbac_limited + β”‚ └─► authenticated_client_no_permissions_rbac + β”‚ + β”œβ”€β–Ί create_test_user_rbac_manage_account + β”‚ └─► authenticated_client_rbac_manage_account + β”‚ + └─► create_test_user_rbac_manage_users_only + └─► authenticated_client_rbac_manage_users_only +``` + +--- + +## Test File Contents + +### `api/src/backend/api/tests/test_views.py` + +Main ViewSet tests covering: +- `TestUserViewSet` - User CRUD, password validation, deletion cascades +- `TestTenantViewSet` - Tenant operations +- `TestProviderViewSet` - Provider CRUD, async deletion, connection testing +- `TestScanViewSet` - Scan trigger, list, filter +- `TestFindingViewSet` - Finding queries, filters +- `TestResourceViewSet` - Resource listing with tags +- `TestTaskViewSet` - Celery task status +- `TestIntegrationViewSet` - S3/Security Hub integrations +- `TestComplianceOverviewViewSet` - Compliance data +- And many more... + +### `api/src/backend/api/tests/test_rbac.py` + +RBAC permission tests covering: +- Permission checks for each ViewSet +- Role-based access patterns +- `unlimited_visibility` behavior +- Provider group visibility filtering +- Self-access patterns (`/me` endpoint) + +### `api/src/backend/api/tests/integration/test_rls_transaction.py` + +RLS enforcement tests: +- `rls_transaction` context manager +- Invalid UUID validation +- Custom parameter names + +### `api/src/backend/api/tests/integration/test_providers.py` + +Provider integration tests: +- Delete + recreate flow with async tasks +- End-to-end provider lifecycle + +### `api/src/backend/api/tests/integration/test_authentication.py` + +Authentication tests: +- JWT token flow +- API key authentication +- Social login (SAML, OAuth) +- Cross-tenant token isolation + +--- + +## Key Test Classes and Their Fixtures + +### Standard API Tests + +```python +@pytest.mark.django_db +class TestProviderViewSet: + def test_list(self, authenticated_client, providers_fixture): + # authenticated_client has JWT for tenant[0] + # providers_fixture has 9 providers in tenant[0] + ... +``` + +### RBAC Tests + +```python +@pytest.mark.django_db +class TestProviderRBAC: + def test_with_permission(self, authenticated_client_rbac, ...): + # Has all permissions + ... + + def test_without_permission(self, authenticated_client_no_permissions_rbac, ...): + # Has no permissions (all False) + ... +``` + +### Cross-Tenant Tests + +```python +@pytest.mark.django_db +class TestCrossTenantIsolation: + def test_cannot_access_other_tenant(self, authenticated_client, tenants_fixture): + other_tenant = tenants_fixture[2] # Isolated tenant + # Create resource in other_tenant + # Try to access with authenticated_client + # Expect 404 +``` + +### Async Task Tests + +```python +@pytest.mark.django_db +class TestAsyncOperations: + @patch("api.v1.views.Task.objects.get") + @patch("api.v1.views.some_task.delay") + def test_async_operation(self, mock_task, mock_task_get, tasks_fixture, ...): + prowler_task = tasks_fixture[0] + mock_task.return_value = Mock(id=prowler_task.id) + mock_task_get.return_value = prowler_task + # Execute and verify 202 response +``` + +--- + +## Constants Available from conftest + +```python +from conftest import ( + API_JSON_CONTENT_TYPE, # "application/vnd.api+json" + NO_TENANT_HTTP_STATUS, # status.HTTP_401_UNAUTHORIZED + TEST_USER, # "dev@prowler.com" + TEST_PASSWORD, # "testing_psswd" + TODAY, # str(datetime.today().date()) + today_after_n_days, # Function: (n: int) -> str + get_api_tokens, # Function: (client, email, password, tenant_id?) -> (access, refresh) + get_authorization_header, # Function: (token) -> {"Authorization": f"Bearer {token}"} +) +``` + +--- + +## Running Tests + +```bash +# Full test suite +cd api && poetry run pytest + +# Fast fail on first error +cd api && poetry run pytest -x + +# Short traceback +cd api && poetry run pytest --tb=short + +# Specific file +cd api && poetry run pytest api/src/backend/api/tests/test_views.py + +# Pattern match +cd api && poetry run pytest -k "Provider" + +# Verbose with print output +cd api && poetry run pytest -v -s + +# With coverage +cd api && poetry run pytest --cov=api --cov-report=html + +# Parallel execution +cd api && poetry run pytest -n auto +``` + +--- + +## pytest Configuration + +From `api/pyproject.toml`: + +```toml +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings" +python_files = "test_*.py" +addopts = "--reuse-db" +``` + +Key points: +- Uses `--reuse-db` for faster test runs +- Settings from `config.settings` +- Test files must match `test_*.py` diff --git a/skills/prowler-test-sdk/SKILL.md b/skills/prowler-test-sdk/SKILL.md index c729bb6896..b165381540 100644 --- a/skills/prowler-test-sdk/SKILL.md +++ b/skills/prowler-test-sdk/SKILL.md @@ -272,6 +272,8 @@ tests/providers/{provider}/services/{service}/ └── {check_name}_test.py # Check tests ``` +NOTE: Do not create a `__init__.py` file in the test folder. + --- ## Required Test Scenarios diff --git a/skills/prowler-test-ui/SKILL.md b/skills/prowler-test-ui/SKILL.md index 67dab1194f..558525932d 100644 --- a/skills/prowler-test-ui/SKILL.md +++ b/skills/prowler-test-ui/SKILL.md @@ -26,11 +26,43 @@ ui/tests/ └── {page-name}/ β”œβ”€β”€ {page-name}-page.ts # Page Object Model β”œβ”€β”€ {page-name}.spec.ts # ALL tests (single file per feature) - └── {page-name}.md # Test documentation + └── {page-name}.md # Test documentation (MANDATORY - sync with spec.ts) ``` --- +## MANDATORY Checklist (Create or Modify Tests) + +**⚠️ ALWAYS verify BEFORE completing any E2E task:** + +### When CREATING new tests: +- [ ] `{page-name}-page.ts` - Page Object created/updated +- [ ] `{page-name}.spec.ts` - Tests added with correct tags (@TEST-ID) +- [ ] `{page-name}.md` - Documentation created with ALL test cases +- [ ] Test IDs in `.md` match tags in `.spec.ts` + +### When MODIFYING existing tests: +- [ ] `{page-name}.md` MUST be updated if: + - Test cases were added/removed + - Test flow changed (steps) + - Preconditions or expected results changed + - Tags or priorities changed +- [ ] Test IDs synchronized between `.md` and `.spec.ts` + +### Quick validation: +```bash +# Verify .md exists for each test folder +ls ui/tests/{feature}/{feature}.md + +# Verify test IDs match +grep -o "@[A-Z]*-E2E-[0-9]*" ui/tests/{feature}/{feature}.spec.ts | sort -u +grep -o "\`[A-Z]*-E2E-[0-9]*\`" ui/tests/{feature}/{feature}.md | sort -u +``` + +**❌ An E2E change is NOT considered complete without updating the corresponding .md file** + +--- + ## MCP Workflow - CRITICAL **⚠️ MANDATORY: If Playwright MCP tools are available, ALWAYS use them BEFORE creating tests.** @@ -45,6 +77,33 @@ ui/tests/ --- +## Wait Strategies (CRITICAL) + +**⚠️ NEVER use `networkidle` - it causes flaky tests!** + +| Strategy | Use Case | +|----------|----------| +| ❌ `networkidle` | NEVER - flaky with polling/WebSockets | +| ⚠️ `load` | Only when absolutely necessary | +| βœ… `expect(element).toBeVisible()` | PREFERRED - wait for specific UI state | +| βœ… `page.waitForURL()` | Wait for navigation | +| βœ… `pageObject.verifyPageLoaded()` | BEST - encapsulated verification | + +**GOOD:** +```typescript +await homePage.verifyPageLoaded(); +await expect(page).toHaveURL("/dashboard"); +await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible(); +``` + +**BAD:** +```typescript +await page.waitForLoadState("networkidle"); // ❌ FLAKY +await page.waitForTimeout(2000); // ❌ ARBITRARY WAIT +``` + +--- + ## Prowler Base Page ```typescript @@ -55,11 +114,12 @@ export class BasePage { async goto(path: string): Promise { await this.page.goto(path); - await this.page.waitForLoadState("networkidle"); + // Child classes should override verifyPageLoaded() to wait for specific elements } - async waitForPageLoad(): Promise { - await this.page.waitForLoadState("networkidle"); + // Override in child classes to wait for page-specific elements + async verifyPageLoaded(): Promise { + await expect(this.page.locator("main")).toBeVisible(); } // Prowler-specific: notification handling @@ -78,6 +138,33 @@ export class BasePage { --- +## Page Navigation Verification Pattern + +**⚠️ URL assertions belong in Page Objects, NOT in tests!** + +When verifying redirects or page navigation, create dedicated methods in the target Page Object: + +```typescript +// βœ… GOOD - In SignInPage +async verifyOnSignInPage(): Promise { + await expect(this.page).toHaveURL(/\/sign-in/); + await expect(this.pageTitle).toBeVisible(); +} + +// βœ… GOOD - In test +await homePage.goto(); // Try to access protected route +await signInPage.verifyOnSignInPage(); // Verify redirect + +// ❌ BAD - Direct assertions in test +await homePage.goto(); +await expect(page).toHaveURL(/\/sign-in/); // Should be in Page Object +await expect(page.getByText("Sign in")).toBeVisible(); +``` + +**Naming convention:** `verifyOn{PageName}Page()` for redirect verification methods. + +--- + ## Prowler-Specific Pages ### Providers Page diff --git a/skills/prowler/SKILL.md b/skills/prowler/SKILL.md index a9294708fb..2ef6f2fb4d 100644 --- a/skills/prowler/SKILL.md +++ b/skills/prowler/SKILL.md @@ -46,7 +46,7 @@ docker-compose up -d ## Providers -AWS, Azure, GCP, Kubernetes, GitHub, M365, OCI, AlibabaCloud, MongoDB Atlas, IaC +AWS, Azure, GCP, Kubernetes, GitHub, M365, OCI, AlibabaCloud, Cloudflare, MongoDB Atlas, NHN, LLM, IaC ## Commit Style diff --git a/skills/skill-sync/SKILL.md b/skills/skill-sync/SKILL.md index 4b136dd5b2..9da79ccba6 100644 --- a/skills/skill-sync/SKILL.md +++ b/skills/skill-sync/SKILL.md @@ -48,6 +48,7 @@ metadata: | `ui` | `ui/AGENTS.md` | | `api` | `api/AGENTS.md` | | `sdk` | `prowler/AGENTS.md` | +| `mcp_server` | `mcp_server/AGENTS.md` | Skills can have multiple scopes: `scope: [ui, api]` diff --git a/skills/skill-sync/assets/sync.sh b/skills/skill-sync/assets/sync.sh index 99997d52d6..941f7bae56 100755 --- a/skills/skill-sync/assets/sync.sh +++ b/skills/skill-sync/assets/sync.sh @@ -35,7 +35,7 @@ while [[ $# -gt 0 ]]; do echo "" echo "Options:" echo " --dry-run Show what would change without modifying files" - echo " --scope Only sync specific scope (root, ui, api, sdk)" + echo " --scope Only sync specific scope (root, ui, api, sdk, mcp_server)" exit 0 ;; *) @@ -49,11 +49,12 @@ done get_agents_path() { local scope="$1" case "$scope" in - root) echo "$REPO_ROOT/AGENTS.md" ;; - ui) echo "$REPO_ROOT/ui/AGENTS.md" ;; - api) echo "$REPO_ROOT/api/AGENTS.md" ;; - sdk) echo "$REPO_ROOT/prowler/AGENTS.md" ;; - *) echo "" ;; + root) echo "$REPO_ROOT/AGENTS.md" ;; + ui) echo "$REPO_ROOT/ui/AGENTS.md" ;; + api) echo "$REPO_ROOT/api/AGENTS.md" ;; + sdk) echo "$REPO_ROOT/prowler/AGENTS.md" ;; + mcp_server) echo "$REPO_ROOT/mcp_server/AGENTS.md" ;; + *) echo "" ;; esac } @@ -136,6 +137,8 @@ extract_metadata() { # On multi-line list, only accept "- item" lines. Anything else ends the list. line = $0 + # Stop at frontmatter delimiter (getline bypasses pattern matching) + if (line ~ /^---$/) break if (line ~ /^[[:space:]]*-[[:space:]]*/) { sub(/^[[:space:]]*-[[:space:]]*/, "", line) line = trim(line) diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index b7c5278147..44e826d382 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -63,7 +63,10 @@ aws: fargate_windows_latest_version: "1.0.0" # AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries) - # AWS SSM Configuration (aws.ssm_documents_set_as_public) + # AWS SSM Configuration (ssm_documents_set_as_public) + # AWS S3 Configuration (s3_bucket_cross_account_access) + # AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access) + # AWS DynamoDB Configuration (dynamodb_table_cross_account_access) # Single account environment: No action required. The AWS account number will be automatically added by the checks. # Multi account environment: Any additional trusted account number should be added as a space separated list, e.g. # trusted_account_ids : ["123456789012", "098765432109", "678901234567"] diff --git a/tests/lib/timeline/__init__.py b/tests/lib/timeline/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/timeline/models_test.py b/tests/lib/timeline/models_test.py new file mode 100644 index 0000000000..829c81a1a7 --- /dev/null +++ b/tests/lib/timeline/models_test.py @@ -0,0 +1,163 @@ +from datetime import datetime, timezone + +import pytest + +from prowler.lib.timeline.models import TimelineEvent + + +class TestTimelineEvent: + """Tests for TimelineEvent model.""" + + def test_minimal_event(self): + """Test creating an event with only required fields.""" + event = TimelineEvent( + event_id="test-event-id-123", + event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + event_name="CreateResource", + event_source="service.example.com", + actor="user@example.com", + actor_type="User", + ) + + assert event.event_id == "test-event-id-123" + assert event.event_time == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + assert event.event_name == "CreateResource" + assert event.event_source == "service.example.com" + assert event.actor == "user@example.com" + assert event.actor_type == "User" + # Optional fields should be None + assert event.actor_uid is None + assert event.source_ip_address is None + assert event.user_agent is None + assert event.request_data is None + assert event.response_data is None + assert event.error_code is None + assert event.error_message is None + + def test_full_event(self): + """Test creating an event with all fields populated.""" + event = TimelineEvent( + event_id="full-event-id-456", + event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + event_name="ModifyResource", + event_source="storage.example.com", + actor="admin-role", + actor_uid="arn:aws:sts::123456789012:assumed-role/admin-role/session", + actor_type="AssumedRole", + source_ip_address="192.168.1.100", + user_agent="aws-cli/2.0.0", + request_data={"bucket": "my-bucket", "acl": "private"}, + response_data={"status": "success"}, + error_code=None, + error_message=None, + ) + + assert event.event_id == "full-event-id-456" + assert ( + event.actor_uid + == "arn:aws:sts::123456789012:assumed-role/admin-role/session" + ) + assert event.source_ip_address == "192.168.1.100" + assert event.user_agent == "aws-cli/2.0.0" + assert event.request_data == {"bucket": "my-bucket", "acl": "private"} + assert event.response_data == {"status": "success"} + + def test_error_event(self): + """Test creating an event that represents a failed operation.""" + event = TimelineEvent( + event_id="error-event-id-789", + event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + event_name="DeleteResource", + event_source="storage.example.com", + actor="unauthorized-user", + actor_type="User", + error_code="AccessDenied", + error_message="User does not have permission to delete this resource", + ) + + assert event.error_code == "AccessDenied" + assert ( + event.error_message + == "User does not have permission to delete this resource" + ) + + def test_event_to_dict(self): + """Test that event can be serialized to dictionary.""" + event = TimelineEvent( + event_id="dict-test-id", + event_time=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + event_name="CreateResource", + event_source="service.example.com", + actor="user@example.com", + actor_type="User", + ) + + event_dict = event.dict() + + assert event_dict["event_id"] == "dict-test-id" + assert event_dict["event_name"] == "CreateResource" + assert event_dict["actor"] == "user@example.com" + assert event_dict["actor_type"] == "User" + + def test_event_from_dict(self): + """Test creating an event from a dictionary.""" + data = { + "event_id": "from-dict-id", + "event_time": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "event_name": "UpdateResource", + "event_source": "compute.example.com", + "actor": "service-account", + "actor_type": "ServiceAccount", + } + + event = TimelineEvent(**data) + + assert event.event_id == "from-dict-id" + assert event.event_name == "UpdateResource" + assert event.actor == "service-account" + assert event.actor_type == "ServiceAccount" + + def test_required_fields_validation(self): + """Test that missing required fields raise validation error.""" + with pytest.raises(Exception): # Pydantic validation error + TimelineEvent( + event_id="validation-test", + event_time=datetime.now(timezone.utc), + event_name="CreateResource", + # Missing: event_source, actor, actor_type + ) + + def test_actor_types_are_flexible(self): + """Test that actor_type accepts any string value (provider-agnostic).""" + # AWS-style + aws_event = TimelineEvent( + event_id="aws-event-id", + event_time=datetime.now(timezone.utc), + event_name="CreateBucket", + event_source="s3.amazonaws.com", + actor="arn:aws:iam::123456789012:user/admin", + actor_type="IAMUser", + ) + assert aws_event.actor_type == "IAMUser" + + # Azure-style + azure_event = TimelineEvent( + event_id="azure-event-id", + event_time=datetime.now(timezone.utc), + event_name="CreateStorageAccount", + event_source="Microsoft.Storage", + actor="user@contoso.com", + actor_type="User", + ) + assert azure_event.actor_type == "User" + + # GCP-style + gcp_event = TimelineEvent( + event_id="gcp-event-id", + event_time=datetime.now(timezone.utc), + event_name="storage.buckets.create", + event_source="storage.googleapis.com", + actor="service-account@project.iam.gserviceaccount.com", + actor_type="serviceAccount", + ) + assert gcp_event.actor_type == "serviceAccount" diff --git a/tests/lib/timeline/timeline_test.py b/tests/lib/timeline/timeline_test.py new file mode 100644 index 0000000000..9523f77c9f --- /dev/null +++ b/tests/lib/timeline/timeline_test.py @@ -0,0 +1,144 @@ +"""Tests for prowler.lib.timeline.timeline module.""" + +from typing import Any, Dict, List, Optional + +import pytest + +from prowler.lib.timeline.timeline import TimelineService + + +class ConcreteTimelineService(TimelineService): + """Concrete implementation for testing the abstract base class.""" + + def __init__(self, mock_events: Optional[List[Dict[str, Any]]] = None): + self.mock_events = mock_events or [] + self.last_call_args = None + + def get_resource_timeline( + self, + region: Optional[str] = None, + resource_id: Optional[str] = None, + resource_uid: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Return mock events for testing.""" + if not resource_id and not resource_uid: + raise ValueError("Either resource_id or resource_uid must be provided") + + self.last_call_args = { + "region": region, + "resource_id": resource_id, + "resource_uid": resource_uid, + } + return self.mock_events + + +class TestTimelineServiceAbstract: + """Tests for TimelineService abstract base class.""" + + def test_cannot_instantiate_abstract_class(self): + """Test that TimelineService cannot be instantiated directly.""" + with pytest.raises(TypeError) as exc_info: + TimelineService() + + assert "abstract" in str(exc_info.value).lower() + + def test_concrete_implementation_can_be_instantiated(self): + """Test that a concrete implementation can be instantiated.""" + service = ConcreteTimelineService() + assert service is not None + + def test_get_resource_timeline_with_resource_id(self): + """Test calling get_resource_timeline with resource_id.""" + service = ConcreteTimelineService(mock_events=[{"event": "test"}]) + + result = service.get_resource_timeline( + region="us-east-1", resource_id="res-123" + ) + + assert result == [{"event": "test"}] + assert service.last_call_args["region"] == "us-east-1" + assert service.last_call_args["resource_id"] == "res-123" + assert service.last_call_args["resource_uid"] is None + + def test_get_resource_timeline_with_resource_uid(self): + """Test calling get_resource_timeline with resource_uid.""" + service = ConcreteTimelineService(mock_events=[{"event": "test"}]) + + result = service.get_resource_timeline( + region="eu-west-1", + resource_uid="arn:aws:s3:::my-bucket", + ) + + assert result == [{"event": "test"}] + assert service.last_call_args["region"] == "eu-west-1" + assert service.last_call_args["resource_id"] is None + assert service.last_call_args["resource_uid"] == "arn:aws:s3:::my-bucket" + + def test_get_resource_timeline_with_both_identifiers(self): + """Test calling get_resource_timeline with both resource_id and resource_uid.""" + service = ConcreteTimelineService(mock_events=[]) + + service.get_resource_timeline( + region="ap-south-1", + resource_id="res-123", + resource_uid="arn:aws:ec2:ap-south-1:123456789012:instance/i-12345", + ) + + assert service.last_call_args["resource_id"] == "res-123" + assert ( + service.last_call_args["resource_uid"] + == "arn:aws:ec2:ap-south-1:123456789012:instance/i-12345" + ) + + def test_get_resource_timeline_missing_identifiers_raises(self): + """Test that missing both identifiers raises ValueError.""" + service = ConcreteTimelineService() + + with pytest.raises(ValueError) as exc_info: + service.get_resource_timeline(region="us-west-2") + + assert "resource_id" in str(exc_info.value) or "resource_uid" in str( + exc_info.value + ) + + def test_return_type_is_list_of_dicts(self): + """Test that get_resource_timeline returns list of dicts (not TimelineEvent).""" + # The abstract interface returns list[dict] to allow flexibility + # Concrete implementations convert to TimelineEvent as needed + mock_events = [ + {"event_name": "CreateBucket", "actor": "user1"}, + {"event_name": "PutBucketPolicy", "actor": "user2"}, + ] + service = ConcreteTimelineService(mock_events=mock_events) + + result = service.get_resource_timeline( + region="us-east-1", resource_id="my-bucket" + ) + + assert isinstance(result, list) + assert all(isinstance(event, dict) for event in result) + + +class TestTimelineServiceInheritance: + """Tests for proper inheritance of TimelineService.""" + + def test_is_abstract_base_class(self): + """Test that TimelineService is an ABC.""" + from abc import ABC + + assert issubclass(TimelineService, ABC) + + def test_get_resource_timeline_is_abstract(self): + """Test that get_resource_timeline is an abstract method.""" + + method = getattr(TimelineService, "get_resource_timeline") + assert getattr(method, "__isabstractmethod__", False) + + def test_subclass_must_implement_abstract_method(self): + """Test that subclass without implementation cannot be instantiated.""" + + class IncompleteService(TimelineService): + """Subclass that doesn't implement abstract methods.""" + + with pytest.raises(TypeError): + IncompleteService() diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index 6d04022abf..5162a080f2 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -844,7 +844,13 @@ aws: aws_provider = AwsProvider() response = aws_provider.generate_regional_clients("ec2") - assert len(response.keys()) == 33 + # Only commercial regions (not GovCloud/China) should have regional clients + commercial_regions = { + r + for r in aws_provider._enabled_regions + if not r.startswith("cn-") and not r.startswith("us-gov-") + } + assert set(response.keys()) == commercial_regions @mock_aws def test_generate_regional_clients_with_enabled_regions(self): diff --git a/tests/providers/aws/lib/cloudtrail_timeline/__init__.py b/tests/providers/aws/lib/cloudtrail_timeline/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py b/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py new file mode 100644 index 0000000000..1c1c10bfd3 --- /dev/null +++ b/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py @@ -0,0 +1,608 @@ +import json +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import ( + CloudTrailTimeline, +) + + +class TestCloudTrailTimeline: + @pytest.fixture + def mock_session(self): + return MagicMock() + + @pytest.fixture + def sample_cloudtrail_event(self): + return { + "EventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "RunInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/admin", + "userName": "admin", + }, + "sourceIPAddress": "203.0.113.1", + "userAgent": "aws-cli/2.0.0", + "requestParameters": {"instanceType": "t3.micro"}, + "responseElements": { + "instancesSet": {"items": [{"instanceId": "i-1234"}]} + }, + } + ), + } + + def test_init_default_lookback(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session) + assert timeline._lookback_days == 90 + + def test_init_custom_lookback(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session, lookback_days=30) + assert timeline._lookback_days == 30 + + def test_init_lookback_capped_at_max(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session, lookback_days=365) + assert timeline._lookback_days == 90 + + def test_init_default_max_results(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session) + assert timeline._max_results == 50 + + def test_init_custom_max_results(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session, max_results=10) + assert timeline._max_results == 10 + + def test_init_default_write_events_only(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session) + assert timeline._write_events_only is True + + def test_init_write_events_only_disabled(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session, write_events_only=False) + assert timeline._write_events_only is False + + def test_get_resource_timeline_defaults_to_us_east_1(self, mock_session): + """When no region is provided, should default to us-east-1 for global resources.""" + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": []} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + timeline.get_resource_timeline( + resource_uid="arn:aws:iam::123456789012:user/admin" + ) + + # Verify us-east-1 was used as the default region + mock_session.client.assert_called_with("cloudtrail", region_name="us-east-1") + + def test_get_resource_timeline_missing_identifier_raises(self, mock_session): + timeline = CloudTrailTimeline(session=mock_session) + with pytest.raises(ValueError, match="Either resource_id or resource_uid"): + timeline.get_resource_timeline(region="us-east-1") + + def test_get_resource_timeline_with_resource_id( + self, mock_session, sample_cloudtrail_event + ): + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", resource_id="i-1234567890abcdef0" + ) + + assert len(result) == 1 + assert result[0]["event_name"] == "RunInstances" + assert result[0]["actor"] == "admin" + assert result[0]["source_ip_address"] == "203.0.113.1" + + def test_get_resource_timeline_with_resource_uid( + self, mock_session, sample_cloudtrail_event + ): + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", + resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0", + ) + + assert len(result) == 1 + assert result[0]["event_name"] == "RunInstances" + + def test_get_resource_timeline_prefers_uid_over_id(self, mock_session): + """When both resource_id and resource_uid are provided, UID should be used.""" + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": []} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + timeline.get_resource_timeline( + region="us-east-1", + resource_id="i-1234", + resource_uid="arn:aws:ec2:us-east-1:123:instance/i-1234", + ) + + # Verify UID was used in the lookup + call_args = mock_client.lookup_events.call_args + lookup_attrs = call_args.kwargs["LookupAttributes"] + assert ( + lookup_attrs[0]["AttributeValue"] + == "arn:aws:ec2:us-east-1:123:instance/i-1234" + ) + + def test_get_resource_timeline_client_error(self, mock_session): + mock_client = MagicMock() + mock_client.lookup_events.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, + "LookupEvents", + ) + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + with pytest.raises(ClientError): + timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234") + + def test_get_resource_timeline_multiple_events(self, mock_session): + events = [ + { + "EventId": "event-1-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "RunInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/admin", + } + } + ), + }, + { + "EventId": "event-2-id", + "EventTime": datetime(2024, 1, 15, 11, 30, 0, tzinfo=timezone.utc), + "EventName": "StopInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/ops", + } + } + ), + }, + ] + + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": events} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", resource_id="i-1234" + ) + + assert len(result) == 2 + assert result[0]["event_name"] == "RunInstances" + assert result[1]["event_name"] == "StopInstances" + + def test_get_resource_timeline_uses_max_results(self, mock_session): + """Verify MaxResults is passed to lookup_events.""" + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": []} + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session, max_results=25) + timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234") + + # Verify MaxResults was passed to lookup_events + call_args = mock_client.lookup_events.call_args + assert call_args.kwargs["MaxResults"] == 25 + + def test_get_resource_timeline_filters_read_only_events(self, mock_session): + """Verify read-only events are filtered when write_events_only=True.""" + events = [ + { + "EventId": "write-event-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "CreateSecurityGroup", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + {"userIdentity": {"type": "IAMUser", "userName": "admin"}} + ), + }, + { + "EventId": "read-event-id", + "EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc), + "EventName": "DescribeSecurityGroups", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + {"userIdentity": {"type": "IAMUser", "userName": "admin"}} + ), + }, + { + "EventId": "another-read-id", + "EventTime": datetime(2024, 1, 15, 10, 32, 0, tzinfo=timezone.utc), + "EventName": "GetSecurityGroupRules", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + {"userIdentity": {"type": "IAMUser", "userName": "admin"}} + ), + }, + ] + + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": events} + mock_session.client.return_value = mock_client + + # Default: write_events_only=True + timeline = CloudTrailTimeline(session=mock_session) + result = timeline.get_resource_timeline( + region="us-east-1", resource_id="sg-123" + ) + + # Only the write event should be returned + assert len(result) == 1 + assert result[0]["event_name"] == "CreateSecurityGroup" + + def test_get_resource_timeline_includes_read_events_when_disabled( + self, mock_session + ): + """Verify all events returned when write_events_only=False.""" + events = [ + { + "EventId": "write-event-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "CreateSecurityGroup", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + {"userIdentity": {"type": "IAMUser", "userName": "admin"}} + ), + }, + { + "EventId": "read-event-id", + "EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc), + "EventName": "DescribeSecurityGroups", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + {"userIdentity": {"type": "IAMUser", "userName": "admin"}} + ), + }, + ] + + mock_client = MagicMock() + mock_client.lookup_events.return_value = {"Events": events} + mock_session.client.return_value = mock_client + + # Disable filtering + timeline = CloudTrailTimeline(session=mock_session, write_events_only=False) + result = timeline.get_resource_timeline( + region="us-east-1", resource_id="sg-123" + ) + + # All events should be returned + assert len(result) == 2 + assert result[0]["event_name"] == "CreateSecurityGroup" + assert result[1]["event_name"] == "DescribeSecurityGroups" + + +class TestExtractActor: + def test_extract_actor_iam_user(self): + user_identity = { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/alice", + "userName": "alice", + } + assert CloudTrailTimeline._extract_actor(user_identity) == "alice" + + def test_extract_actor_assumed_role(self): + user_identity = { + "type": "AssumedRole", + "arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-name", + } + assert CloudTrailTimeline._extract_actor(user_identity) == "MyRole" + + def test_extract_actor_root(self): + user_identity = {"type": "Root", "arn": "arn:aws:iam::123456789012:root"} + assert CloudTrailTimeline._extract_actor(user_identity) == "root" + + def test_extract_actor_service(self): + user_identity = { + "type": "AWSService", + "invokedBy": "elasticloadbalancing.amazonaws.com", + } + assert ( + CloudTrailTimeline._extract_actor(user_identity) + == "elasticloadbalancing.amazonaws.com" + ) + + def test_extract_actor_fallback_to_principal_id(self): + user_identity = {"type": "Unknown", "principalId": "AROAEXAMPLEID:session"} + assert ( + CloudTrailTimeline._extract_actor(user_identity) == "AROAEXAMPLEID:session" + ) + + def test_extract_actor_unknown(self): + assert CloudTrailTimeline._extract_actor({}) == "Unknown" + + def test_extract_actor_federated_user(self): + user_identity = { + "type": "FederatedUser", + "arn": "arn:aws:sts::123456789012:federated-user/developer", + } + assert CloudTrailTimeline._extract_actor(user_identity) == "developer" + + +class TestParseEvent: + @pytest.fixture + def mock_session(self): + return MagicMock() + + @pytest.fixture + def sample_cloudtrail_event(self): + return { + "EventId": "b2c3d4e5-f6a7-8901-bcde-f23456789012", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "RunInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/admin", + "userName": "admin", + }, + "sourceIPAddress": "203.0.113.1", + "userAgent": "aws-cli/2.0.0", + "requestParameters": {"instanceType": "t3.micro"}, + "responseElements": { + "instancesSet": {"items": [{"instanceId": "i-1234"}]} + }, + } + ), + } + + def test_parse_event_success(self, mock_session, sample_cloudtrail_event): + timeline = CloudTrailTimeline(session=mock_session) + result = timeline._parse_event(sample_cloudtrail_event) + + assert result is not None + assert result["event_name"] == "RunInstances" + assert result["event_source"] == "ec2.amazonaws.com" + assert result["actor"] == "admin" + assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin" + assert result["actor_type"] == "IAMUser" + + def test_parse_event_malformed_json(self, mock_session): + event = { + "EventId": "malformed-event-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "RunInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": "not valid json", + } + timeline = CloudTrailTimeline(session=mock_session) + assert timeline._parse_event(event) is None + + def test_parse_event_with_error_fields(self, mock_session): + event = { + "EventId": "error-event-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "CreateBucket", + "EventSource": "s3.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": {"type": "IAMUser", "userName": "developer"}, + "errorCode": "AccessDenied", + "errorMessage": "Access Denied", + } + ), + } + timeline = CloudTrailTimeline(session=mock_session) + result = timeline._parse_event(event) + + assert result is not None + assert result["error_code"] == "AccessDenied" + assert result["error_message"] == "Access Denied" + + def test_parse_event_dict_cloud_trail_event(self, mock_session): + """Test parsing when CloudTrailEvent is already a dict (not JSON string).""" + event = { + "EventId": "dict-event-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "RunInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": { + "userIdentity": {"type": "IAMUser", "userName": "admin"}, + }, + } + timeline = CloudTrailTimeline(session=mock_session) + result = timeline._parse_event(event) + + assert result is not None + assert result["event_name"] == "RunInstances" + assert result["actor"] == "admin" + + def test_parse_event_missing_event_id(self, mock_session): + """Test parsing event without EventId returns None (event_id is required).""" + event = { + # Missing EventId + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "RunInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + {"userIdentity": {"type": "IAMUser", "userName": "admin"}} + ), + } + timeline = CloudTrailTimeline(session=mock_session) + result = timeline._parse_event(event) + + # Should return None because event_id is required by TimelineEvent model + assert result is None + + def test_parse_event_uses_request_data_and_response_data_fields(self, mock_session): + """Test that parsed event uses request_data and response_data field names.""" + event = { + "EventId": "field-names-test-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "CreateBucket", + "EventSource": "s3.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": {"type": "IAMUser", "userName": "admin"}, + "requestParameters": {"bucketName": "my-bucket", "acl": "private"}, + "responseElements": { + "location": "http://my-bucket.s3.amazonaws.com" + }, + } + ), + } + timeline = CloudTrailTimeline(session=mock_session) + result = timeline._parse_event(event) + + assert result is not None + # Verify field names are request_data/response_data (not request_parameters/response_elements) + assert "request_data" in result + assert "response_data" in result + assert "request_parameters" not in result + assert "response_elements" not in result + # Verify the data is correctly mapped + assert result["request_data"] == {"bucketName": "my-bucket", "acl": "private"} + assert result["response_data"] == { + "location": "http://my-bucket.s3.amazonaws.com" + } + + def test_parse_event_missing_actor_type(self, mock_session): + """Test parsing event where userIdentity has no type field.""" + event = { + "EventId": "no-actor-type-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "RunInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": { + # No "type" field + "arn": "arn:aws:iam::123456789012:user/admin", + "userName": "admin", + }, + "sourceIPAddress": "203.0.113.1", + } + ), + } + timeline = CloudTrailTimeline(session=mock_session) + result = timeline._parse_event(event) + + assert result is not None + assert result["event_name"] == "RunInstances" + assert result["actor"] == "admin" + # actor_type should be None when not present in userIdentity + assert result["actor_type"] is None + + def test_parse_event_empty_request_response(self, mock_session): + """Test parsing event with no requestParameters or responseElements.""" + event = { + "EventId": "no-params-id", + "EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + "EventName": "DescribeInstances", + "EventSource": "ec2.amazonaws.com", + "CloudTrailEvent": json.dumps( + { + "userIdentity": {"type": "IAMUser", "userName": "reader"}, + # No requestParameters or responseElements + } + ), + } + timeline = CloudTrailTimeline(session=mock_session) + result = timeline._parse_event(event) + + assert result is not None + assert result["request_data"] is None + assert result["response_data"] is None + + +class TestClientCaching: + def test_client_cached_per_region(self): + mock_session = MagicMock() + mock_client = MagicMock() + mock_session.client.return_value = mock_client + + timeline = CloudTrailTimeline(session=mock_session) + + # Get client twice for same region + client1 = timeline._get_client("us-east-1") + client2 = timeline._get_client("us-east-1") + + # Should only create client once + assert mock_session.client.call_count == 1 + assert client1 is client2 + + def test_different_clients_per_region(self): + mock_session = MagicMock() + + timeline = CloudTrailTimeline(session=mock_session) + + timeline._get_client("us-east-1") + timeline._get_client("eu-west-1") + + # Should create client for each region + assert mock_session.client.call_count == 2 + + +class TestIsReadOnlyEvent: + """Tests for _is_read_only_event method.""" + + @pytest.fixture + def mock_session(self): + return MagicMock() + + @pytest.mark.parametrize( + "event_name", + [ + "DescribeSecurityGroups", + "GetBucketPolicy", + "ListBuckets", + "HeadObject", + "CheckAccessNotGranted", + "LookupEvents", + "SearchResources", + "ScanOnDemand", + "QueryObjects", + "BatchGetItem", + "SelectObjectContent", + ], + ) + def test_read_only_events_detected(self, mock_session, event_name): + """Verify various read-only event prefixes are correctly identified.""" + timeline = CloudTrailTimeline(session=mock_session) + assert timeline._is_read_only_event(event_name) is True + + @pytest.mark.parametrize( + "event_name", + [ + "CreateSecurityGroup", + "DeleteSecurityGroup", + "ModifySecurityGroupRules", + "PutBucketPolicy", + "RunInstances", + "TerminateInstances", + "UpdateFunction", + "AttachRolePolicy", + "AuthorizeSecurityGroupIngress", + ], + ) + def test_write_events_not_filtered(self, mock_session, event_name): + """Verify write events are not marked as read-only.""" + timeline = CloudTrailTimeline(session=mock_session) + assert timeline._is_read_only_event(event_name) is False diff --git a/tests/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns_test.py b/tests/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns_test.py new file mode 100644 index 0000000000..e8b370ad49 --- /dev/null +++ b/tests/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/codebuild_project_webhook_filters_use_anchored_patterns_test.py @@ -0,0 +1,667 @@ +from unittest import mock + +from prowler.providers.aws.services.codebuild.codebuild_service import ( + Project, + Webhook, + WebhookFilter, + WebhookFilterGroup, +) + +AWS_REGION = "eu-west-1" +AWS_ACCOUNT_NUMBER = "123456789012" + + +class Test_codebuild_project_webhook_filters_use_anchored_patterns: + def test_no_projects(self): + codebuild_client = mock.MagicMock + codebuild_client.projects = {} + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 0 + + def test_project_without_webhook(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=None, + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "no webhook configured or all webhook filter patterns are properly anchored" + in result[0].status_extended + ) + assert result[0].resource_id == project_name + assert result[0].resource_arn == project_arn + assert result[0].region == AWS_REGION + + def test_project_webhook_empty_filter_groups(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook(filter_groups=[]), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "no webhook configured or all webhook filter patterns are properly anchored" + in result[0].status_extended + ) + + def test_project_webhook_with_anchored_patterns(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="^123456789$|^987654321$", + ), + WebhookFilter( + type="HEAD_REF", + pattern="^refs/heads/main$", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "no webhook configured or all webhook filter patterns are properly anchored" + in result[0].status_extended + ) + assert result[0].resource_id == project_name + assert result[0].resource_arn == project_arn + assert result[0].region == AWS_REGION + + def test_project_webhook_with_unanchored_patterns(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="123456|234567", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "unanchored patterns" in result[0].status_extended + assert "ACTOR_ACCOUNT_ID" in result[0].status_extended + assert result[0].resource_id == project_name + assert result[0].resource_arn == project_arn + assert result[0].region == AWS_REGION + + def test_project_webhook_with_mixed_anchored_unanchored(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="^123456$|234567", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "unanchored patterns" in result[0].status_extended + + def test_project_multiple_filter_groups_one_bad(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="^123456789$", + ), + ] + ), + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="BASE_REF", + pattern="refs/heads/main", + ), + ] + ), + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BASE_REF" in result[0].status_extended + assert "unanchored patterns" in result[0].status_extended + + def test_project_non_high_risk_filter_unanchored(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="EVENT", + pattern="PUSH|PULL_REQUEST_MERGED", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "no webhook configured or all webhook filter patterns are properly anchored" + in result[0].status_extended + ) + + def test_project_multiple_unanchored_filters_truncated(self): + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="123456", + ), + WebhookFilter( + type="HEAD_REF", + pattern="refs/heads/main", + ), + WebhookFilter( + type="BASE_REF", + pattern="refs/heads/develop", + ), + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="987654", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "and 1 more" in result[0].status_extended + + def test_project_webhook_with_empty_pattern(self): + """Empty patterns should PASS as they don't pose a bypass risk.""" + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_project_webhook_with_start_anchor_only(self): + """Pattern with only start anchor (^) should FAIL.""" + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="^123456789", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "unanchored patterns" in result[0].status_extended + + def test_project_webhook_with_end_anchor_only(self): + """Pattern with only end anchor ($) should FAIL.""" + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="123456789$", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "unanchored patterns" in result[0].status_extended + + def test_project_webhook_codebreach_research_vulnerable_pattern(self): + """Test with the exact vulnerable pattern from Wiz CodeBreach research - should FAIL.""" + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="16024985|755743|48153483|191175973|47447266|213081198", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "unanchored patterns" in result[0].status_extended + assert "ACTOR_ACCOUNT_ID" in result[0].status_extended + + def test_project_webhook_codebreach_research_fixed_pattern(self): + """Test with the properly anchored version of the research pattern - should PASS.""" + codebuild_client = mock.MagicMock + project_name = "test-project" + project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}" + codebuild_client.projects = { + project_arn: Project( + name=project_name, + arn=project_arn, + region=AWS_REGION, + webhook=Webhook( + filter_groups=[ + WebhookFilterGroup( + filters=[ + WebhookFilter( + type="ACTOR_ACCOUNT_ID", + pattern="^16024985$|^755743$|^48153483$|^191175973$|^47447266$|^213081198$", + ), + ] + ) + ] + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_service.Codebuild", + codebuild_client, + ), + mock.patch( + "prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_client", + codebuild_client, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_webhook_filters_use_anchored_patterns.codebuild_project_webhook_filters_use_anchored_patterns import ( + codebuild_project_webhook_filters_use_anchored_patterns, + ) + + check = codebuild_project_webhook_filters_use_anchored_patterns() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "no webhook configured or all webhook filter patterns are properly anchored" + in result[0].status_extended + ) diff --git a/tests/providers/aws/services/codebuild/codebuild_service_test.py b/tests/providers/aws/services/codebuild/codebuild_service_test.py index 9884b3a09f..6bf81f58c2 100644 --- a/tests/providers/aws/services/codebuild/codebuild_service_test.py +++ b/tests/providers/aws/services/codebuild/codebuild_service_test.py @@ -11,6 +11,9 @@ from prowler.providers.aws.services.codebuild.codebuild_service import ( ExportConfig, Project, ReportGroup, + Webhook, + WebhookFilter, + WebhookFilterGroup, s3Logs, ) from tests.providers.aws.utils import ( @@ -73,6 +76,23 @@ def mock_make_api_call(self, operation_name, kwarg): }, "tags": [{"key": "Name", "value": project_name}], "projectVisibility": project_visibility, + "webhook": { + "filterGroups": [ + [ + { + "type": "ACTOR_ACCOUNT_ID", + "pattern": "^123456789$", + "excludeMatchedPattern": False, + }, + { + "type": "EVENT", + "pattern": "PUSH", + "excludeMatchedPattern": False, + }, + ] + ], + "branchFilter": "main", + }, } ] } @@ -155,7 +175,37 @@ class Test_Codebuild_Service: assert codebuild.projects[project_arn].tags[0]["key"] == "Name" assert codebuild.projects[project_arn].tags[0]["value"] == project_name assert codebuild.projects[project_arn].project_visibility == project_visibility - # Asserttions related with report groups + # Assertions related with webhooks + assert codebuild.projects[project_arn].webhook is not None + assert isinstance(codebuild.projects[project_arn].webhook, Webhook) + assert codebuild.projects[project_arn].webhook.branch_filter == "main" + assert len(codebuild.projects[project_arn].webhook.filter_groups) == 1 + assert isinstance( + codebuild.projects[project_arn].webhook.filter_groups[0], WebhookFilterGroup + ) + assert ( + len(codebuild.projects[project_arn].webhook.filter_groups[0].filters) == 2 + ) + assert isinstance( + codebuild.projects[project_arn].webhook.filter_groups[0].filters[0], + WebhookFilter, + ) + assert ( + codebuild.projects[project_arn].webhook.filter_groups[0].filters[0].type + == "ACTOR_ACCOUNT_ID" + ) + assert ( + codebuild.projects[project_arn].webhook.filter_groups[0].filters[0].pattern + == "^123456789$" + ) + assert ( + codebuild.projects[project_arn] + .webhook.filter_groups[0] + .filters[0] + .exclude_matched_pattern + is False + ) + # Assertions related with report groups assert len(codebuild.report_groups) == 1 assert isinstance(codebuild.report_groups, dict) assert isinstance(codebuild.report_groups[report_group_arn], ReportGroup) diff --git a/tests/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access_test.py b/tests/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access_test.py index b29cd086bb..d3377a9b6e 100644 --- a/tests/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access_test.py +++ b/tests/providers/aws/services/dynamodb/dynamodb_table_cross_account_access/dynamodb_table_cross_account_access_test.py @@ -104,6 +104,7 @@ class Test_dynamodb_table_cross_account_access: def test_no_tables(self): dynamodb_client = mock.MagicMock dynamodb_client.tables = {} + dynamodb_client.audit_config = {} with ( mock.patch( "prowler.providers.aws.services.dynamodb.dynamodb_service.DynamoDB", diff --git a/tests/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access_test.py b/tests/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access_test.py index f48700db7b..e23e78a4a4 100644 --- a/tests/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access_test.py +++ b/tests/providers/aws/services/eventbridge/eventbridge_schema_registry_cross_account_access/eventbridge_schema_registry_cross_account_access_test.py @@ -46,10 +46,10 @@ self_asterisk_policy = { class Test_eventbridge_schema_registry_cross_account_access: - def test_no_schemas(self): schema_client = mock.MagicMock schema_client.registries = {} + schema_client.audit_config = {} with ( mock.patch( @@ -76,6 +76,7 @@ class Test_eventbridge_schema_registry_cross_account_access: schema_client = mock.MagicMock schema_client.audited_account = AWS_ACCOUNT_NUMBER + schema_client.audit_config = {} schema_client.registries = { test_schema_arn: Registry( name=test_schema_name, @@ -119,6 +120,7 @@ class Test_eventbridge_schema_registry_cross_account_access: schema_client = mock.MagicMock schema_client.audited_account = AWS_ACCOUNT_NUMBER + schema_client.audit_config = {} schema_client.registries = { test_schema_arn: Registry( name=test_schema_name, @@ -162,6 +164,7 @@ class Test_eventbridge_schema_registry_cross_account_access: schema_client = mock.MagicMock schema_client.audited_account = AWS_ACCOUNT_NUMBER + schema_client.audit_config = {} schema_client.registries = { test_schema_arn: Registry( name=test_schema_name, diff --git a/tests/providers/aws/services/eventbridge/eventbridge_service_test.py b/tests/providers/aws/services/eventbridge/eventbridge_service_test.py index c9b9ce4dcf..f0aa7e7541 100644 --- a/tests/providers/aws/services/eventbridge/eventbridge_service_test.py +++ b/tests/providers/aws/services/eventbridge/eventbridge_service_test.py @@ -135,7 +135,8 @@ class Test_EventBridge_Service: ) aws_provider = set_mocked_aws_provider() eventbridge = EventBridge(aws_provider) - assert len(eventbridge.buses) == 34 # 1 per region + # Each region has a default bus, plus the "test" bus we created in us-east-1 + assert len(eventbridge.buses) == len(eventbridge.regional_clients) + 1 for bus in eventbridge.buses.values(): if bus.name == "test": assert ( diff --git a/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py b/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py index 9600b320ac..ca8a9e5877 100644 --- a/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_is_enabled/guardduty_is_enabled_test.py @@ -55,7 +55,7 @@ class Test_guardduty_is_enabled: patch( "prowler.providers.aws.services.guardduty.guardduty_is_enabled.guardduty_is_enabled.guardduty_client", new=GuardDuty(aws_provider), - ), + ) as mock_guardduty_client, ): from prowler.providers.aws.services.guardduty.guardduty_is_enabled.guardduty_is_enabled import ( guardduty_is_enabled, @@ -63,7 +63,8 @@ class Test_guardduty_is_enabled: check = guardduty_is_enabled() results = check.execute() - assert len(results) == 32 + # One result per detector (one detector per region) + assert len(results) == len(mock_guardduty_client.detectors) for result in results: if result.region == AWS_REGION_EU_WEST_1: assert result.status == "PASS" @@ -108,7 +109,8 @@ class Test_guardduty_is_enabled: check = guardduty_is_enabled() results = check.execute() - assert len(results) == 32 + # One result per detector (one detector per region) + assert len(results) == len(mock_guardduty_client.detectors) for result in results: if result.region == AWS_REGION_EU_WEST_1: assert result.status == "FAIL" @@ -153,7 +155,8 @@ class Test_guardduty_is_enabled: check = guardduty_is_enabled() results = check.execute() - assert len(results) == 32 + # One result per detector (one detector per region) + assert len(results) == len(mock_guardduty_client.detectors) for result in results: if result.region == AWS_REGION_EU_WEST_1: assert result.status == "FAIL" @@ -196,7 +199,8 @@ class Test_guardduty_is_enabled: check = guardduty_is_enabled() results = check.execute() - assert len(results) == 32 + # One result per detector (one detector per region) + assert len(results) == len(mock_guardduty_client.detectors) for result in results: if result.region == AWS_REGION_EU_WEST_1: assert result.status == "FAIL" diff --git a/tests/providers/aws/services/iam/lib/policy_test.py b/tests/providers/aws/services/iam/lib/policy_test.py index 4687d38dab..fe25b4ca0f 100644 --- a/tests/providers/aws/services/iam/lib/policy_test.py +++ b/tests/providers/aws/services/iam/lib/policy_test.py @@ -18,6 +18,7 @@ from prowler.providers.aws.services.iam.lib.policy import ( TRUSTED_AWS_ACCOUNT_NUMBER = "123456789012" NON_TRUSTED_AWS_ACCOUNT_NUMBER = "111222333444" +TRUSTED_AWS_ACCOUNT_NUMBER_LIST = ["123456789012", "123456789013", "123456789014"] TRUSTED_ORGANIZATION_ID = "o-123456789012" NON_TRUSTED_ORGANIZATION_ID = "o-111222333444" @@ -1652,6 +1653,49 @@ class Test_Policy: is_cross_account_allowed=False, ) + def test_cross_account_access_trusted_account_list(self): + policy = { + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": f"arn:aws:iam::{TRUSTED_AWS_ACCOUNT_NUMBER_LIST[0]}:root" + }, + "Action": "*", + "Resource": "*", + } + ] + } + assert not is_policy_public( + policy, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=False, + trusted_account_ids=TRUSTED_AWS_ACCOUNT_NUMBER_LIST, + ) + + def test_cross_account_access_with_principal_list_trusted_account_list(self): + policy = { + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": [ + f"arn:aws:iam::{TRUSTED_AWS_ACCOUNT_NUMBER_LIST[0]}:root", + f"arn:aws:iam::{NON_TRUSTED_AWS_ACCOUNT_NUMBER}:root", + ] + }, + "Action": "*", + "Resource": "*", + } + ] + } + assert is_policy_public( + policy, + TRUSTED_AWS_ACCOUNT_NUMBER, + is_cross_account_allowed=False, + trusted_account_ids=TRUSTED_AWS_ACCOUNT_NUMBER_LIST, + ) + def test_policy_allows_public_access_with_wildcard_principal(self): policy_allow_wildcard_principal = { "Statement": [ diff --git a/tests/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support_test.py b/tests/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support_test.py new file mode 100644 index 0000000000..fd1c79cbb5 --- /dev/null +++ b/tests/providers/aws/services/rds/rds_instance_extended_support/rds_instance_extended_support_test.py @@ -0,0 +1,155 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + """ + Moto's RDS implementation does not currently expose EngineLifecycleSupport on DescribeDBInstances. + This patch injects it into the response so that Prowler's RDS service can map it onto the DBInstance model. + + The check under test fails when: + EngineLifecycleSupport == "open-source-rds-extended-support" + """ + response = make_api_call(self, operation_name, kwarg) + + if operation_name == "DescribeDBInstances": + for instance in response.get("DBInstances", []): + if instance.get("DBInstanceIdentifier") == "db-extended-1": + instance["EngineLifecycleSupport"] = "open-source-rds-extended-support" + return response + + return response + + +@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) +class Test_rds_instance_extended_support: + @mock_aws + def test_rds_no_instances(self): + from prowler.providers.aws.services.rds.rds_service import RDS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.rds.rds_instance_extended_support.rds_instance_extended_support.rds_client", + new=RDS(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.rds.rds_instance_extended_support.rds_instance_extended_support import ( + rds_instance_extended_support, + ) + + check = rds_instance_extended_support() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_rds_instance_not_enrolled_in_extended_support(self): + conn = client("rds", region_name=AWS_REGION_US_EAST_1) + conn.create_db_instance( + DBInstanceIdentifier="db-standard-1", + AllocatedStorage=10, + Engine="postgres", + EngineVersion="8.0.32", + DBName="staging-postgres", + DBInstanceClass="db.m1.small", + PubliclyAccessible=False, + ) + + from prowler.providers.aws.services.rds.rds_service import RDS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.rds.rds_instance_extended_support.rds_instance_extended_support.rds_client", + new=RDS(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.rds.rds_instance_extended_support.rds_instance_extended_support import ( + rds_instance_extended_support, + ) + + check = rds_instance_extended_support() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "RDS instance db-standard-1 (postgres 8.0.32) is not enrolled in RDS Extended Support." + ) + assert result[0].resource_id == "db-standard-1" + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].resource_arn + == f"arn:aws:rds:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:db:db-standard-1" + ) + assert result[0].resource_tags == [] + + @mock_aws + def test_rds_instance_enrolled_in_extended_support(self): + conn = client("rds", region_name=AWS_REGION_US_EAST_1) + conn.create_db_instance( + DBInstanceIdentifier="db-extended-1", + AllocatedStorage=10, + Engine="postgres", + EngineVersion="8.0.32", + DBName="staging-postgres", + DBInstanceClass="db.m1.small", + PubliclyAccessible=False, + ) + + from prowler.providers.aws.services.rds.rds_service import RDS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.rds.rds_instance_extended_support.rds_instance_extended_support.rds_client", + new=RDS(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.rds.rds_instance_extended_support.rds_instance_extended_support import ( + rds_instance_extended_support, + ) + + check = rds_instance_extended_support() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "RDS instance db-extended-1 (postgres 8.0.32) is enrolled in RDS Extended Support " + "(EngineLifecycleSupport=open-source-rds-extended-support)." + ) + assert result[0].resource_id == "db-extended-1" + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].resource_arn + == f"arn:aws:rds:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:db:db-extended-1" + ) + assert result[0].resource_tags == [] diff --git a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py index 84dab8985b..4667b665ed 100644 --- a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py @@ -69,7 +69,6 @@ class Test_entra_non_privileged_user_has_mfa: entra_non_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -77,7 +76,7 @@ class Test_entra_non_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")], + is_mfa_capable=False, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} @@ -117,7 +116,6 @@ class Test_entra_non_privileged_user_has_mfa: entra_non_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -125,10 +123,7 @@ class Test_entra_non_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[ - AuthMethod(id=str(uuid4()), type="foo"), - AuthMethod(id=str(uuid4()), type="bar"), - ], + is_mfa_capable=True, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} @@ -165,7 +160,6 @@ class Test_entra_non_privileged_user_has_mfa: entra_non_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -173,7 +167,7 @@ class Test_entra_non_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")], + is_mfa_capable=False, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} @@ -207,7 +201,6 @@ class Test_entra_non_privileged_user_has_mfa: entra_non_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -215,10 +208,7 @@ class Test_entra_non_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[ - AuthMethod(id=str(uuid4()), type="foo"), - AuthMethod(id=str(uuid4()), type="bar"), - ], + is_mfa_capable=True, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} diff --git a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py index 51ffba58da..31e0a57bff 100644 --- a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py @@ -69,7 +69,6 @@ class Test_entra_privileged_user_has_mfa: entra_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -77,7 +76,7 @@ class Test_entra_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")], + is_mfa_capable=False, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} @@ -109,7 +108,6 @@ class Test_entra_privileged_user_has_mfa: entra_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -117,10 +115,7 @@ class Test_entra_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[ - AuthMethod(id=str(uuid4()), type="foo"), - AuthMethod(id=str(uuid4()), type="bar"), - ], + is_mfa_capable=True, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} @@ -152,7 +147,6 @@ class Test_entra_privileged_user_has_mfa: entra_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -160,7 +154,7 @@ class Test_entra_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")], + is_mfa_capable=False, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} @@ -199,7 +193,6 @@ class Test_entra_privileged_user_has_mfa: entra_privileged_user_has_mfa, ) from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, DirectoryRole, User, ) @@ -207,10 +200,7 @@ class Test_entra_privileged_user_has_mfa: user = User( id=user_id, name="foo", - authentication_methods=[ - AuthMethod(id=str(uuid4()), type="foo"), - AuthMethod(id=str(uuid4()), type="bar"), - ], + is_mfa_capable=True, ) entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index 85f9a3c558..b038d075b1 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -145,10 +145,7 @@ class Test_Entra_Service: assert len(entra_client.users) == 1 assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1" assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1" - assert ( - len(entra_client.users[DOMAIN]["user-1@tenant1.es"].authentication_methods) - == 0 - ) + assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False def test_get_authorization_policy(self): entra_client = Entra(set_mocked_azure_provider()) @@ -251,38 +248,48 @@ def test_azure_entra__get_users_handles_pagination(): ) with_url_mock = MagicMock(return_value=users_with_url_builder) - def by_user_id_side_effect(user_id): - auth_methods_response = SimpleNamespace( - value=[ - SimpleNamespace( - id=f"{user_id}-method", - odata_type="#microsoft.graph.passwordAuthenticationMethod", - ) - ] - ) - return SimpleNamespace( - authentication=SimpleNamespace( - methods=SimpleNamespace( - get=AsyncMock(return_value=auth_methods_response) - ) - ) - ) - users_builder = SimpleNamespace( get=AsyncMock(return_value=users_response_page_one), with_url=with_url_mock, - by_user_id=MagicMock(side_effect=by_user_id_side_effect), ) - entra_service.clients = {"tenant-1": SimpleNamespace(users=users_builder)} + registration_details_response = SimpleNamespace( + value=[ + SimpleNamespace( + id="user-1", + is_mfa_capable=True, + ), + SimpleNamespace( + id="user-2", + is_mfa_capable=True, + ), + ], + odata_next_link=None, + ) + + registration_details_builder = SimpleNamespace( + get=AsyncMock(return_value=registration_details_response), + with_url=MagicMock(), + ) + + entra_service.clients = { + "tenant-1": SimpleNamespace( + users=users_builder, + reports=SimpleNamespace( + authentication_methods=SimpleNamespace( + user_registration_details=registration_details_builder + ) + ), + ) + } users = asyncio.run(entra_service._get_users()) assert len(users["tenant-1"]) == 3 assert users_builder.get.await_count == 1 with_url_mock.assert_called_once_with("next-link") - assert users["tenant-1"]["user-1"].authentication_methods[0].id == "user-1-method" - assert ( - users["tenant-1"]["user-3"].authentication_methods[0].type - == "#microsoft.graph.passwordAuthenticationMethod" - ) + registration_details_builder.get.assert_awaited() + registration_details_builder.with_url.assert_not_called() + assert users["tenant-1"]["user-1"].is_mfa_capable is True + assert users["tenant-1"]["user-2"].is_mfa_capable is True + assert users["tenant-1"]["user-3"].is_mfa_capable is False diff --git a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py index a02995c45b..b9ebe959ef 100644 --- a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py @@ -61,10 +61,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: new=entra_client, ), ): - from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, - User, - ) + from prowler.providers.azure.services.entra.entra_service import User from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import ( entra_user_with_vm_access_has_mfa, ) @@ -90,12 +87,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: f"test@{DOMAIN}": User( id=user_id, name="test", - authentication_methods=[ - AuthMethod(id=str(uuid4()), type="Password"), - AuthMethod( - id=str(uuid4()), type="MicrosoftAuthenticator" - ), - ], + is_mfa_capable=True, ) } } @@ -138,10 +130,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: new=entra_client, ), ): - from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, - User, - ) + from prowler.providers.azure.services.entra.entra_service import User from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import ( entra_user_with_vm_access_has_mfa, ) @@ -167,9 +156,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: f"test@{DOMAIN}": User( id=user_id, name="test", - authentication_methods=[ - AuthMethod(id=str(uuid4()), type="Password"), - ], + is_mfa_capable=False, ) } } @@ -264,10 +251,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: new=entra_client, ), ): - from prowler.providers.azure.services.entra.entra_service import ( - AuthMethod, - User, - ) + from prowler.providers.azure.services.entra.entra_service import User from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import ( entra_user_with_vm_access_has_mfa, ) @@ -293,12 +277,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: f"test@{DOMAIN}": User( id=user_id, name="test", - authentication_methods=[ - AuthMethod(id=str(uuid4()), type="Password"), - AuthMethod( - id=str(uuid4()), type="MicrosoftAuthenticator" - ), - ], + is_mfa_capable=True, ) } } diff --git a/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py b/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py index 59b5285de5..d46bc7b223 100644 --- a/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py @@ -97,11 +97,12 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) + secret_id = str(uuid4()) secret = Secret( - id="id", + id=secret_id, name=secret_name, enabled=True, - location="location", + location="westeurope", attributes=SecretAttributes(expires=None), ) keyvault_client.key_vaults = { @@ -127,11 +128,11 @@ class Test_keyvault_rbac_secret_expiration_set: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the secret {secret_name} without expiration date set." + == f"Secret '{secret_name}' in KeyVault '{keyvault_name}' does not have expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id + assert result[0].resource_name == secret_name + assert result[0].resource_id == secret_id assert result[0].location == "westeurope" def test_key_vaults_invalid_multiple_secrets(self): @@ -159,18 +160,20 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) + secret1_id = str(uuid4()) + secret2_id = str(uuid4()) secret1 = Secret( - id="id", + id=secret1_id, name=secret1_name, enabled=True, - location="location", + location="westeurope", attributes=SecretAttributes(expires=None), ) secret2 = Secret( - id="id", + id=secret2_id, name=secret2_name, enabled=True, - location="location", + location="westeurope", attributes=SecretAttributes(expires=84934), ) keyvault_client.key_vaults = { @@ -192,16 +195,35 @@ class Test_keyvault_rbac_secret_expiration_set: } check = keyvault_rbac_secret_expiration_set() result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" + # Now we get 1 finding per secret (2 total) + assert len(result) == 2 + + # Find the FAIL and PASS results by status + fail_results = [r for r in result if r.status == "FAIL"] + pass_results = [r for r in result if r.status == "PASS"] + + assert len(fail_results) == 1 + assert len(pass_results) == 1 + + # Verify FAIL finding (secret1 without expiration) assert ( - result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the secret {secret1_name} without expiration date set." + fail_results[0].status_extended + == f"Secret '{secret1_name}' in KeyVault '{keyvault_name}' does not have expiration date set." ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert fail_results[0].subscription == AZURE_SUBSCRIPTION_ID + assert fail_results[0].resource_name == secret1_name + assert fail_results[0].resource_id == secret1_id + assert fail_results[0].location == "westeurope" + + # Verify PASS finding (secret2 with expiration) + assert ( + pass_results[0].status_extended + == f"Secret '{secret2_name}' in KeyVault '{keyvault_name}' has expiration date set." + ) + assert pass_results[0].subscription == AZURE_SUBSCRIPTION_ID + assert pass_results[0].resource_name == secret2_name + assert pass_results[0].resource_id == secret2_id + assert pass_results[0].location == "westeurope" def test_key_vaults_valid_keys(self): keyvault_client = mock.MagicMock @@ -226,11 +248,13 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) + secret_name = "secret-name" + secret_id = str(uuid4()) secret = Secret( - id="id", - name="name", + id=secret_id, + name=secret_name, enabled=False, - location="location", + location="westeurope", attributes=SecretAttributes(expires=None), ) keyvault_client.key_vaults = { @@ -256,9 +280,9 @@ class Test_keyvault_rbac_secret_expiration_set: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has all the secrets with expiration date set." + == f"Secret '{secret_name}' in KeyVault '{keyvault_name}' has expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id + assert result[0].resource_name == secret_name + assert result[0].resource_id == secret_id assert result[0].location == "westeurope" diff --git a/tests/providers/cloudflare/cloudflare_provider_test.py b/tests/providers/cloudflare/cloudflare_provider_test.py index 57bdade5fb..1f56db03ea 100644 --- a/tests/providers/cloudflare/cloudflare_provider_test.py +++ b/tests/providers/cloudflare/cloudflare_provider_test.py @@ -5,6 +5,7 @@ import pytest from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider from prowler.providers.cloudflare.exceptions.exceptions import ( CloudflareCredentialsError, + CloudflareInvalidAccountError, ) from prowler.providers.cloudflare.models import ( CloudflareAccount, @@ -201,6 +202,74 @@ class TestCloudflareProvider: assert provider.filter_zones == set(filter_zones) + def test_cloudflare_provider_with_filter_accounts(self): + with ( + patch( + "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session", + return_value=CloudflareSession( + client=MagicMock(), + api_token=API_TOKEN, + api_key=None, + api_email=None, + ), + ), + patch( + "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity", + return_value=CloudflareIdentityInfo( + user_id=USER_ID, + email=USER_EMAIL, + accounts=[ + CloudflareAccount( + id=ACCOUNT_ID, + name=ACCOUNT_NAME, + type="standard", + ), + CloudflareAccount( + id="other-account-id", + name="Other Account", + type="standard", + ), + ], + audited_accounts=[ACCOUNT_ID, "other-account-id"], + ), + ), + ): + provider = CloudflareProvider(filter_accounts=[ACCOUNT_ID]) + + assert provider.filter_accounts == {ACCOUNT_ID} + # Only the filtered account should remain in audited_accounts + assert provider.identity.audited_accounts == [ACCOUNT_ID] + + def test_cloudflare_provider_with_invalid_filter_accounts(self): + with ( + patch( + "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session", + return_value=CloudflareSession( + client=MagicMock(), + api_token=API_TOKEN, + api_key=None, + api_email=None, + ), + ), + patch( + "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_identity", + return_value=CloudflareIdentityInfo( + user_id=USER_ID, + email=USER_EMAIL, + accounts=[ + CloudflareAccount( + id=ACCOUNT_ID, + name=ACCOUNT_NAME, + type="standard", + ), + ], + audited_accounts=[ACCOUNT_ID], + ), + ), + ): + with pytest.raises(CloudflareInvalidAccountError): + CloudflareProvider(filter_accounts=["non-existent-account-id"]) + def test_cloudflare_provider_properties(self): with ( patch( diff --git a/tests/providers/cloudflare/lib/mutelist/cloudflare_mutelist_test.py b/tests/providers/cloudflare/lib/mutelist/cloudflare_mutelist_test.py index 394ec38bb7..64d684e356 100644 --- a/tests/providers/cloudflare/lib/mutelist/cloudflare_mutelist_test.py +++ b/tests/providers/cloudflare/lib/mutelist/cloudflare_mutelist_test.py @@ -45,7 +45,7 @@ class TestCloudflareMutelist: "Accounts": { "test-account-id": { "Checks": { - "zones_dnssec_enabled": { + "zone_dnssec_enabled": { "Regions": ["*"], "Resources": ["test-zone-id"], } @@ -58,7 +58,7 @@ class TestCloudflareMutelist: finding = MagicMock() finding.check_metadata = MagicMock() - finding.check_metadata.CheckID = "zones_dnssec_enabled" + finding.check_metadata.CheckID = "zone_dnssec_enabled" finding.status = "FAIL" finding.resource_id = "test-zone-id" finding.resource_name = "example.com" @@ -71,7 +71,7 @@ class TestCloudflareMutelist: "Accounts": { "test-account-id": { "Checks": { - "zones_dnssec_enabled": { + "zone_dnssec_enabled": { "Regions": ["*"], "Resources": ["other-zone-id"], } @@ -84,7 +84,7 @@ class TestCloudflareMutelist: finding = MagicMock() finding.check_metadata = MagicMock() - finding.check_metadata.CheckID = "zones_dnssec_enabled" + finding.check_metadata.CheckID = "zone_dnssec_enabled" finding.status = "FAIL" finding.resource_id = "test-zone-id" finding.resource_name = "example.com" diff --git a/tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml b/tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml index 9bbe4159cf..f18878efd6 100644 --- a/tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml +++ b/tests/providers/cloudflare/lib/mutelist/fixtures/cloudflare_mutelist.yaml @@ -2,7 +2,7 @@ Mutelist: Accounts: "test-account-id": Checks: - "zones_dnssec_enabled": + "zone_dnssec_enabled": Regions: - "*" Resources: diff --git a/tests/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid_test.py b/tests/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid_test.py new file mode 100644 index 0000000000..fa3ea04c4e --- /dev/null +++ b/tests/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid_test.py @@ -0,0 +1,425 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_dns_record_cname_target_valid: + def test_no_records(self): + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 0 + + def test_non_cname_record(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 0 + + def test_cname_record_valid_target(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + return_value=[("", "", "", "", ("192.0.2.1", 0))], + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "record-1" + assert result[0].resource_name == "www.example.com" + assert result[0].status == "PASS" + assert "points to valid target" in result[0].status_extended + + def test_cname_record_dangling_target(self): + import socket + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="old.example.com", + type="CNAME", + content="nonexistent.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + side_effect=socket.gaierror("Name or service not known"), + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "potentially dangling target" in result[0].status_extended + assert "subdomain takeover risk" in result[0].status_extended + + def test_cname_record_with_trailing_dot(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com.", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + return_value=[("", "", "", "", ("192.0.2.1", 0))], + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_mx_record_valid_target(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="example.com", + type="MX", + content="10 mail.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + return_value=[("", "", "", "", ("192.0.2.1", 0))], + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "MX record" in result[0].status_extended + assert "points to valid target" in result[0].status_extended + + def test_mx_record_dangling_target(self): + import socket + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="example.com", + type="MX", + content="10 nonexistent-mail.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + side_effect=socket.gaierror("Name or service not known"), + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MX record" in result[0].status_extended + assert "mail interception risk" in result[0].status_extended + + def test_ns_record_valid_target(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="sub.example.com", + type="NS", + content="ns1.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + return_value=[("", "", "", "", ("192.0.2.1", 0))], + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "NS record" in result[0].status_extended + + def test_ns_record_dangling_target(self): + import socket + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="sub.example.com", + type="NS", + content="nonexistent-ns.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + side_effect=socket.gaierror("Name or service not known"), + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "NS record" in result[0].status_extended + assert "subdomain delegation takeover risk" in result[0].status_extended + + def test_srv_record_valid_target(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="_sip._tcp.example.com", + type="SRV", + content="10 5 5060 sip.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + return_value=[("", "", "", "", ("192.0.2.1", 0))], + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "SRV record" in result[0].status_extended + + def test_srv_record_dangling_target(self): + import socket + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="_sip._tcp.example.com", + type="SRV", + content="10 5 5060 nonexistent-sip.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid.dns_client", + new=dns_client, + ), + mock.patch( + "socket.getaddrinfo", + side_effect=socket.gaierror("Name or service not known"), + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_cname_target_valid.dns_record_cname_target_valid import ( + dns_record_cname_target_valid, + ) + + check = dns_record_cname_target_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "SRV record" in result[0].status_extended + assert "service discovery vulnerability" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip_test.py b/tests/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip_test.py new file mode 100644 index 0000000000..76942df8f4 --- /dev/null +++ b/tests/providers/cloudflare/services/dns/dns_record_no_internal_ip/dns_record_no_internal_ip_test.py @@ -0,0 +1,312 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_dns_record_no_internal_ip: + def test_no_records(self): + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 0 + + def test_non_ip_record(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 0 + + def test_a_record_public_ip(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="A", + content="8.8.8.8", # Google DNS - a truly public IP + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "record-1" + assert result[0].resource_name == "www.example.com" + assert result[0].status == "PASS" + assert "public IP address" in result[0].status_extended + + def test_a_record_private_ip_10(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="internal.example.com", + type="A", + content="10.0.0.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "internal IP address" in result[0].status_extended + assert "information disclosure risk" in result[0].status_extended + + def test_a_record_private_ip_172(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="internal.example.com", + type="A", + content="172.16.0.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "internal IP address" in result[0].status_extended + + def test_a_record_private_ip_192(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="internal.example.com", + type="A", + content="192.168.1.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "internal IP address" in result[0].status_extended + + def test_a_record_loopback(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="localhost.example.com", + type="A", + content="127.0.0.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "internal IP address" in result[0].status_extended + + def test_aaaa_record_public_ip(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="AAAA", + content="2001:db8::1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 1 + # 2001:db8:: is documentation prefix and is reserved + assert result[0].status == "FAIL" + + def test_aaaa_record_link_local(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="internal.example.com", + type="AAAA", + content="fe80::1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_internal_ip.dns_record_no_internal_ip import ( + dns_record_no_internal_ip, + ) + + check = dns_record_no_internal_ip() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "internal IP address" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard_test.py b/tests/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard_test.py new file mode 100644 index 0000000000..11d8486166 --- /dev/null +++ b/tests/providers/cloudflare/services/dns/dns_record_no_wildcard/dns_record_no_wildcard_test.py @@ -0,0 +1,345 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_dns_record_no_wildcard: + def test_no_records(self): + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 0 + + def test_non_ip_record(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="example.com", + type="TXT", + content="v=spf1 include:_spf.google.com ~all", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 0 + + def test_a_record_not_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="A", + content="8.8.8.8", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "record-1" + assert result[0].resource_name == "www.example.com" + assert result[0].status == "PASS" + assert "is not a wildcard record" in result[0].status_extended + + def test_a_record_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="*.example.com", + type="A", + content="8.8.8.8", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is a wildcard record" in result[0].status_extended + assert "may expose unintended services" in result[0].status_extended + + def test_aaaa_record_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="*.example.com", + type="AAAA", + content="2001:db8::1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is a wildcard record" in result[0].status_extended + + def test_cname_record_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="*.example.com", + type="CNAME", + content="example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is a wildcard record" in result[0].status_extended + + def test_cname_record_not_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is not a wildcard record" in result[0].status_extended + + def test_mx_record_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="*.example.com", + type="MX", + content="10 mail.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is a wildcard record" in result[0].status_extended + + def test_mx_record_not_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="example.com", + type="MX", + content="10 mail.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is not a wildcard record" in result[0].status_extended + + def test_srv_record_wildcard(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="*._tcp.example.com", + type="SRV", + content="10 5 5060 sip.example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_no_wildcard.dns_record_no_wildcard import ( + dns_record_no_wildcard, + ) + + check = dns_record_no_wildcard() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is a wildcard record" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied_test.py b/tests/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied_test.py new file mode 100644 index 0000000000..41a828aa01 --- /dev/null +++ b/tests/providers/cloudflare/services/dns/dns_record_proxied/dns_record_proxied_test.py @@ -0,0 +1,288 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_dns_record_proxied: + def test_no_records(self): + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 0 + + def test_non_proxyable_record(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="example.com", + type="TXT", + content="v=spf1 include:_spf.google.com ~all", + proxied=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 0 + + def test_a_record_proxied(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="A", + content="8.8.8.8", + proxied=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "record-1" + assert result[0].resource_name == "www.example.com" + assert result[0].status == "PASS" + assert "is proxied through Cloudflare" in result[0].status_extended + # DNS records should have zone_name as region + assert result[0].region == ZONE_NAME + assert result[0].zone_name == ZONE_NAME + + def test_a_record_not_proxied(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="A", + content="8.8.8.8", + proxied=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not proxied through Cloudflare" in result[0].status_extended + + def test_aaaa_record_proxied(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="AAAA", + content="2001:db8::1", + proxied=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is proxied through Cloudflare" in result[0].status_extended + + def test_aaaa_record_not_proxied(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="AAAA", + content="2001:db8::1", + proxied=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not proxied through Cloudflare" in result[0].status_extended + + def test_cname_record_proxied(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com", + proxied=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is proxied through Cloudflare" in result[0].status_extended + + def test_cname_record_not_proxied(self): + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com", + proxied=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.dns.dns_record_proxied.dns_record_proxied import ( + dns_record_proxied, + ) + + check = dns_record_proxied() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not proxied through Cloudflare" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/dns/dns_service_test.py b/tests/providers/cloudflare/services/dns/dns_service_test.py new file mode 100644 index 0000000000..c4e30c5d56 --- /dev/null +++ b/tests/providers/cloudflare/services/dns/dns_service_test.py @@ -0,0 +1,119 @@ +from typing import Optional + +from pydantic import BaseModel + +from tests.providers.cloudflare.cloudflare_fixtures import ZONE_ID, ZONE_NAME + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class TestDNSService: + def test_cloudflare_dns_record_model(self): + record = CloudflareDNSRecord( + id="record-123", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="A", + content="192.0.2.1", + ttl=3600, + proxied=True, + ) + + assert record.id == "record-123" + assert record.zone_id == ZONE_ID + assert record.zone_name == ZONE_NAME + assert record.name == "www.example.com" + assert record.type == "A" + assert record.content == "192.0.2.1" + assert record.ttl == 3600 + assert record.proxied is True + + def test_cloudflare_dns_record_defaults(self): + record = CloudflareDNSRecord( + id="record-123", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + ) + + assert record.id == "record-123" + assert record.zone_id == ZONE_ID + assert record.zone_name == ZONE_NAME + assert record.name is None + assert record.type is None + assert record.content == "" + assert record.ttl is None + assert record.proxied is False + + def test_cloudflare_dns_record_txt(self): + record = CloudflareDNSRecord( + id="record-txt", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="v=spf1 include:_spf.google.com ~all", + ttl=1, + proxied=False, + ) + + assert record.type == "TXT" + assert "v=spf1" in record.content + assert record.proxied is False + + def test_cloudflare_dns_record_cname(self): + record = CloudflareDNSRecord( + id="record-cname", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com", + ttl=3600, + proxied=True, + ) + + assert record.type == "CNAME" + assert record.content == "example.com" + assert record.proxied is True + + def test_cloudflare_dns_record_mx(self): + record = CloudflareDNSRecord( + id="record-mx", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="MX", + content="10 mail.example.com", + ttl=3600, + proxied=False, + ) + + assert record.type == "MX" + assert "mail.example.com" in record.content + + def test_cloudflare_dns_record_caa(self): + record = CloudflareDNSRecord( + id="record-caa", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issue "letsencrypt.org"', + ttl=3600, + proxied=False, + ) + + assert record.type == "CAA" + assert "letsencrypt.org" in record.content diff --git a/tests/providers/cloudflare/services/firewall/firewall_service_test.py b/tests/providers/cloudflare/services/firewall/firewall_service_test.py new file mode 100644 index 0000000000..0b4db7db90 --- /dev/null +++ b/tests/providers/cloudflare/services/firewall/firewall_service_test.py @@ -0,0 +1,84 @@ +from typing import Optional + +from pydantic import BaseModel + +from tests.providers.cloudflare.cloudflare_fixtures import ZONE_ID, ZONE_NAME + + +class CloudflareFirewallRule(BaseModel): + """Cloudflare firewall rule representation for testing.""" + + id: Optional[str] = None + zone_id: str + zone_name: str + ruleset_id: Optional[str] = None + phase: Optional[str] = None + action: Optional[str] = None + expression: Optional[str] = None + description: Optional[str] = None + enabled: bool = True + + +class TestFirewallService: + def test_cloudflare_firewall_rule_model(self): + rule = CloudflareFirewallRule( + id="rule-123", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + ruleset_id="ruleset-456", + phase="http_ratelimit", + action="block", + expression="(http.request.uri.path contains '/api/')", + description="Rate limit API requests", + enabled=True, + ) + + assert rule.id == "rule-123" + assert rule.zone_id == ZONE_ID + assert rule.zone_name == ZONE_NAME + assert rule.ruleset_id == "ruleset-456" + assert rule.phase == "http_ratelimit" + assert rule.action == "block" + assert rule.expression == "(http.request.uri.path contains '/api/')" + assert rule.description == "Rate limit API requests" + assert rule.enabled is True + + def test_cloudflare_firewall_rule_defaults(self): + rule = CloudflareFirewallRule( + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + ) + + assert rule.id is None + assert rule.zone_id == ZONE_ID + assert rule.zone_name == ZONE_NAME + assert rule.ruleset_id is None + assert rule.phase is None + assert rule.action is None + assert rule.expression is None + assert rule.description is None + assert rule.enabled is True + + def test_cloudflare_firewall_rule_disabled(self): + rule = CloudflareFirewallRule( + id="rule-disabled", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + phase="http_ratelimit", + enabled=False, + ) + + assert rule.enabled is False + + def test_cloudflare_firewall_rule_custom_phase(self): + rule = CloudflareFirewallRule( + id="rule-custom", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + phase="http_request_firewall_custom", + action="challenge", + expression="(cf.threat_score > 10)", + ) + + assert rule.phase == "http_request_firewall_custom" + assert rule.action == "challenge" diff --git a/tests/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled_test.py b/tests/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled_test.py new file mode 100644 index 0000000000..0d785b4fe9 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_always_online_disabled/zone_always_online_disabled_test.py @@ -0,0 +1,138 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_always_online_disabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled import ( + zone_always_online_disabled, + ) + + check = zone_always_online_disabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_always_online_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + always_online="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled import ( + zone_always_online_disabled, + ) + + check = zone_always_online_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Always Online is disabled" in result[0].status_extended + + def test_zone_always_online_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + always_online="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled import ( + zone_always_online_disabled, + ) + + check = zone_always_online_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Always Online is enabled" in result[0].status_extended + + def test_zone_always_online_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + always_online=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_always_online_disabled.zone_always_online_disabled import ( + zone_always_online_disabled, + ) + + check = zone_always_online_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled_test.py new file mode 100644 index 0000000000..2cacb15796 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled_test.py @@ -0,0 +1,143 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_automatic_https_rewrites_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_automatic_https_rewrites_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + automatic_https_rewrites="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Automatic HTTPS Rewrites is enabled" in result[0].status_extended + + def test_zone_automatic_https_rewrites_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + automatic_https_rewrites="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Automatic HTTPS Rewrites is not enabled" in result[0].status_extended + ) + + def test_zone_automatic_https_rewrites_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + automatic_https_rewrites=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Automatic HTTPS Rewrites is not enabled" in result[0].status_extended + ) diff --git a/tests/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled_test.py new file mode 100644 index 0000000000..396c93abc7 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_bot_fight_mode_enabled/zone_bot_fight_mode_enabled_test.py @@ -0,0 +1,106 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_bot_fight_mode_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_bot_fight_mode_enabled.zone_bot_fight_mode_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_bot_fight_mode_enabled.zone_bot_fight_mode_enabled import ( + zone_bot_fight_mode_enabled, + ) + + check = zone_bot_fight_mode_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_bot_fight_mode_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + bot_fight_mode_enabled=True, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_bot_fight_mode_enabled.zone_bot_fight_mode_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_bot_fight_mode_enabled.zone_bot_fight_mode_enabled import ( + zone_bot_fight_mode_enabled, + ) + + check = zone_bot_fight_mode_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Bot Fight Mode" in result[0].status_extended + assert "enabled" in result[0].status_extended + + def test_zone_bot_fight_mode_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + bot_fight_mode_enabled=False, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_bot_fight_mode_enabled.zone_bot_fight_mode_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_bot_fight_mode_enabled.zone_bot_fight_mode_enabled import ( + zone_bot_fight_mode_enabled, + ) + + check = zone_bot_fight_mode_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "not enabled" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled_test.py new file mode 100644 index 0000000000..f79dac8ad1 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_browser_integrity_check_enabled/zone_browser_integrity_check_enabled_test.py @@ -0,0 +1,139 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_browser_integrity_check_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled import ( + zone_browser_integrity_check_enabled, + ) + + check = zone_browser_integrity_check_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_browser_integrity_check_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + browser_check="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled import ( + zone_browser_integrity_check_enabled, + ) + + check = zone_browser_integrity_check_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Browser Integrity Check" in result[0].status_extended + assert "enabled" in result[0].status_extended + + def test_zone_browser_integrity_check_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + browser_check="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled import ( + zone_browser_integrity_check_enabled, + ) + + check = zone_browser_integrity_check_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "not enabled" in result[0].status_extended + + def test_zone_browser_integrity_check_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + browser_check=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_browser_integrity_check_enabled.zone_browser_integrity_check_enabled import ( + zone_browser_integrity_check_enabled, + ) + + check = zone_browser_integrity_check_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured_test.py b/tests/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured_test.py new file mode 100644 index 0000000000..dc794104c9 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_challenge_passage_configured/zone_challenge_passage_configured_test.py @@ -0,0 +1,242 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_challenge_passage_configured: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured import ( + zone_challenge_passage_configured, + ) + + check = zone_challenge_passage_configured() + result = check.execute() + assert len(result) == 0 + + def test_zone_challenge_passage_at_min(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + challenge_ttl=900, # 15 minutes - minimum recommended + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured import ( + zone_challenge_passage_configured, + ) + + check = zone_challenge_passage_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "15 minutes" in result[0].status_extended + + def test_zone_challenge_passage_at_max(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + challenge_ttl=2700, # 45 minutes - maximum recommended + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured import ( + zone_challenge_passage_configured, + ) + + check = zone_challenge_passage_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "45 minutes" in result[0].status_extended + + def test_zone_challenge_passage_default(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + challenge_ttl=1800, # 30 minutes - default and secure + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured import ( + zone_challenge_passage_configured, + ) + + check = zone_challenge_passage_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "30 minutes" in result[0].status_extended + + def test_zone_challenge_passage_too_short(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + challenge_ttl=300, # 5 minutes - too short + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured import ( + zone_challenge_passage_configured, + ) + + check = zone_challenge_passage_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "5 minutes" in result[0].status_extended + assert "recommended" in result[0].status_extended + + def test_zone_challenge_passage_too_long(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + challenge_ttl=3600, # 60 minutes - exceeds recommended + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured import ( + zone_challenge_passage_configured, + ) + + check = zone_challenge_passage_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "60 minutes" in result[0].status_extended + assert "recommended" in result[0].status_extended + + def test_zone_challenge_passage_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + challenge_ttl=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_challenge_passage_configured.zone_challenge_passage_configured import ( + zone_challenge_passage_configured, + ) + + check = zone_challenge_passage_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled_test.py b/tests/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled_test.py new file mode 100644 index 0000000000..a3dc02807e --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_development_mode_disabled/zone_development_mode_disabled_test.py @@ -0,0 +1,140 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_development_mode_disabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled import ( + zone_development_mode_disabled, + ) + + check = zone_development_mode_disabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_development_mode_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + development_mode="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled import ( + zone_development_mode_disabled, + ) + + check = zone_development_mode_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Development mode is disabled" in result[0].status_extended + + def test_zone_development_mode_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + development_mode="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled import ( + zone_development_mode_disabled, + ) + + check = zone_development_mode_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Development mode is enabled" in result[0].status_extended + assert "bypasses" in result[0].status_extended + + def test_zone_development_mode_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + development_mode=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_development_mode_disabled.zone_development_mode_disabled import ( + zone_development_mode_disabled, + ) + + check = zone_development_mode_disabled() + result = check.execute() + assert len(result) == 1 + # None or empty string should be treated as disabled (PASS) + assert result[0].status == "PASS" diff --git a/tests/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled_test.py similarity index 64% rename from tests/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled_test.py rename to tests/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled_test.py index 90d745932c..83690e8de9 100644 --- a/tests/providers/cloudflare/services/zones/zones_dnssec_enabled/zones_dnssec_enabled_test.py +++ b/tests/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled_test.py @@ -1,6 +1,6 @@ from unittest import mock -from prowler.providers.cloudflare.services.zones.zones_service import ( +from prowler.providers.cloudflare.services.zone.zone_service import ( CloudflareZone, CloudflareZoneSettings, ) @@ -11,10 +11,10 @@ from tests.providers.cloudflare.cloudflare_fixtures import ( ) -class Test_zones_dnssec_enabled: +class Test_zone_dnssec_enabled: def test_no_zones(self): - zones_client = mock.MagicMock - zones_client.zones = {} + zone_client = mock.MagicMock + zone_client.zones = {} with ( mock.patch( @@ -22,21 +22,21 @@ class Test_zones_dnssec_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import ( - zones_dnssec_enabled, + from prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled import ( + zone_dnssec_enabled, ) - check = zones_dnssec_enabled() + check = zone_dnssec_enabled() result = check.execute() assert len(result) == 0 def test_zone_dnssec_enabled(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -53,15 +53,15 @@ class Test_zones_dnssec_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import ( - zones_dnssec_enabled, + from prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled import ( + zone_dnssec_enabled, ) - check = zones_dnssec_enabled() + check = zone_dnssec_enabled() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -72,8 +72,8 @@ class Test_zones_dnssec_enabled: ) def test_zone_dnssec_disabled(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -90,15 +90,15 @@ class Test_zones_dnssec_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import ( - zones_dnssec_enabled, + from prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled import ( + zone_dnssec_enabled, ) - check = zones_dnssec_enabled() + check = zone_dnssec_enabled() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -110,8 +110,8 @@ class Test_zones_dnssec_enabled: ) def test_zone_dnssec_pending(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -128,15 +128,15 @@ class Test_zones_dnssec_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_dnssec_enabled.zones_dnssec_enabled import ( - zones_dnssec_enabled, + from prowler.providers.cloudflare.services.zone.zone_dnssec_enabled.zone_dnssec_enabled import ( + zone_dnssec_enabled, ) - check = zones_dnssec_enabled() + check = zone_dnssec_enabled() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled_test.py new file mode 100644 index 0000000000..dfe3aec33a --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled_test.py @@ -0,0 +1,139 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_email_obfuscation_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_email_obfuscation_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + email_obfuscation="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Email Obfuscation is enabled" in result[0].status_extended + + def test_zone_email_obfuscation_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + email_obfuscation="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Email Obfuscation is not enabled" in result[0].status_extended + + def test_zone_email_obfuscation_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + email_obfuscation=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Email Obfuscation is not enabled" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured_test.py b/tests/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured_test.py new file mode 100644 index 0000000000..0eddee2158 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_firewall_blocking_rules_configured/zone_firewall_blocking_rules_configured_test.py @@ -0,0 +1,250 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareFirewallRule, + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_firewall_blocking_rules_configured: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import ( + zone_firewall_blocking_rules_configured, + ) + + check = zone_firewall_blocking_rules_configured() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_blocking_rules(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + firewall_rules=[ + CloudflareFirewallRule( + id="rule-1", + name="Block bad actors", + action="block", + enabled=True, + ), + CloudflareFirewallRule( + id="rule-2", + name="Challenge suspicious", + action="challenge", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import ( + zone_firewall_blocking_rules_configured, + ) + + check = zone_firewall_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + "has firewall rules with blocking actions" in result[0].status_extended + ) + assert "2 rule(s)" in result[0].status_extended + + def test_zone_without_blocking_rules(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + firewall_rules=[ + CloudflareFirewallRule( + id="rule-1", + name="Log traffic", + action="log", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import ( + zone_firewall_blocking_rules_configured, + ) + + check = zone_firewall_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "has no firewall rules with blocking actions" + in result[0].status_extended + ) + + def test_zone_with_no_firewall_rules(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + firewall_rules=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import ( + zone_firewall_blocking_rules_configured, + ) + + check = zone_firewall_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "has no firewall rules with blocking actions" + in result[0].status_extended + ) + + def test_zone_with_js_challenge_rule(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + firewall_rules=[ + CloudflareFirewallRule( + id="rule-1", + name="JS Challenge", + action="js_challenge", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import ( + zone_firewall_blocking_rules_configured, + ) + + check = zone_firewall_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "has firewall rules with blocking actions" in result[0].status_extended + ) + + def test_zone_with_managed_challenge_rule(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + firewall_rules=[ + CloudflareFirewallRule( + id="rule-1", + name="Managed Challenge", + action="managed_challenge", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_firewall_blocking_rules_configured.zone_firewall_blocking_rules_configured import ( + zone_firewall_blocking_rules_configured, + ) + + check = zone_firewall_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "has firewall rules with blocking actions" in result[0].status_extended + ) diff --git a/tests/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled_test.py new file mode 100644 index 0000000000..1887cc8106 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_hotlink_protection_enabled/zone_hotlink_protection_enabled_test.py @@ -0,0 +1,138 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_hotlink_protection_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled import ( + zone_hotlink_protection_enabled, + ) + + check = zone_hotlink_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_hotlink_protection_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + hotlink_protection="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled import ( + zone_hotlink_protection_enabled, + ) + + check = zone_hotlink_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Hotlink Protection is enabled" in result[0].status_extended + + def test_zone_hotlink_protection_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + hotlink_protection="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled import ( + zone_hotlink_protection_enabled, + ) + + check = zone_hotlink_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Hotlink Protection is not enabled" in result[0].status_extended + + def test_zone_hotlink_protection_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + hotlink_protection=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_hotlink_protection_enabled.zone_hotlink_protection_enabled import ( + zone_hotlink_protection_enabled, + ) + + check = zone_hotlink_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled_test.py similarity index 68% rename from tests/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled_test.py rename to tests/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled_test.py index 847a74ee76..a8094c66ed 100644 --- a/tests/providers/cloudflare/services/zones/zones_hsts_enabled/zones_hsts_enabled_test.py +++ b/tests/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled_test.py @@ -1,6 +1,6 @@ from unittest import mock -from prowler.providers.cloudflare.services.zones.zones_service import ( +from prowler.providers.cloudflare.services.zone.zone_service import ( CloudflareZone, CloudflareZoneSettings, StrictTransportSecurity, @@ -12,10 +12,10 @@ from tests.providers.cloudflare.cloudflare_fixtures import ( ) -class Test_zones_hsts_enabled: +class Test_zone_hsts_enabled: def test_no_zones(self): - zones_client = mock.MagicMock - zones_client.zones = {} + zone_client = mock.MagicMock + zone_client.zones = {} with ( mock.patch( @@ -23,21 +23,21 @@ class Test_zones_hsts_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import ( - zones_hsts_enabled, + from prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled import ( + zone_hsts_enabled, ) - check = zones_hsts_enabled() + check = zone_hsts_enabled() result = check.execute() assert len(result) == 0 def test_zone_hsts_enabled_properly_configured(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -60,15 +60,15 @@ class Test_zones_hsts_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import ( - zones_hsts_enabled, + from prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled import ( + zone_hsts_enabled, ) - check = zones_hsts_enabled() + check = zone_hsts_enabled() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -77,8 +77,8 @@ class Test_zones_hsts_enabled: assert "HSTS is enabled" in result[0].status_extended def test_zone_hsts_disabled(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -98,23 +98,23 @@ class Test_zones_hsts_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import ( - zones_hsts_enabled, + from prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled import ( + zone_hsts_enabled, ) - check = zones_hsts_enabled() + check = zone_hsts_enabled() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" assert "HSTS is not enabled" in result[0].status_extended def test_zone_hsts_enabled_no_subdomains(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -136,23 +136,23 @@ class Test_zones_hsts_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import ( - zones_hsts_enabled, + from prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled import ( + zone_hsts_enabled, ) - check = zones_hsts_enabled() + check = zone_hsts_enabled() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" assert "does not include subdomains" in result[0].status_extended def test_zone_hsts_enabled_low_max_age(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -174,15 +174,15 @@ class Test_zones_hsts_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_hsts_enabled.zones_hsts_enabled import ( - zones_hsts_enabled, + from prowler.providers.cloudflare.services.zone.zone_hsts_enabled.zone_hsts_enabled import ( + zone_hsts_enabled, ) - check = zones_hsts_enabled() + check = zone_hsts_enabled() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled_test.py similarity index 61% rename from tests/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled_test.py rename to tests/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled_test.py index 10d0671cb1..1eb490ba1e 100644 --- a/tests/providers/cloudflare/services/zones/zones_https_redirect_enabled/zones_https_redirect_enabled_test.py +++ b/tests/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled_test.py @@ -1,6 +1,6 @@ from unittest import mock -from prowler.providers.cloudflare.services.zones.zones_service import ( +from prowler.providers.cloudflare.services.zone.zone_service import ( CloudflareZone, CloudflareZoneSettings, ) @@ -11,10 +11,10 @@ from tests.providers.cloudflare.cloudflare_fixtures import ( ) -class Test_zones_https_redirect_enabled: +class Test_zone_https_redirect_enabled: def test_no_zones(self): - zones_client = mock.MagicMock - zones_client.zones = {} + zone_client = mock.MagicMock + zone_client.zones = {} with ( mock.patch( @@ -22,21 +22,21 @@ class Test_zones_https_redirect_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import ( - zones_https_redirect_enabled, + from prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled import ( + zone_https_redirect_enabled, ) - check = zones_https_redirect_enabled() + check = zone_https_redirect_enabled() result = check.execute() assert len(result) == 0 def test_zone_https_redirect_enabled(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -54,15 +54,15 @@ class Test_zones_https_redirect_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import ( - zones_https_redirect_enabled, + from prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled import ( + zone_https_redirect_enabled, ) - check = zones_https_redirect_enabled() + check = zone_https_redirect_enabled() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -71,8 +71,8 @@ class Test_zones_https_redirect_enabled: assert "Always Use HTTPS is enabled" in result[0].status_extended def test_zone_https_redirect_disabled(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -90,15 +90,15 @@ class Test_zones_https_redirect_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import ( - zones_https_redirect_enabled, + from prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled import ( + zone_https_redirect_enabled, ) - check = zones_https_redirect_enabled() + check = zone_https_redirect_enabled() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -107,8 +107,8 @@ class Test_zones_https_redirect_enabled: assert "Always Use HTTPS is not enabled" in result[0].status_extended def test_zone_https_redirect_none(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -126,15 +126,15 @@ class Test_zones_https_redirect_enabled: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_https_redirect_enabled.zones_https_redirect_enabled import ( - zones_https_redirect_enabled, + from prowler.providers.cloudflare.services.zone.zone_https_redirect_enabled.zone_https_redirect_enabled import ( + zone_https_redirect_enabled, ) - check = zones_https_redirect_enabled() + check = zone_https_redirect_enabled() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled_test.py new file mode 100644 index 0000000000..3d8078f62f --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_ip_geolocation_enabled/zone_ip_geolocation_enabled_test.py @@ -0,0 +1,138 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_ip_geolocation_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled import ( + zone_ip_geolocation_enabled, + ) + + check = zone_ip_geolocation_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_ip_geolocation_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + ip_geolocation="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled import ( + zone_ip_geolocation_enabled, + ) + + check = zone_ip_geolocation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "IP Geolocation is enabled" in result[0].status_extended + + def test_zone_ip_geolocation_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + ip_geolocation="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled import ( + zone_ip_geolocation_enabled, + ) + + check = zone_ip_geolocation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "IP Geolocation is not enabled" in result[0].status_extended + + def test_zone_ip_geolocation_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + ip_geolocation=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_ip_geolocation_enabled.zone_ip_geolocation_enabled import ( + zone_ip_geolocation_enabled, + ) + + check = zone_ip_geolocation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure_test.py b/tests/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure_test.py similarity index 58% rename from tests/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure_test.py rename to tests/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure_test.py index 91dc78a2f7..9f6437f6b7 100644 --- a/tests/providers/cloudflare/services/zones/zones_min_tls_version_secure/zones_min_tls_version_secure_test.py +++ b/tests/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure_test.py @@ -1,6 +1,6 @@ from unittest import mock -from prowler.providers.cloudflare.services.zones.zones_service import ( +from prowler.providers.cloudflare.services.zone.zone_service import ( CloudflareZone, CloudflareZoneSettings, ) @@ -11,11 +11,11 @@ from tests.providers.cloudflare.cloudflare_fixtures import ( ) -class Test_zones_min_tls_version_secure: +class Test_zone_min_tls_version_secure: def test_no_zones(self): - zones_client = mock.MagicMock - zones_client.zones = {} - zones_client.audit_config = {"min_tls_version": "1.2"} + zone_client = mock.MagicMock + zone_client.zones = {} + zone_client.audit_config = {"min_tls_version": "1.2"} with ( mock.patch( @@ -23,21 +23,21 @@ class Test_zones_min_tls_version_secure: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import ( - zones_min_tls_version_secure, + from prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure import ( + zone_min_tls_version_secure, ) - check = zones_min_tls_version_secure() + check = zone_min_tls_version_secure() result = check.execute() assert len(result) == 0 def test_zone_tls_version_secure(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -48,7 +48,7 @@ class Test_zones_min_tls_version_secure: ), ) } - zones_client.audit_config = {"min_tls_version": "1.2"} + zone_client.audit_config = {"min_tls_version": "1.2"} with ( mock.patch( @@ -56,15 +56,15 @@ class Test_zones_min_tls_version_secure: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import ( - zones_min_tls_version_secure, + from prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure import ( + zone_min_tls_version_secure, ) - check = zones_min_tls_version_secure() + check = zone_min_tls_version_secure() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -73,8 +73,8 @@ class Test_zones_min_tls_version_secure: assert "1.2" in result[0].status_extended def test_zone_tls_version_1_3(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -85,7 +85,7 @@ class Test_zones_min_tls_version_secure: ), ) } - zones_client.audit_config = {"min_tls_version": "1.2"} + zone_client.audit_config = {"min_tls_version": "1.2"} with ( mock.patch( @@ -93,22 +93,22 @@ class Test_zones_min_tls_version_secure: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import ( - zones_min_tls_version_secure, + from prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure import ( + zone_min_tls_version_secure, ) - check = zones_min_tls_version_secure() + check = zone_min_tls_version_secure() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" def test_zone_tls_version_insecure(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -119,7 +119,7 @@ class Test_zones_min_tls_version_secure: ), ) } - zones_client.audit_config = {"min_tls_version": "1.2"} + zone_client.audit_config = {"min_tls_version": "1.2"} with ( mock.patch( @@ -127,15 +127,15 @@ class Test_zones_min_tls_version_secure: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import ( - zones_min_tls_version_secure, + from prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure import ( + zone_min_tls_version_secure, ) - check = zones_min_tls_version_secure() + check = zone_min_tls_version_secure() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -144,8 +144,8 @@ class Test_zones_min_tls_version_secure: assert "below the recommended" in result[0].status_extended def test_zone_tls_version_1_1(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -156,7 +156,7 @@ class Test_zones_min_tls_version_secure: ), ) } - zones_client.audit_config = {"min_tls_version": "1.2"} + zone_client.audit_config = {"min_tls_version": "1.2"} with ( mock.patch( @@ -164,15 +164,15 @@ class Test_zones_min_tls_version_secure: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_min_tls_version_secure.zones_min_tls_version_secure import ( - zones_min_tls_version_secure, + from prowler.providers.cloudflare.services.zone.zone_min_tls_version_secure.zone_min_tls_version_secure import ( + zone_min_tls_version_secure, ) - check = zones_min_tls_version_secure() + check = zone_min_tls_version_secure() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled_test.py new file mode 100644 index 0000000000..0a0d423b26 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_rate_limiting_enabled/zone_rate_limiting_enabled_test.py @@ -0,0 +1,193 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareRateLimitRule, + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_rate_limiting_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled import ( + zone_rate_limiting_enabled, + ) + + check = zone_rate_limiting_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_rate_limiting_rules(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + rate_limit_rules=[ + CloudflareRateLimitRule( + id="rule-1", + description="API Rate Limit", + action="block", + enabled=True, + expression="(http.request.uri.path contains '/api/')", + ) + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled import ( + zone_rate_limiting_enabled, + ) + + check = zone_rate_limiting_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Rate limiting is configured" in result[0].status_extended + + def test_zone_with_multiple_rate_limiting_rules(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + rate_limit_rules=[ + CloudflareRateLimitRule( + id="rule-1", + description="API Rate Limit", + enabled=True, + ), + CloudflareRateLimitRule( + id="rule-2", + description="Login Rate Limit", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled import ( + zone_rate_limiting_enabled, + ) + + check = zone_rate_limiting_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_zone_without_rate_limiting_rules(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + rate_limit_rules=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled import ( + zone_rate_limiting_enabled, + ) + + check = zone_rate_limiting_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No rate limiting rules configured" in result[0].status_extended + + def test_zone_with_disabled_rate_limiting_rules(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + rate_limit_rules=[ + CloudflareRateLimitRule( + id="rule-1", + description="Disabled Rule", + enabled=False, + ) + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_rate_limiting_enabled.zone_rate_limiting_enabled import ( + zone_rate_limiting_enabled, + ) + + check = zone_rate_limiting_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists_test.py new file mode 100644 index 0000000000..a2e7914d86 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists_test.py @@ -0,0 +1,323 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_zone_record_caa_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_caa_record_issue_tag(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issue "letsencrypt.org"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CAA record with certificate issuance restrictions exists for zone {ZONE_NAME}: {ZONE_NAME}." + ) + + def test_zone_with_multiple_caa_records(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issue "letsencrypt.org"', + ), + CloudflareDNSRecord( + id="record-2", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issuewild ";"', + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CAA record with certificate issuance restrictions exists for zone {ZONE_NAME}: {ZONE_NAME}, {ZONE_NAME}." + ) + + def test_zone_with_caa_record_only_iodef(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 iodef "mailto:security@example.com"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"CAA record exists for zone {ZONE_NAME} but does not specify authorized CAs with issue or issuewild tags: {ZONE_NAME}." + ) + + def test_zone_without_caa_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No CAA record found for zone {ZONE_NAME}." + ) + + def test_zone_with_caa_record_for_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="other.com", + type="CAA", + content='0 issue "letsencrypt.org"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No CAA record found for zone {ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists_test.py new file mode 100644 index 0000000000..6930551993 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists_test.py @@ -0,0 +1,646 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +# Valid DKIM public key for testing (real RSA 2048-bit key in DER SubjectPublicKeyInfo format) +# This is a complete valid RSA public key that can be loaded by cryptography library +VALID_DKIM_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp4czBy2GlDrezAtyoKtrqZYpTLMsuJz1HjV0wZ/yIpClhKp5f8xGlAJuxOjxWokz5SoyW/XpmUtIPkFYwj90jlvUVkFhh9Q81BlJ/0DmhNnmIOs9MnVzgnLiUfNv06NQeKg3d65reCWNjEyrb1fDP6U4ePKM/lunTQc5CbHEUnSnU43vXpUO8v1TYb6OGeAKhumfVSdXFBF905c43/sqkt2QeRMabIoWPkYlSI0KSV0qhNpcRtOdfntFSyPljwa7iNVLlV9AckdL4+abOiy8zuYW0GDF5/1Jgl/Xbdab2M70AXuFnYldq6EgkhvyyiGEm7/15H5STgKxp8idarb6XQIDAQAB" + + +class Test_zone_record_dkim_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_dkim_record_valid_key(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_multiple_dkim_records(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ), + CloudflareDNSRecord( + id="record-2", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"selector1._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}, selector1._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_revoked_key(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content="v=DKIM1; k=rsa; p=", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DKIM record exists for zone {ZONE_NAME} but has invalid or missing public key: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_invalid_key_not_real_public_key(self): + """Test that valid Base64 that is not a real public key fails.""" + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Valid Base64 but not a valid DER-encoded public key + content="v=DKIM1; k=rsa; p=SGVsbG9Xb3JsZFRoaXNJc05vdEFWYWxpZFB1YmxpY0tleUJ1dEl0SXNWYWxpZEJhc2U2NA==", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DKIM record exists for zone {ZONE_NAME} but has invalid or missing public key: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_invalid_base64(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Invalid Base64 - contains characters not valid in Base64 and is long enough + content="v=DKIM1; k=rsa; p=ThisIsNotValidBase64!!!@@@###$$$%%%^^^&&&***Because_It_Contains_Invalid_Characters_And_Is_Long_Enough_To_Pass_Length_Check", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DKIM record exists for zone {ZONE_NAME} but has invalid or missing public key: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_without_dkim_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DKIM record found for zone {ZONE_NAME}." + ) + + def test_zone_with_domainkey_but_not_dkim(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content="some other txt record", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DKIM record found for zone {ZONE_NAME}." + ) + + def test_zone_with_dkim_record_lowercase(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"default._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=dkim1; k=rsa; p={VALID_DKIM_KEY}", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: default._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="google._domainkey.other.com", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DKIM record found for zone {ZONE_NAME}." + ) + + def test_zone_with_dkim_record_quoted_content(self): + """Test that DKIM records with quoted content from Cloudflare API work.""" + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Cloudflare API returns content wrapped in quotes + content=f'"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_split_quoted_content(self): + """Test that long DKIM records split into multiple quoted strings work.""" + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + # Split the key to simulate how Cloudflare returns long TXT records + # The split happens in the middle of the p= value with '" "' between parts + key_part1 = VALID_DKIM_KEY[:200] + key_part2 = VALID_DKIM_KEY[200:] + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Cloudflare splits long TXT records with '" "' between parts + content=f'v=DKIM1; k=rsa; p={key_part1}" "{key_part2}', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists_test.py new file mode 100644 index 0000000000..d01fa36a9e --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists_test.py @@ -0,0 +1,417 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_zone_record_dmarc_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_dmarc_record_reject_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=DMARC1; p=reject; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record with enforcement policy p=reject exists for zone {ZONE_NAME}: _dmarc.{ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_quarantine_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record with enforcement policy p=quarantine exists for zone {ZONE_NAME}: _dmarc.{ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_none_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=DMARC1; p=none; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DMARC record exists for zone {ZONE_NAME} but uses monitoring-only policy p=none: _dmarc.{ZONE_NAME}." + ) + + def test_zone_without_dmarc_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DMARC record found for zone {ZONE_NAME}." + ) + + def test_zone_with_txt_record_but_not_dmarc(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="some other txt record", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DMARC record found for zone {ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_lowercase(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=dmarc1; p=reject; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record with enforcement policy p=reject exists for zone {ZONE_NAME}: _dmarc.{ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="_dmarc.other.com", + type="TXT", + content="v=DMARC1; p=reject", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DMARC record found for zone {ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists_test.py new file mode 100644 index 0000000000..8543f68954 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists_test.py @@ -0,0 +1,315 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_zone_record_spf_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_spf_record_strict_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="v=spf1 include:_spf.google.com -all", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SPF record with strict policy -all exists for zone {ZONE_NAME}: {ZONE_NAME}." + ) + + def test_zone_with_spf_record_permissive_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="v=spf1 include:_spf.google.com ~all", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SPF record exists for zone {ZONE_NAME} but does not use strict policy -all: {ZONE_NAME}." + ) + + def test_zone_without_spf_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SPF record found for zone {ZONE_NAME}." + ) + + def test_zone_with_txt_record_but_not_spf(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="google-site-verification=abc123", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SPF record found for zone {ZONE_NAME}." + ) + + def test_zone_with_spf_record_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="other.com", + type="TXT", + content="v=spf1 include:_spf.google.com -all", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SPF record found for zone {ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled_test.py b/tests/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled_test.py new file mode 100644 index 0000000000..e9cccd4992 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled_test.py @@ -0,0 +1,210 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_security_under_attack_disabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_under_attack_mode_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="under_attack", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Zone {ZONE_NAME} has Under Attack Mode enabled." + ) + + def test_zone_security_level_high(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="high", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Zone {ZONE_NAME} does not have Under Attack Mode enabled." + ) + + def test_zone_security_level_medium(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="medium", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_zone_security_level_low(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="low", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_zone_security_level_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/cloudflare/services/zones/zones_service_test.py b/tests/providers/cloudflare/services/zone/zone_service_test.py similarity index 95% rename from tests/providers/cloudflare/services/zones/zones_service_test.py rename to tests/providers/cloudflare/services/zone/zone_service_test.py index d26629aedd..bd34907149 100644 --- a/tests/providers/cloudflare/services/zones/zones_service_test.py +++ b/tests/providers/cloudflare/services/zone/zone_service_test.py @@ -1,4 +1,4 @@ -from prowler.providers.cloudflare.services.zones.zones_service import ( +from prowler.providers.cloudflare.services.zone.zone_service import ( CloudflareZone, CloudflareZoneSettings, StrictTransportSecurity, @@ -6,7 +6,7 @@ from prowler.providers.cloudflare.services.zones.zones_service import ( from tests.providers.cloudflare.cloudflare_fixtures import ZONE_ID, ZONE_NAME -class TestZonesService: +class TestZoneService: def test_cloudflare_zone_model(self): zone = CloudflareZone( id=ZONE_ID, diff --git a/tests/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict_test.py b/tests/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict_test.py similarity index 67% rename from tests/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict_test.py rename to tests/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict_test.py index 01aa66a274..6bbb4eacdb 100644 --- a/tests/providers/cloudflare/services/zones/zones_ssl_strict/zones_ssl_strict_test.py +++ b/tests/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict_test.py @@ -1,6 +1,6 @@ from unittest import mock -from prowler.providers.cloudflare.services.zones.zones_service import ( +from prowler.providers.cloudflare.services.zone.zone_service import ( CloudflareZone, CloudflareZoneSettings, ) @@ -11,10 +11,10 @@ from tests.providers.cloudflare.cloudflare_fixtures import ( ) -class Test_zones_ssl_strict: +class Test_zone_ssl_strict: def test_no_zones(self): - zones_client = mock.MagicMock - zones_client.zones = {} + zone_client = mock.MagicMock + zone_client.zones = {} with ( mock.patch( @@ -22,21 +22,21 @@ class Test_zones_ssl_strict: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import ( - zones_ssl_strict, + from prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict import ( + zone_ssl_strict, ) - check = zones_ssl_strict() + check = zone_ssl_strict() result = check.execute() assert len(result) == 0 def test_zone_ssl_strict_mode(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -54,15 +54,15 @@ class Test_zones_ssl_strict: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import ( - zones_ssl_strict, + from prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict import ( + zone_ssl_strict, ) - check = zones_ssl_strict() + check = zone_ssl_strict() result = check.execute() assert len(result) == 1 assert result[0].resource_id == ZONE_ID @@ -74,8 +74,8 @@ class Test_zones_ssl_strict: ) def test_zone_ssl_full_mode(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -93,15 +93,15 @@ class Test_zones_ssl_strict: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import ( - zones_ssl_strict, + from prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict import ( + zone_ssl_strict, ) - check = zones_ssl_strict() + check = zone_ssl_strict() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" @@ -111,8 +111,8 @@ class Test_zones_ssl_strict: ) def test_zone_ssl_flexible_mode(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -130,15 +130,15 @@ class Test_zones_ssl_strict: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import ( - zones_ssl_strict, + from prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict import ( + zone_ssl_strict, ) - check = zones_ssl_strict() + check = zone_ssl_strict() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" @@ -148,8 +148,8 @@ class Test_zones_ssl_strict: ) def test_zone_ssl_off_mode(self): - zones_client = mock.MagicMock - zones_client.zones = { + zone_client = mock.MagicMock + zone_client.zones = { ZONE_ID: CloudflareZone( id=ZONE_ID, name=ZONE_NAME, @@ -167,15 +167,15 @@ class Test_zones_ssl_strict: return_value=set_mocked_cloudflare_provider(), ), mock.patch( - "prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict.zones_client", - new=zones_client, + "prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict.zone_client", + new=zone_client, ), ): - from prowler.providers.cloudflare.services.zones.zones_ssl_strict.zones_ssl_strict import ( - zones_ssl_strict, + from prowler.providers.cloudflare.services.zone.zone_ssl_strict.zone_ssl_strict import ( + zone_ssl_strict, ) - check = zones_ssl_strict() + check = zone_ssl_strict() result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled_test.py new file mode 100644 index 0000000000..70a02b34fd --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled_test.py @@ -0,0 +1,173 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_tls_1_3_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_tls_1_3_enabled_on(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "TLS 1.3 is enabled" in result[0].status_extended + + def test_zone_tls_1_3_enabled_zrt(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3="zrt", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TLS 1.3 is enabled" in result[0].status_extended + + def test_zone_tls_1_3_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLS 1.3 is not enabled" in result[0].status_extended + + def test_zone_tls_1_3_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLS 1.3 is not enabled" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled_test.py new file mode 100644 index 0000000000..dfa804181c --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_universal_ssl_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled import ( + zone_universal_ssl_enabled, + ) + + check = zone_universal_ssl_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_universal_ssl_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + universal_ssl_enabled=True, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled import ( + zone_universal_ssl_enabled, + ) + + check = zone_universal_ssl_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Universal SSL is enabled for zone {ZONE_NAME}." + ) + + def test_zone_universal_ssl_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + universal_ssl_enabled=False, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled import ( + zone_universal_ssl_enabled, + ) + + check = zone_universal_ssl_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Universal SSL is not enabled for zone {ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py new file mode 100644 index 0000000000..0d7d91bc5f --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py @@ -0,0 +1,138 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_waf_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_waf_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + waf="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "WAF is enabled" in result[0].status_extended + + def test_zone_waf_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + waf="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "WAF is not enabled" in result[0].status_extended + + def test_zone_waf_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + waf=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled_test.py new file mode 100644 index 0000000000..b4848d1181 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_waf_owasp_ruleset_enabled/zone_waf_owasp_ruleset_enabled_test.py @@ -0,0 +1,254 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareWAFRuleset, + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_waf_owasp_ruleset_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import ( + zone_waf_owasp_ruleset_enabled, + ) + + check = zone_waf_owasp_ruleset_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_owasp_ruleset_by_name(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + waf_rulesets=[ + CloudflareWAFRuleset( + id="ruleset-1", + name="Cloudflare OWASP Core Ruleset", + kind="managed", + phase="http_request_firewall_managed", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import ( + zone_waf_owasp_ruleset_enabled, + ) + + check = zone_waf_owasp_ruleset_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "has OWASP managed WAF ruleset enabled" in result[0].status_extended + assert "Cloudflare OWASP Core Ruleset" in result[0].status_extended + + def test_zone_with_managed_ruleset_without_owasp_name(self): + """Test that a managed ruleset without 'owasp' in name does NOT pass.""" + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + waf_rulesets=[ + CloudflareWAFRuleset( + id="ruleset-1", + name="Managed Rules", + kind="managed", + phase="http_request_firewall_managed", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import ( + zone_waf_owasp_ruleset_enabled, + ) + + check = zone_waf_owasp_ruleset_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have OWASP managed WAF ruleset enabled" + in result[0].status_extended + ) + + def test_zone_without_owasp_ruleset(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + waf_rulesets=[ + CloudflareWAFRuleset( + id="ruleset-1", + name="Custom Rules", + kind="custom", + phase="http_request_firewall_custom", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import ( + zone_waf_owasp_ruleset_enabled, + ) + + check = zone_waf_owasp_ruleset_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have OWASP managed WAF ruleset enabled" + in result[0].status_extended + ) + + def test_zone_with_no_waf_rulesets(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + waf_rulesets=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import ( + zone_waf_owasp_ruleset_enabled, + ) + + check = zone_waf_owasp_ruleset_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have OWASP managed WAF ruleset enabled" + in result[0].status_extended + ) + + def test_zone_with_multiple_owasp_rulesets(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + waf_rulesets=[ + CloudflareWAFRuleset( + id="ruleset-1", + name="Cloudflare OWASP Core Ruleset", + kind="managed", + phase="http_request_firewall_managed", + enabled=True, + ), + CloudflareWAFRuleset( + id="ruleset-2", + name="Custom OWASP Rules", + kind="managed", + phase="http_request_firewall_managed", + enabled=True, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_owasp_ruleset_enabled.zone_waf_owasp_ruleset_enabled import ( + zone_waf_owasp_ruleset_enabled, + ) + + check = zone_waf_owasp_ruleset_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "Cloudflare OWASP Core Ruleset" in result[0].status_extended + assert "Custom OWASP Rules" in result[0].status_extended diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index 2f968f5555..99eb88d25b 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -64,6 +64,7 @@ def mock_api_client(GCPService, service, api_version, _): mock_api_access_policies_calls(client) mock_api_instance_group_managers_calls(client) mock_api_images_calls(client) + mock_api_snapshots_calls(client) return client @@ -770,6 +771,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): "automaticRestart": False, "preemptible": False, "provisioningModel": "STANDARD", + "onHostMaintenance": "MIGRATE", }, }, { @@ -801,6 +803,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): "automaticRestart": False, "preemptible": False, "provisioningModel": "STANDARD", + "onHostMaintenance": "TERMINATE", }, }, ] @@ -1344,3 +1347,8 @@ def mock_api_images_calls(client: MagicMock): return return_value client.images().getIamPolicy = mock_get_image_iam_policy + + +def mock_api_snapshots_calls(client: MagicMock): + client.snapshots().list().execute.return_value = {"items": []} + client.snapshots().list_next.return_value = None diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index e561674d1f..7d2bea9f88 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -92,6 +92,7 @@ class TestGCPProvider: "max_unused_account_days": 180, "storage_min_retention_days": 90, "mig_min_zones": 2, + "max_snapshot_age_days": 90, } @freeze_time(datetime.today()) diff --git a/tests/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate_test.py b/tests/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate_test.py new file mode 100644 index 0000000000..1f9c230046 --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_instance_on_host_maintenance_migrate/compute_instance_on_host_maintenance_migrate_test.py @@ -0,0 +1,515 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class TestComputeInstanceOnHostMaintenanceMigrate: + def test_compute_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + assert len(result) == 0 + + def test_instance_with_on_host_maintenance_migrate(self): + compute_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.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="test-instance", + id="1234567890", + zone="us-central1-a", + region="us-central1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[("disk1", False)], + automatic_restart=True, + project_id=GCP_PROJECT_ID, + on_host_maintenance="MIGRATE", + ) + ] + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance test-instance has On Host Maintenance set to MIGRATE." + ) + assert result[0].resource_id == compute_client.instances[0].id + assert result[0].resource_name == compute_client.instances[0].name + assert result[0].location == "us-central1" + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_with_on_host_maintenance_terminate(self): + compute_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.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="test-instance-terminate", + id="0987654321", + zone="us-west1-b", + region="us-west1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=False, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=False, + project_id=GCP_PROJECT_ID, + on_host_maintenance="TERMINATE", + ) + ] + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance test-instance-terminate has On Host Maintenance set to TERMINATE instead of MIGRATE." + ) + assert result[0].resource_id == compute_client.instances[0].id + assert result[0].resource_name == compute_client.instances[0].name + assert result[0].location == "us-west1" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_instances_mixed(self): + compute_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.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="compliant-instance", + id="1111111111", + zone="us-central1-a", + region="us-central1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=True, + project_id=GCP_PROJECT_ID, + on_host_maintenance="MIGRATE", + ), + Instance( + name="non-compliant-instance", + id="2222222222", + zone="us-west1-b", + region="us-west1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=False, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=False, + project_id=GCP_PROJECT_ID, + on_host_maintenance="TERMINATE", + ), + ] + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + + assert len(result) == 2 + + compliant_result = next(r for r in result if r.resource_id == "1111111111") + non_compliant_result = next( + r for r in result if r.resource_id == "2222222222" + ) + + assert compliant_result.status == "PASS" + assert ( + compliant_result.status_extended + == "VM Instance compliant-instance has On Host Maintenance set to MIGRATE." + ) + assert compliant_result.resource_id == "1111111111" + assert compliant_result.resource_name == "compliant-instance" + assert compliant_result.location == "us-central1" + assert compliant_result.project_id == GCP_PROJECT_ID + + assert non_compliant_result.status == "FAIL" + assert ( + non_compliant_result.status_extended + == "VM Instance non-compliant-instance has On Host Maintenance set to TERMINATE instead of MIGRATE." + ) + assert non_compliant_result.resource_id == "2222222222" + assert non_compliant_result.resource_name == "non-compliant-instance" + assert non_compliant_result.location == "us-west1" + assert non_compliant_result.project_id == GCP_PROJECT_ID + + def test_instance_with_default_on_host_maintenance(self): + compute_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.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="default-instance", + id="3333333333", + zone="us-east1-b", + region="us-east1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=True, + project_id=GCP_PROJECT_ID, + ) + ] + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance default-instance has On Host Maintenance set to MIGRATE." + ) + assert result[0].resource_id == "3333333333" + assert result[0].resource_name == "default-instance" + assert result[0].location == "us-east1" + assert result[0].project_id == GCP_PROJECT_ID + + def test_preemptible_instance_fails_with_explanation(self): + compute_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.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="preemptible-instance", + id="4444444444", + zone="us-central1-a", + region="us-central1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=False, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=False, + project_id=GCP_PROJECT_ID, + preemptible=True, + provisioning_model="STANDARD", + on_host_maintenance="TERMINATE", + ) + ] + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance preemptible-instance is a preemptible VM and has On Host Maintenance set to TERMINATE. Preemptible VMs cannot use MIGRATE and must always use TERMINATE. If high availability is required, consider using a non-preemptible VM instead." + ) + assert result[0].resource_id == "4444444444" + assert result[0].resource_name == "preemptible-instance" + + def test_spot_instance_fails_with_explanation(self): + compute_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.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="spot-instance", + id="5555555555", + zone="us-west1-a", + region="us-west1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=False, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=False, + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="SPOT", + on_host_maintenance="TERMINATE", + ) + ] + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance spot-instance is a Spot VM and has On Host Maintenance set to TERMINATE. Spot VMs cannot use MIGRATE and must always use TERMINATE. If high availability is required, consider using a non-preemptible VM instead." + ) + assert result[0].resource_id == "5555555555" + assert result[0].resource_name == "spot-instance" + + def test_mixed_with_preemptible_and_spot(self): + compute_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.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_on_host_maintenance_migrate.compute_instance_on_host_maintenance_migrate import ( + compute_instance_on_host_maintenance_migrate, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="regular-instance-pass", + id="6666666666", + zone="us-central1-a", + region="us-central1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=True, + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="STANDARD", + on_host_maintenance="MIGRATE", + ), + Instance( + name="preemptible-instance", + id="7777777777", + zone="us-west1-a", + region="us-west1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=False, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=False, + project_id=GCP_PROJECT_ID, + preemptible=True, + provisioning_model="STANDARD", + on_host_maintenance="TERMINATE", + ), + Instance( + name="spot-instance", + id="8888888888", + zone="us-east1-b", + region="us-east1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=False, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=False, + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="SPOT", + on_host_maintenance="TERMINATE", + ), + Instance( + name="regular-instance-fail", + id="9999999999", + zone="us-central1-b", + region="us-central1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=False, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + automatic_restart=False, + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="STANDARD", + on_host_maintenance="TERMINATE", + ), + ] + + check = compute_instance_on_host_maintenance_migrate() + result = check.execute() + + assert len(result) == 4 + + pass_result = next(r for r in result if r.resource_id == "6666666666") + preemptible_result = next( + r for r in result if r.resource_id == "7777777777" + ) + spot_result = next(r for r in result if r.resource_id == "8888888888") + fail_result = next(r for r in result if r.resource_id == "9999999999") + + assert pass_result.status == "PASS" + assert ( + pass_result.status_extended + == "VM Instance regular-instance-pass has On Host Maintenance set to MIGRATE." + ) + assert pass_result.resource_name == "regular-instance-pass" + + assert preemptible_result.status == "FAIL" + assert ( + preemptible_result.status_extended + == "VM Instance preemptible-instance is a preemptible VM and has On Host Maintenance set to TERMINATE. Preemptible VMs cannot use MIGRATE and must always use TERMINATE. If high availability is required, consider using a non-preemptible VM instead." + ) + assert preemptible_result.resource_name == "preemptible-instance" + + assert spot_result.status == "FAIL" + assert ( + spot_result.status_extended + == "VM Instance spot-instance is a Spot VM and has On Host Maintenance set to TERMINATE. Spot VMs cannot use MIGRATE and must always use TERMINATE. If high availability is required, consider using a non-preemptible VM instead." + ) + assert spot_result.resource_name == "spot-instance" + + assert fail_result.status == "FAIL" + assert ( + fail_result.status_extended + == "VM Instance regular-instance-fail has On Host Maintenance set to TERMINATE instead of MIGRATE." + ) + assert fail_result.resource_name == "regular-instance-fail" diff --git a/tests/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks_test.py b/tests/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks_test.py new file mode 100644 index 0000000000..96e9d2558a --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks_test.py @@ -0,0 +1,538 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + + +class TestComputeInstanceSuspendedWithoutPersistentDisks: + + def test_compute_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + assert len(result) == 0 + + def test_instance_running_with_disks(self): + compute_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.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + from prowler.providers.gcp.services.compute.compute_service import ( + Disk, + Instance, + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.region = GCP_US_CENTER1_LOCATION + + compute_client.instances = [ + Instance( + name="running-instance", + id="1234567890", + zone=f"{GCP_US_CENTER1_LOCATION}-a", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[ + {"email": "123-compute@developer.gserviceaccount.com"} + ], + ip_forward=False, + disks_encryption=[], + disks=[ + Disk( + name="boot-disk", + auto_delete=False, + boot=True, + encryption=False, + ), + Disk( + name="data-disk", + auto_delete=False, + boot=False, + encryption=False, + ), + ], + project_id=GCP_PROJECT_ID, + status="RUNNING", + ) + ] + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance running-instance is not suspended." + ) + assert result[0].resource_id == "1234567890" + assert result[0].resource_name == "running-instance" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_suspended_with_disks(self): + compute_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.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + from prowler.providers.gcp.services.compute.compute_service import ( + Disk, + Instance, + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.region = GCP_US_CENTER1_LOCATION + + compute_client.instances = [ + Instance( + name="suspended-instance", + id="1234567890", + zone=f"{GCP_US_CENTER1_LOCATION}-a", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[ + {"email": "123-compute@developer.gserviceaccount.com"} + ], + ip_forward=False, + disks_encryption=[], + disks=[ + Disk( + name="boot-disk", + auto_delete=False, + boot=True, + encryption=False, + ), + Disk( + name="data-disk", + auto_delete=False, + boot=False, + encryption=False, + ), + ], + project_id=GCP_PROJECT_ID, + status="SUSPENDED", + ) + ] + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance suspended-instance is suspended with 2 persistent disk(s) attached: boot-disk, data-disk." + ) + assert result[0].resource_id == "1234567890" + assert result[0].resource_name == "suspended-instance" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_suspending_with_disks(self): + compute_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.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + from prowler.providers.gcp.services.compute.compute_service import ( + Disk, + Instance, + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.region = GCP_US_CENTER1_LOCATION + + compute_client.instances = [ + Instance( + name="suspending-instance", + id="9876543210", + zone=f"{GCP_US_CENTER1_LOCATION}-b", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + disks=[ + Disk( + name="boot-disk", + auto_delete=True, + boot=True, + encryption=False, + ), + ], + project_id=GCP_PROJECT_ID, + status="SUSPENDING", + ) + ] + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance suspending-instance is suspending with 1 persistent disk(s) attached: boot-disk." + ) + assert result[0].resource_id == "9876543210" + assert result[0].resource_name == "suspending-instance" + + def test_instance_suspended_no_disks(self): + compute_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.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.region = GCP_US_CENTER1_LOCATION + + compute_client.instances = [ + Instance( + name="suspended-no-disks", + id="1111111111", + zone=f"{GCP_US_CENTER1_LOCATION}-a", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + disks=[], + project_id=GCP_PROJECT_ID, + status="SUSPENDED", + ) + ] + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance suspended-no-disks is suspended but has no persistent disks attached." + ) + assert result[0].resource_id == "1111111111" + assert result[0].resource_name == "suspended-no-disks" + + def test_instance_terminated_with_disks(self): + compute_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.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + from prowler.providers.gcp.services.compute.compute_service import ( + Disk, + Instance, + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.region = GCP_US_CENTER1_LOCATION + + compute_client.instances = [ + Instance( + name="terminated-instance", + id="2222222222", + zone=f"{GCP_US_CENTER1_LOCATION}-a", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + disks=[ + Disk( + name="boot-disk", + auto_delete=False, + boot=True, + encryption=False, + ), + ], + project_id=GCP_PROJECT_ID, + status="TERMINATED", + ) + ] + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance terminated-instance is not suspended." + ) + assert result[0].resource_id == "2222222222" + assert result[0].resource_name == "terminated-instance" + + def test_multiple_instances_mixed_results(self): + compute_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.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + from prowler.providers.gcp.services.compute.compute_service import ( + Disk, + Instance, + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.region = GCP_US_CENTER1_LOCATION + + compute_client.instances = [ + Instance( + name="running-instance", + id="1111111111", + zone=f"{GCP_US_CENTER1_LOCATION}-a", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + disks=[ + Disk( + name="boot-disk", + auto_delete=False, + boot=True, + encryption=False, + ), + ], + project_id=GCP_PROJECT_ID, + status="RUNNING", + ), + Instance( + name="suspended-with-disks", + id="2222222222", + zone=f"{GCP_US_CENTER1_LOCATION}-b", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + disks=[ + Disk( + name="persistent-disk", + auto_delete=True, + boot=True, + encryption=False, + ), + ], + project_id=GCP_PROJECT_ID, + status="SUSPENDED", + ), + Instance( + name="suspended-no-disks", + id="3333333333", + zone=f"{GCP_US_CENTER1_LOCATION}-c", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + disks=[], + project_id=GCP_PROJECT_ID, + status="SUSPENDED", + ), + ] + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + + assert len(result) == 3 + + # First instance - RUNNING with disks (PASS) + assert result[0].status == "PASS" + assert result[0].resource_name == "running-instance" + assert "is not suspended" in result[0].status_extended + + # Second instance - SUSPENDED with disks (FAIL) + assert result[1].status == "FAIL" + assert result[1].resource_name == "suspended-with-disks" + assert ( + "is suspended with 1 persistent disk(s) attached" + in result[1].status_extended + ) + + # Third instance - SUSPENDED without disks (PASS) + assert result[2].status == "PASS" + assert result[2].resource_name == "suspended-no-disks" + assert ( + "is suspended but has no persistent disks attached" + in result[2].status_extended + ) + + def test_instance_stopping_with_disks(self): + compute_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.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_suspended_without_persistent_disks.compute_instance_suspended_without_persistent_disks import ( + compute_instance_suspended_without_persistent_disks, + ) + from prowler.providers.gcp.services.compute.compute_service import ( + Disk, + Instance, + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.region = GCP_US_CENTER1_LOCATION + + compute_client.instances = [ + Instance( + name="stopping-instance", + id="4444444444", + zone=f"{GCP_US_CENTER1_LOCATION}-a", + region=GCP_US_CENTER1_LOCATION, + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + disks=[ + Disk( + name="boot-disk", + auto_delete=False, + boot=True, + encryption=False, + ), + ], + project_id=GCP_PROJECT_ID, + status="STOPPING", + ) + ] + + check = compute_instance_suspended_without_persistent_disks() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance stopping-instance is not suspended." + ) + assert result[0].resource_id == "4444444444" + assert result[0].resource_name == "stopping-instance" diff --git a/tests/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled_test.py b/tests/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled_test.py new file mode 100644 index 0000000000..8988ff7678 --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled_test.py @@ -0,0 +1,285 @@ +from re import search +from unittest import mock + +from prowler.providers.gcp.models import GCPProject +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class Test_compute_project_os_login_2fa_enabled: + def test_compute_no_project(self): + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.projects = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + assert len(result) == 0 + + def test_one_compliant_project_2fa_enabled(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=True, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"Project {project.id} has OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "test" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_non_compliant_project_2fa_disabled(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Project {project.id} does not have OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "test" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_non_compliant_project_oslogin_disabled_2fa_disabled(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=False, + enable_oslogin_2fa=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Project {project.id} does not have OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "test" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_compliant_project_empty_project_name(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=True, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"Project {project.id} has OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "GCP Project" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_non_compliant_project_empty_project_name(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Project {project.id} does not have OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "GCP Project" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID diff --git a/tests/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated_test.py b/tests/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated_test.py new file mode 100644 index 0000000000..9f0a246f1e --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_snapshot_not_outdated/compute_snapshot_not_outdated_test.py @@ -0,0 +1,324 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class TestComputeSnapshotNotOutdated: + def test_compute_no_snapshots(self): + compute_client = mock.MagicMock() + compute_client.snapshots = [] + compute_client.audit_config = {"max_snapshot_age_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated import ( + compute_snapshot_not_outdated, + ) + + check = compute_snapshot_not_outdated() + result = check.execute() + assert len(result) == 0 + + def test_snapshot_within_threshold(self): + compute_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.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_service import Snapshot + from prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated import ( + compute_snapshot_not_outdated, + ) + + creation_time = datetime.now(timezone.utc) - timedelta(days=30) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.audit_config = {"max_snapshot_age_days": 90} + compute_client.snapshots = [ + Snapshot( + name="test-snapshot-recent", + id="1234567890", + project_id=GCP_PROJECT_ID, + creation_timestamp=creation_time, + source_disk="test-disk", + source_disk_id="disk-123", + disk_size_gb=100, + storage_bytes=1073741824, + storage_locations=["us-central1"], + status="READY", + auto_created=False, + ) + ] + + check = compute_snapshot_not_outdated() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "30 days old" in result[0].status_extended + assert "within the 90 day threshold" in result[0].status_extended + assert result[0].resource_id == "1234567890" + assert result[0].resource_name == "test-snapshot-recent" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_snapshot_exceeds_threshold(self): + compute_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.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_service import Snapshot + from prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated import ( + compute_snapshot_not_outdated, + ) + + creation_time = datetime.now(timezone.utc) - timedelta(days=120) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.audit_config = {"max_snapshot_age_days": 90} + compute_client.snapshots = [ + Snapshot( + name="test-snapshot-old", + id="0987654321", + project_id=GCP_PROJECT_ID, + creation_timestamp=creation_time, + source_disk="test-disk", + source_disk_id="disk-456", + disk_size_gb=200, + storage_bytes=2147483648, + storage_locations=["us-east1"], + status="READY", + auto_created=False, + ) + ] + + check = compute_snapshot_not_outdated() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "120 days old" in result[0].status_extended + assert "exceeding the 90 day threshold" in result[0].status_extended + assert result[0].resource_id == "0987654321" + assert result[0].resource_name == "test-snapshot-old" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_snapshot_no_creation_timestamp(self): + compute_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.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_service import Snapshot + from prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated import ( + compute_snapshot_not_outdated, + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.audit_config = {"max_snapshot_age_days": 90} + compute_client.snapshots = [ + Snapshot( + name="test-snapshot-no-timestamp", + id="1111111111", + project_id=GCP_PROJECT_ID, + creation_timestamp=None, + source_disk="test-disk", + source_disk_id="disk-789", + disk_size_gb=50, + storage_bytes=536870912, + storage_locations=["eu-west1"], + status="READY", + auto_created=False, + ) + ] + + check = compute_snapshot_not_outdated() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "timestamp could not be retrieved" in result[0].status_extended + assert result[0].resource_id == "1111111111" + assert result[0].resource_name == "test-snapshot-no-timestamp" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_snapshots_mixed(self): + compute_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.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_service import Snapshot + from prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated import ( + compute_snapshot_not_outdated, + ) + + current_time = datetime.now(timezone.utc) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.audit_config = {"max_snapshot_age_days": 90} + compute_client.snapshots = [ + Snapshot( + name="recent-snapshot", + id="1111111111", + project_id=GCP_PROJECT_ID, + creation_timestamp=current_time - timedelta(days=10), + source_disk="disk-1", + status="READY", + ), + Snapshot( + name="old-snapshot", + id="2222222222", + project_id=GCP_PROJECT_ID, + creation_timestamp=current_time - timedelta(days=150), + source_disk="disk-2", + status="READY", + ), + Snapshot( + name="boundary-snapshot", + id="3333333333", + project_id=GCP_PROJECT_ID, + creation_timestamp=current_time - timedelta(days=91), + source_disk="disk-3", + status="READY", + ), + ] + + check = compute_snapshot_not_outdated() + result = check.execute() + + assert len(result) == 3 + + recent_result = next(r for r in result if r.resource_id == "1111111111") + old_result = next(r for r in result if r.resource_id == "2222222222") + boundary_result = next(r for r in result if r.resource_id == "3333333333") + + assert recent_result.status == "PASS" + assert recent_result.resource_name == "recent-snapshot" + + assert old_result.status == "FAIL" + assert old_result.resource_name == "old-snapshot" + + assert boundary_result.status == "FAIL" + assert boundary_result.resource_name == "boundary-snapshot" + + def test_custom_threshold(self): + compute_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.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_service import Snapshot + from prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated import ( + compute_snapshot_not_outdated, + ) + + creation_time = datetime.now(timezone.utc) - timedelta(days=45) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.audit_config = {"max_snapshot_age_days": 30} + compute_client.snapshots = [ + Snapshot( + name="test-snapshot-custom", + id="4444444444", + project_id=GCP_PROJECT_ID, + creation_timestamp=creation_time, + source_disk="test-disk", + status="READY", + ) + ] + + check = compute_snapshot_not_outdated() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "45 days old" in result[0].status_extended + assert "exceeding the 30 day threshold" in result[0].status_extended + + def test_default_threshold_when_not_configured(self): + compute_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.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_service import Snapshot + from prowler.providers.gcp.services.compute.compute_snapshot_not_outdated.compute_snapshot_not_outdated import ( + compute_snapshot_not_outdated, + ) + + creation_time = datetime.now(timezone.utc) - timedelta(days=85) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.audit_config = {} + compute_client.snapshots = [ + Snapshot( + name="test-snapshot-default", + id="5555555555", + project_id=GCP_PROJECT_ID, + creation_timestamp=creation_time, + source_disk="test-disk", + status="READY", + ) + ] + + check = compute_snapshot_not_outdated() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "85 days old" in result[0].status_extended + assert "within the 90 day threshold" in result[0].status_extended diff --git a/tests/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled_test.py b/tests/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled_test.py new file mode 100644 index 0000000000..89879ca235 --- /dev/null +++ b/tests/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled_test.py @@ -0,0 +1,119 @@ +from unittest import mock + +from prowler.providers.m365.services.defender.defender_service import ( + TeamsProtectionPolicy, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_defender_zap_for_teams_enabled: + def test_zap_enabled_pass(self): + """Test PASS scenario when ZAP is enabled for Teams.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.teams_protection_policy = TeamsProtectionPolicy( + identity="Teams Protection Policy", + zap_enabled=True, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_zap_for_teams_enabled.defender_zap_for_teams_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_zap_for_teams_enabled.defender_zap_for_teams_enabled import ( + defender_zap_for_teams_enabled, + ) + + check = defender_zap_for_teams_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Zero-hour auto purge (ZAP) is enabled for Microsoft Teams." + ) + assert result[0].resource == defender_client.teams_protection_policy.dict() + assert result[0].resource_name == "Teams Protection Policy" + assert result[0].resource_id == "teamsProtectionPolicy" + assert result[0].location == "global" + + def test_zap_disabled_fail(self): + """Test FAIL scenario when ZAP is disabled for Teams.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.teams_protection_policy = TeamsProtectionPolicy( + identity="Teams Protection Policy", + zap_enabled=False, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_zap_for_teams_enabled.defender_zap_for_teams_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_zap_for_teams_enabled.defender_zap_for_teams_enabled import ( + defender_zap_for_teams_enabled, + ) + + check = defender_zap_for_teams_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Zero-hour auto purge (ZAP) is not enabled for Microsoft Teams." + ) + assert result[0].resource == defender_client.teams_protection_policy.dict() + assert result[0].resource_name == "Teams Protection Policy" + assert result[0].resource_id == "teamsProtectionPolicy" + assert result[0].location == "global" + + def test_teams_protection_policy_none(self): + """Test scenario when Teams protection policy is not available.""" + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.teams_protection_policy = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_zap_for_teams_enabled.defender_zap_for_teams_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_zap_for_teams_enabled.defender_zap_for_teams_enabled import ( + defender_zap_for_teams_enabled, + ) + + check = defender_zap_for_teams_enabled() + result = check.execute() + + assert len(result) == 0 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 9848507b93..b56939c2cb 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -401,10 +401,14 @@ class Test_Entra_Service: value=[ SimpleNamespace(id="user-1", is_mfa_capable=True), SimpleNamespace(id="user-6", is_mfa_capable=True), - ] + ], + odata_next_link=None, ) registration_details_builder = SimpleNamespace( - get=AsyncMock(return_value=registration_details_response) + get=AsyncMock(return_value=registration_details_response), + with_url=MagicMock( + return_value=SimpleNamespace(get=AsyncMock(return_value=None)) + ), ) reports_builder = SimpleNamespace( authentication_methods=SimpleNamespace( @@ -429,3 +433,44 @@ class Test_Entra_Service: assert users["user-6"].account_enabled is False assert users["user-1"].is_mfa_capable is True assert users["user-2"].is_mfa_capable is False + + def test__get_user_registration_details_handles_pagination(self): + entra_service = Entra.__new__(Entra) + + registration_response_page_one = SimpleNamespace( + value=[ + SimpleNamespace(id="user-1", is_mfa_capable=True), + ], + odata_next_link="next-link", + ) + registration_response_page_two = SimpleNamespace( + value=[ + SimpleNamespace(id="user-2", is_mfa_capable=False), + ], + odata_next_link=None, + ) + + registration_builder_next = SimpleNamespace( + get=AsyncMock(return_value=registration_response_page_two) + ) + registration_builder = SimpleNamespace( + get=AsyncMock(return_value=registration_response_page_one), + with_url=MagicMock(return_value=registration_builder_next), + ) + + entra_service.client = SimpleNamespace( + reports=SimpleNamespace( + authentication_methods=SimpleNamespace( + user_registration_details=registration_builder + ) + ) + ) + + registration_details = asyncio.run( + entra_service._get_user_registration_details() + ) + + assert registration_details == {"user-1": True, "user-2": False} + registration_builder.get.assert_awaited() + registration_builder.with_url.assert_called_once_with("next-link") + registration_builder_next.get.assert_awaited() diff --git a/tests/providers/m365/services/exchange/exchange_service_test.py b/tests/providers/m365/services/exchange/exchange_service_test.py index 9a93e616a5..3f0e62cc3b 100644 --- a/tests/providers/m365/services/exchange/exchange_service_test.py +++ b/tests/providers/m365/services/exchange/exchange_service_test.py @@ -9,6 +9,7 @@ from prowler.providers.m365.services.exchange.exchange_service import ( MailboxAuditProperties, Organization, RoleAssignmentPolicy, + SharedMailbox, TransportConfig, TransportRule, ) @@ -143,6 +144,23 @@ def mock_exchange_get_mailbox_audit_properties(_): ] +def mock_exchange_get_shared_mailboxes(_): + return [ + SharedMailbox( + name="Support Mailbox", + user_principal_name="support@contoso.com", + external_directory_object_id="12345678-1234-1234-1234-123456789012", + identity="support@contoso.com", + ), + SharedMailbox( + name="Info Mailbox", + user_principal_name="info@contoso.com", + external_directory_object_id="87654321-4321-4321-4321-210987654321", + identity="info@contoso.com", + ), + ] + + class Test_Exchange_Service: def test_get_client(self): with ( @@ -429,3 +447,37 @@ class Test_Exchange_Service: assert role_assignment_policies[1].assigned_roles == [] exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.services.exchange.exchange_service.Exchange._get_shared_mailboxes", + new=mock_exchange_get_shared_mailboxes, + ) + def test_get_shared_mailboxes(self): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + shared_mailboxes = exchange_client.shared_mailboxes + assert len(shared_mailboxes) == 2 + assert shared_mailboxes[0].name == "Support Mailbox" + assert shared_mailboxes[0].user_principal_name == "support@contoso.com" + assert ( + shared_mailboxes[0].external_directory_object_id + == "12345678-1234-1234-1234-123456789012" + ) + assert shared_mailboxes[0].identity == "support@contoso.com" + assert shared_mailboxes[1].name == "Info Mailbox" + assert shared_mailboxes[1].user_principal_name == "info@contoso.com" + assert ( + shared_mailboxes[1].external_directory_object_id + == "87654321-4321-4321-4321-210987654321" + ) + assert shared_mailboxes[1].identity == "info@contoso.com" + + exchange_client.powershell.close() diff --git a/tests/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled_test.py b/tests/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled_test.py new file mode 100644 index 0000000000..7cd9191049 --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled_test.py @@ -0,0 +1,317 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_exchange_shared_mailbox_sign_in_disabled: + def test_no_shared_mailboxes(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.shared_mailboxes = [] + + entra_client = mock.MagicMock() + entra_client.users = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled import ( + exchange_shared_mailbox_sign_in_disabled, + ) + + check = exchange_shared_mailbox_sign_in_disabled() + result = check.execute() + assert len(result) == 0 + + def test_sign_in_disabled(self): + """PASS: Shared mailbox has sign-in blocked (AccountEnabled = False in Entra ID).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service import User + from prowler.providers.m365.services.exchange.exchange_service import ( + SharedMailbox, + ) + from prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled import ( + exchange_shared_mailbox_sign_in_disabled, + ) + + shared_mailbox = SharedMailbox( + name="Support Mailbox", + user_principal_name="support@contoso.com", + external_directory_object_id="12345678-1234-1234-1234-123456789012", + identity="support@contoso.com", + ) + exchange_client.shared_mailboxes = [shared_mailbox] + + entra_user = User( + id="12345678-1234-1234-1234-123456789012", + name="Support Mailbox", + on_premises_sync_enabled=False, + account_enabled=False, + ) + entra_client = mock.MagicMock() + entra_client.users = { + "12345678-1234-1234-1234-123456789012": entra_user, + } + + with mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.entra_client", + new=entra_client, + ): + check = exchange_shared_mailbox_sign_in_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Shared mailbox support@contoso.com has sign-in blocked." + ) + assert result[0].resource_name == "Support Mailbox" + assert result[0].resource_id == "12345678-1234-1234-1234-123456789012" + assert result[0].location == "global" + + def test_sign_in_enabled(self): + """FAIL: Shared mailbox has sign-in enabled (AccountEnabled = True in Entra ID).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service import User + from prowler.providers.m365.services.exchange.exchange_service import ( + SharedMailbox, + ) + from prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled import ( + exchange_shared_mailbox_sign_in_disabled, + ) + + shared_mailbox = SharedMailbox( + name="Info Mailbox", + user_principal_name="info@contoso.com", + external_directory_object_id="87654321-4321-4321-4321-210987654321", + identity="info@contoso.com", + ) + exchange_client.shared_mailboxes = [shared_mailbox] + + entra_user = User( + id="87654321-4321-4321-4321-210987654321", + name="Info Mailbox", + on_premises_sync_enabled=False, + account_enabled=True, + ) + entra_client = mock.MagicMock() + entra_client.users = { + "87654321-4321-4321-4321-210987654321": entra_user, + } + + with mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.entra_client", + new=entra_client, + ): + check = exchange_shared_mailbox_sign_in_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Shared mailbox info@contoso.com has sign-in enabled." + ) + assert result[0].resource_name == "Info Mailbox" + assert result[0].resource_id == "87654321-4321-4321-4321-210987654321" + assert result[0].location == "global" + + def test_user_not_found_in_entra(self): + """FAIL: Shared mailbox not found in Entra ID for verification.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_service import ( + SharedMailbox, + ) + from prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled import ( + exchange_shared_mailbox_sign_in_disabled, + ) + + shared_mailbox = SharedMailbox( + name="Orphan Mailbox", + user_principal_name="orphan@contoso.com", + external_directory_object_id="00000000-0000-0000-0000-000000000000", + identity="orphan@contoso.com", + ) + exchange_client.shared_mailboxes = [shared_mailbox] + + entra_client = mock.MagicMock() + entra_client.users = {} + + with mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.entra_client", + new=entra_client, + ): + check = exchange_shared_mailbox_sign_in_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Shared mailbox orphan@contoso.com could not be found in Entra ID for verification." + ) + assert result[0].resource_name == "Orphan Mailbox" + assert result[0].resource_id == "00000000-0000-0000-0000-000000000000" + assert result[0].location == "global" + + def test_multiple_shared_mailboxes_mixed_status(self): + """Test multiple shared mailboxes with different sign-in statuses.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service import User + from prowler.providers.m365.services.exchange.exchange_service import ( + SharedMailbox, + ) + from prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled import ( + exchange_shared_mailbox_sign_in_disabled, + ) + + mailbox_disabled = SharedMailbox( + name="Secure Mailbox", + user_principal_name="secure@contoso.com", + external_directory_object_id="11111111-1111-1111-1111-111111111111", + identity="secure@contoso.com", + ) + mailbox_enabled = SharedMailbox( + name="Insecure Mailbox", + user_principal_name="insecure@contoso.com", + external_directory_object_id="22222222-2222-2222-2222-222222222222", + identity="insecure@contoso.com", + ) + mailbox_orphan = SharedMailbox( + name="Unknown Mailbox", + user_principal_name="unknown@contoso.com", + external_directory_object_id="33333333-3333-3333-3333-333333333333", + identity="unknown@contoso.com", + ) + + exchange_client.shared_mailboxes = [ + mailbox_disabled, + mailbox_enabled, + mailbox_orphan, + ] + + user_disabled = User( + id="11111111-1111-1111-1111-111111111111", + name="Secure Mailbox", + on_premises_sync_enabled=False, + account_enabled=False, + ) + user_enabled = User( + id="22222222-2222-2222-2222-222222222222", + name="Insecure Mailbox", + on_premises_sync_enabled=False, + account_enabled=True, + ) + + entra_client = mock.MagicMock() + entra_client.users = { + "11111111-1111-1111-1111-111111111111": user_disabled, + "22222222-2222-2222-2222-222222222222": user_enabled, + } + + with mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.entra_client", + new=entra_client, + ): + check = exchange_shared_mailbox_sign_in_disabled() + result = check.execute() + + assert len(result) == 3 + + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Shared mailbox secure@contoso.com has sign-in blocked." + ) + + assert result[1].status == "FAIL" + assert ( + result[1].status_extended + == "Shared mailbox insecure@contoso.com has sign-in enabled." + ) + + assert result[2].status == "FAIL" + assert ( + result[2].status_extended + == "Shared mailbox unknown@contoso.com could not be found in Entra ID for verification." + ) diff --git a/ui/.eslintignore b/ui/.eslintignore deleted file mode 100644 index bf8a7b18e9..0000000000 --- a/ui/.eslintignore +++ /dev/null @@ -1,21 +0,0 @@ -.now/* -*.css -.changeset -dist -esm/* -public/* -tests/* -scripts/* -*.config.js -.DS_Store -node_modules -coverage -.next -build -next-env.d.ts -!.commitlintrc.cjs -!.lintstagedrc.cjs -!jest.config.js -!plopfile.js -!react-shim.js -!tsup.config.ts \ No newline at end of file diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs deleted file mode 100644 index 01d6afe1ac..0000000000 --- a/ui/.eslintrc.cjs +++ /dev/null @@ -1,53 +0,0 @@ -module.exports = { - env: { - node: true, - es2021: true, - }, - parser: "@typescript-eslint/parser", - plugins: ["prettier", "@typescript-eslint", "simple-import-sort", "jsx-a11y"], - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:security/recommended-legacy", - "plugin:jsx-a11y/recommended", - "eslint-config-prettier", - "prettier", - "next/core-web-vitals", - ], - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - ecmaFeatures: { - jsx: true, - }, - }, - rules: { - // console.error are allowed but no console.log - "no-console": ["error", { allow: ["error"] }], - eqeqeq: 2, - quotes: ["error", "double", "avoid-escape"], - "@typescript-eslint/no-explicit-any": "off", - "security/detect-object-injection": "off", - "prettier/prettier": [ - "error", - { - endOfLine: "auto", - tabWidth: 2, - useTabs: false, - }, - ], - "eol-last": ["error", "always"], - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "jsx-a11y/anchor-is-valid": [ - "error", - { - components: ["Link"], - specialLink: ["hrefLeft", "hrefRight"], - aspects: ["invalidHref", "preferButton"], - }, - ], - "jsx-a11y/alt-text": "error", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - }, -}; diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit index 9e12cf3a85..db9b378299 100755 --- a/ui/.husky/pre-commit +++ b/ui/.husky/pre-commit @@ -37,8 +37,8 @@ CODE_REVIEW_ENABLED=$(echo "$CODE_REVIEW_ENABLED" | tr '[:upper:]' '[:lower:]') echo -e "${BLUE}ℹ️ Code Review Status: ${CODE_REVIEW_ENABLED}${NC}" echo "" -# Get staged files (what will be committed) -STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(tsx?|jsx?)$' || true) +# Get staged files in the UI folder only (what will be committed) +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- 'ui/**' | grep -E '\.(tsx?|jsx?)$' || true) if [ "$CODE_REVIEW_ENABLED" = "true" ]; then if [ -z "$STAGED_FILES" ]; then @@ -135,7 +135,14 @@ else echo "" fi -# Run healthcheck (typecheck and lint check) +# Check if there are any UI files to validate +if [ -z "$STAGED_FILES" ] && [ "$CODE_REVIEW_ENABLED" = "true" ]; then + echo -e "${YELLOW}⏭️ No UI files to validate, skipping healthcheck${NC}" + echo "" + exit 0 +fi + +# Run healthcheck (typecheck and lint check) only if there are UI changes echo -e "${BLUE}πŸ₯ Running healthcheck...${NC}" echo "" diff --git a/ui/.nvmrc b/ui/.nvmrc index b009dfb9d9..3fe3b1570a 100644 --- a/ui/.nvmrc +++ b/ui/.nvmrc @@ -1 +1 @@ -lts/* +24.13.0 diff --git a/ui/AGENTS.md b/ui/AGENTS.md index e8b2377944..edb3f53447 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -18,10 +18,16 @@ 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-15` | | 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` | +| Review changelog format and conventions | `prowler-changelog` | +| 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 with Prowler UI test helpers/pages | `prowler-test-ui` | diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 4aa46e86cb..b957c2961a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,21 +2,48 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.17.0] (Prowler UNRELEASED) - -### πŸš€ Added - -- Add search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) -- New findings table UI with new design system components, improved filtering UX, and enhanced table interactions [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699) -- Add gradient background to Risk Plot for visual risk context [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) +## [1.18.0] (Prowler UNRELEASED) ### πŸ”„ Changed +- Restyle resources view with improved resource detail drawer [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) +- Launch Scan page now displays all providers without pagination limit [(#9700)](https://github.com/prowler-cloud/prowler/pull/9700) +- Upgrade Next.js from 15.5.9 to 16.1.3 with ESLint 9 flat config migration [(#9826)](https://github.com/prowler-cloud/prowler/pull/9826) + +--- + +## [1.17.0] (Prowler v5.17.0) + +### πŸš€ Added + +- Search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) +- New findings table UI with new design system components, improved filtering UX, and enhanced table interactions [(#9699)](https://github.com/prowler-cloud/prowler/pull/9699) +- Gradient background to Risk Plot for visual risk context [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) +- ThreatScore pillar breakdown to Compliance Summary page and detail view [(#9773)](https://github.com/prowler-cloud/prowler/pull/9773) +- Provider and Group filters to Resources page [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) +- Compliance Watchlist component in Overview page [(#9786)](https://github.com/prowler-cloud/prowler/pull/9786) +- Add a new main section for list Attack Paths scans, execute queries on them and view their result as a graph [(#9805)](https://github.com/prowler-cloud/prowler/pull/9805) +- Resource group label filter to Resources page [(#9820)](https://github.com/prowler-cloud/prowler/pull/9820) + +### πŸ”„ Changed + +- Refactor Lighthouse AI MCP tool filtering from blacklist to whitelist approach for improved security [(#9802)](https://github.com/prowler-cloud/prowler/pull/9802) - Refactor ScatterPlot as reusable generic component with TypeScript generics [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) +- Rename resource_group filter to group in Resources page and Overview cards [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) +- Update Resources filters to use `__in` format for multi-select support [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) - Swap Risk Plot axes: X = Fail Findings, Y = Prowler ThreatScore [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Remove duplicate scan_id filter badge from Findings page [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) - Remove unused hasDots prop from RadialChart component [(#9664)](https://github.com/prowler-cloud/prowler/pull/9664) +### 🐞 Fixed + +- OCI update credentials form failing silently due to missing provider UID [(#9746)](https://github.com/prowler-cloud/prowler/pull/9746) + +### πŸ” Security + +- Node.js from 20.x to 24.13.0 LTS, patching 8 CVEs from January 2026 security advisory [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) +- langchain from 1.1.5 to 1.2.10 and @langchain/core from 1.1.8 to 1.1.15 [(#9797)](https://github.com/prowler-cloud/prowler/pull/9797) + --- ## [1.16.1] (Prowler v5.16.1) @@ -67,6 +94,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Navigation progress bar for page transitions using Next.js `onRouterTransitionStart` [(#9465)](https://github.com/prowler-cloud/prowler/pull/9465) - Findings Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405) - Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412) +- Resource Inventory component to Overview page [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) - Add Alibaba Cloud provider [(#9501)](https://github.com/prowler-cloud/prowler/pull/9501) ### πŸ”„ Changed @@ -112,6 +140,7 @@ All notable changes to the **Prowler UI** are documented in this file. - PDF reporting for NIS2 compliance framework [(#9170)](https://github.com/prowler-cloud/prowler/pull/9170) - External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151) - New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234) +- Attack Paths feature with query execution and graph visualization [(#PROWLER-383)](https://github.com/prowler-cloud/prowler/pull/9270) - Use branch name as region for IaC findings [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296) ### πŸ”„ Changed diff --git a/ui/Dockerfile b/ui/Dockerfile index 15e6a71c5e..bad77dae1f 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS base +FROM node:24.13.0-alpine AS base LABEL maintainer="https://github.com/prowler-cloud" diff --git a/ui/actions/api-keys/api-keys.ts b/ui/actions/api-keys/api-keys.ts index 966cc2114b..2e62932474 100644 --- a/ui/actions/api-keys/api-keys.ts +++ b/ui/actions/api-keys/api-keys.ts @@ -89,7 +89,7 @@ export const createApiKey = async ( const data = (await handleApiResponse(response)) as CreateApiKeyResponse; // Revalidate the api-keys list - revalidateTag("api-keys"); + revalidateTag("api-keys", "max"); return { data }; } catch (error) { @@ -138,7 +138,7 @@ export const updateApiKey = async ( const data = (await handleApiResponse(response)) as SingleApiKeyResponse; // Revalidate the api-keys list - revalidateTag("api-keys"); + revalidateTag("api-keys", "max"); return { data }; } catch (error) { @@ -171,7 +171,7 @@ export const revokeApiKey = async ( } // Revalidate the api-keys list - revalidateTag("api-keys"); + revalidateTag("api-keys", "max"); return { success: true }; } catch (error) { diff --git a/ui/actions/attack-paths/index.ts b/ui/actions/attack-paths/index.ts new file mode 100644 index 0000000000..0120dceb86 --- /dev/null +++ b/ui/actions/attack-paths/index.ts @@ -0,0 +1,4 @@ +export * from "./queries"; +export * from "./queries.adapter"; +export * from "./scans"; +export * from "./scans.adapter"; diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts new file mode 100644 index 0000000000..fd256739e1 --- /dev/null +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -0,0 +1,55 @@ +import { MetaDataProps } from "@/types"; +import { + AttackPathQueriesResponse, + AttackPathQuery, +} from "@/types/attack-paths"; + +/** + * Adapts raw query API responses to enriched domain models + * - Enriches queries with metadata and computed properties + * - Co-locates related data for better performance + * - Preserves pagination metadata for list operations + * + * Uses plugin architecture for extensibility: + * - Handles query-specific response transformation + * - Can be composed with backend service plugins + * - Maintains separation of concerns between API layer and business logic + */ + +/** + * Adapt attack path queries response with enriched data + * + * @param response - Raw API response from attack-paths-scans/{id}/queries endpoint + * @returns Enriched queries data with metadata + */ +export function adaptAttackPathQueriesResponse( + response: AttackPathQueriesResponse | undefined, +): { + data: AttackPathQuery[]; + metadata?: MetaDataProps; +} { + if (!response?.data) { + return { data: [] }; + } + + // Enrich query data with computed properties + const enrichedData = response.data.map((query) => ({ + ...query, + // Can add computed properties here, e.g.: + // parameterCount: query.attributes.parameters.length, + // requiredParameters: query.attributes.parameters.filter(p => p.required), + // hasParameters: query.attributes.parameters.length > 0, + })); + + const metadata: MetaDataProps | undefined = { + pagination: { + page: 1, + pages: 1, + count: enrichedData.length, + itemsPerPage: [10, 25, 50, 100], + }, + version: "1.0", + }; + + return { data: enrichedData, metadata }; +} diff --git a/ui/actions/attack-paths/queries.ts b/ui/actions/attack-paths/queries.ts new file mode 100644 index 0000000000..0332e6ec22 --- /dev/null +++ b/ui/actions/attack-paths/queries.ts @@ -0,0 +1,97 @@ +"use server"; + +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { + AttackPathQueriesResponse, + AttackPathQuery, + AttackPathQueryResult, + ExecuteQueryRequest, +} from "@/types/attack-paths"; + +import { adaptAttackPathQueriesResponse } from "./queries.adapter"; + +// Validation schema for UUID - RFC 9562/4122 compliant +const UUIDSchema = z.uuid(); + +/** + * Fetch available queries for a specific attack path scan + */ +export const getAvailableQueries = async ( + scanId: string, +): Promise<{ data: AttackPathQuery[] } | undefined> => { + // Validate scanId is a valid UUID format to prevent request forgery + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries`, + { + headers, + method: "GET", + }, + ); + + const apiResponse = (await handleApiResponse( + response, + )) as AttackPathQueriesResponse; + const adaptedData = adaptAttackPathQueriesResponse(apiResponse); + + return { data: adaptedData.data }; + } catch (error) { + console.error("Error fetching available queries for scan:", error); + return undefined; + } +}; + +/** + * Execute a query on an attack path scan + */ +export const executeQuery = async ( + scanId: string, + queryId: string, + parameters?: Record, +): Promise => { + // Validate scanId is a valid UUID format to prevent request forgery + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: true }); + + const requestBody: ExecuteQueryRequest = { + data: { + type: "attack-paths-query-run-requests", + attributes: { + id: queryId, + ...(parameters && { parameters }), + }, + }, + }; + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/run`, + { + headers, + method: "POST", + body: JSON.stringify(requestBody), + }, + ); + + return handleApiResponse(response); + } catch (error) { + console.error("Error executing query on scan:", error); + return undefined; + } +}; diff --git a/ui/actions/attack-paths/query-result.adapter.ts b/ui/actions/attack-paths/query-result.adapter.ts new file mode 100644 index 0000000000..65b33843af --- /dev/null +++ b/ui/actions/attack-paths/query-result.adapter.ts @@ -0,0 +1,164 @@ +import { + AttackPathGraphData, + GraphEdge, + GraphNodeProperties, + GraphNodePropertyValue, + GraphRelationship, +} from "@/types/attack-paths"; + +/** + * Normalizes property values to ensure they are primitives + * Arrays are converted to comma-separated strings + * + * @param value - The property value to normalize + * @returns Normalized primitive value + */ +function normalizePropertyValue( + value: + | GraphNodePropertyValue + | GraphNodePropertyValue[] + | Record, +): string | number | boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + // Convert arrays to comma-separated strings + return value.join(", "); + } + + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + // For any other type, convert to string + return String(value); +} + +/** + * Normalizes all properties in an object to ensure they are primitives + * + * @param properties - The properties object to normalize + * @returns Normalized properties object + */ +function normalizeProperties( + properties: Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] | Record + >, +): GraphNodeProperties { + const normalized: GraphNodeProperties = {}; + + for (const [key, value] of Object.entries(properties)) { + normalized[key] = normalizePropertyValue(value); + } + + return normalized; +} + +/** + * Adapts graph query result data for D3 visualization + * Transforms relationships array into edges array for D3 force-directed graph + * + * The adapter handles: + * - Converting relationship objects to edge objects compatible with D3 + * - Mapping relationship labels to edge types for graph styling + * - Normalizing array properties to strings (e.g., anonymous_actions: ["s3:GetObject"] -> "s3:GetObject") + * - Preserving node and relationship data structure + * - Adding findings array to each node based on HAS_FINDING edges + * - Adding resources array to finding nodes based on HAS_FINDING edges (reverse relationship) + * + * @param graphData - Raw graph data with nodes and relationships from API + * @returns Graph data with edges array formatted for D3 visualization and findings/resources on nodes + */ +export function adaptQueryResultToGraphData( + graphData: AttackPathGraphData, +): AttackPathGraphData { + // Normalize node properties to ensure all values are primitives + const normalizedNodes = graphData.nodes.map((node) => ({ + ...node, + properties: normalizeProperties( + node.properties as Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] + >, + ), + findings: [] as string[], // Will be populated below + resources: [] as string[], // Will be populated below for finding nodes + })); + + // Transform relationships into D3-compatible edges if relationships exist + // Also handle case where edges are already provided (e.g., from mock data) + let edges: GraphEdge[] = []; + + if (graphData.relationships) { + edges = (graphData.relationships as GraphRelationship[]).map( + (relationship) => ({ + id: relationship.id, + source: relationship.source, + target: relationship.target, + type: relationship.label, // D3 uses 'type' for styling edge appearance + properties: relationship.properties + ? normalizeProperties( + relationship.properties as Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] + >, + ) + : undefined, + }), + ); + } else if (graphData.edges) { + // If edges are already provided, just normalize their properties + edges = (graphData.edges as GraphEdge[]).map((edge) => ({ + ...edge, + properties: edge.properties + ? normalizeProperties( + edge.properties as Record< + string, + GraphNodePropertyValue | GraphNodePropertyValue[] + >, + ) + : undefined, + })); + } + + // Populate findings and resources based on HAS_FINDING edges + edges.forEach((edge) => { + if (edge.type === "HAS_FINDING") { + const sourceId = + typeof edge.source === "string" + ? edge.source + : (edge.source as { id?: string })?.id; + const targetId = + typeof edge.target === "string" + ? edge.target + : (edge.target as { id?: string })?.id; + + if (sourceId && targetId) { + // Add finding to source node (resource -> finding) + const sourceNode = normalizedNodes.find((n) => n.id === sourceId); + if (sourceNode) { + sourceNode.findings.push(targetId); + } + + // Add resource to target node (finding <- resource) + const targetNode = normalizedNodes.find((n) => n.id === targetId); + if (targetNode) { + targetNode.resources.push(sourceId); + } + } + } + }); + + return { + nodes: normalizedNodes, + edges, + relationships: graphData.relationships, // Preserve original relationships data + }; +} diff --git a/ui/actions/attack-paths/scans.adapter.ts b/ui/actions/attack-paths/scans.adapter.ts new file mode 100644 index 0000000000..a8236241a3 --- /dev/null +++ b/ui/actions/attack-paths/scans.adapter.ts @@ -0,0 +1,89 @@ +import { MetaDataProps } from "@/types"; +import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths"; + +/** + * Adapts raw scan API responses to enriched domain models + * - Transforms raw scan data with computed properties + * - Co-locates related data for better performance + * - Preserves pagination metadata for list operations + * + * Uses plugin architecture for extensibility: + * - Handles scan-specific response transformation + * - Can be composed with backend service plugins + * - Maintains separation of concerns between API layer and business logic + */ + +/** + * Adapt attack path scans response with enriched data + * + * @param response - Raw API response from attack-paths-scans endpoint + * @returns Enriched scans data with metadata and computed properties + */ +export function adaptAttackPathScansResponse( + response: AttackPathScansResponse | undefined, +): { + data: AttackPathScan[]; + metadata?: MetaDataProps; +} { + if (!response?.data) { + return { data: [] }; + } + + // Enrich scan data with computed properties + const enrichedData = response.data.map((scan) => ({ + ...scan, + attributes: { + ...scan.attributes, + // Format duration for display + durationLabel: scan.attributes.duration + ? formatDuration(scan.attributes.duration) + : null, + // Check if scan is recent (completed within last 24 hours) + isRecent: isRecentScan(scan.attributes.completed_at), + }, + })); + + // Transform links to MetaDataProps format if pagination exists + const metadata: MetaDataProps | undefined = response.links + ? { + pagination: { + // Links-based pagination doesn't have traditional page numbers + // but we preserve the structure for consistency + page: 1, + pages: 1, + count: enrichedData.length, + itemsPerPage: [10, 25, 50, 100], + }, + version: "1.0", + } + : undefined; + + return { data: enrichedData, metadata }; +} + +/** + * Format duration in seconds to human-readable format + * + * @param seconds - Duration in seconds + * @returns Formatted duration string (e.g., "2m 30s") + */ +function formatDuration(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +/** + * Check if a scan is recent (completed within last 24 hours) + * + * @param completedAt - Completion timestamp + * @returns true if scan completed within last 24 hours + */ +function isRecentScan(completedAt: string | null): boolean { + if (!completedAt) return false; + + const completionTime = new Date(completedAt).getTime(); + const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; + + return completionTime > oneDayAgo; +} diff --git a/ui/actions/attack-paths/scans.ts b/ui/actions/attack-paths/scans.ts new file mode 100644 index 0000000000..11342f6ad3 --- /dev/null +++ b/ui/actions/attack-paths/scans.ts @@ -0,0 +1,69 @@ +"use server"; + +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; +import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths"; + +import { adaptAttackPathScansResponse } from "./scans.adapter"; + +// Validation schema for UUID - RFC 9562/4122 compliant +const UUIDSchema = z.uuid(); + +/** + * Fetch list of attack path scans (latest scan for each provider) + */ +export const getAttackPathScans = async (): Promise< + { data: AttackPathScan[] } | undefined +> => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch(`${apiBaseUrl}/attack-paths-scans`, { + headers, + method: "GET", + }); + + const apiResponse = (await handleApiResponse( + response, + )) as AttackPathScansResponse; + const adaptedData = adaptAttackPathScansResponse(apiResponse); + + return { data: adaptedData.data }; + } catch (error) { + console.error("Error fetching attack path scans:", error); + return undefined; + } +}; + +/** + * Fetch detail of a specific attack path scan + */ +export const getAttackPathScanDetail = async ( + scanId: string, +): Promise<{ data: AttackPathScan } | undefined> => { + // Validate scanId is a valid UUID format to prevent request forgery + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}`, + { + headers, + method: "GET", + }, + ); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching attack path scan detail:", error); + return undefined; + } +}; diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 9dfdde4d9a..1ecc1608fc 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -82,7 +82,7 @@ export const createNewUser = async (formData: SignUpFormData) => { } return parsedResponse; - } catch (error) { + } catch (_error) { return { errors: [ { @@ -127,7 +127,7 @@ export const getToken = async (formData: SignInFormData) => { accessToken, refreshToken, }; - } catch (error) { + } catch (_error) { throw new Error("Error in trying to get token"); } }; diff --git a/ui/actions/feeds/feeds.ts b/ui/actions/feeds/feeds.ts index 9defa6f41f..97ab8d90e7 100644 --- a/ui/actions/feeds/feeds.ts +++ b/ui/actions/feeds/feeds.ts @@ -1,7 +1,7 @@ "use server"; +import { extract } from "@extractus/feed-extractor"; import { unstable_cache } from "next/cache"; -import Parser from "rss-parser"; import { z } from "zod"; import type { FeedError, FeedItem, FeedSource, ParsedFeed } from "./types"; @@ -42,44 +42,24 @@ function getFeedSources(): FeedSource[] { async function parseSingleFeed( source: FeedSource, ): Promise<{ items: FeedItem[]; error?: FeedError }> { - const parser = new Parser({ - timeout: 10000, - headers: { - "User-Agent": "Prowler-UI/1.0", - }, - }); - try { - const feed = await parser.parseURL(source.url); + const feed = await extract(source.url); - // Map RSS items to our FeedItem type - const items: FeedItem[] = (feed.items || []).map((item) => { - // Validate and parse date with fallback to current date - const parsePubDate = (): string => { - const dateString = item.isoDate || item.pubDate; - if (!dateString) return new Date().toISOString(); - - const parsed = new Date(dateString); - return isNaN(parsed.getTime()) - ? new Date().toISOString() - : parsed.toISOString(); - }; - - return { - id: item.guid || item.link || `${source.id}-${item.title}`, - title: item.title || "Untitled", - description: - item.contentSnippet || item.content || item.description || "", - link: item.link || "", - pubDate: parsePubDate(), - sourceId: source.id, - sourceName: source.name, - sourceType: source.type, - author: item.creator || item.author, - categories: item.categories || [], - contentSnippet: item.contentSnippet || undefined, - }; - }); + const items: FeedItem[] = (feed.entries || []).map((entry) => ({ + id: entry.id || entry.link || `${source.id}-${entry.title}`, + title: entry.title || "Untitled", + description: entry.description || "", + link: entry.link || "", + pubDate: entry.published + ? new Date(entry.published).toISOString() + : new Date().toISOString(), + sourceId: source.id, + sourceName: source.name, + sourceType: source.type, + author: undefined, + categories: [], + contentSnippet: entry.description?.slice(0, 500), + })); return { items }; } catch (error) { diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 249742ce2e..1fade543aa 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -92,7 +92,12 @@ export const getMetadataInfo = async ({ Object.entries(filters).forEach(([key, value]) => { // Define filters to exclude - const excludedFilters = ["region__in", "service__in", "resource_type__in"]; + const excludedFilters = [ + "region__in", + "service__in", + "resource_type__in", + "resource_groups__in", + ]; if ( key !== "filter[search]" && !excludedFilters.some((filter) => key.includes(filter)) @@ -127,7 +132,12 @@ export const getLatestMetadataInfo = async ({ Object.entries(filters).forEach(([key, value]) => { // Define filters to exclude - const excludedFilters = ["region__in", "service__in", "resource_type__in"]; + const excludedFilters = [ + "region__in", + "service__in", + "resource_type__in", + "resource_groups__in", + ]; if ( key !== "filter[search]" && !excludedFilters.some((filter) => key.includes(filter)) diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 94d698114a..40084a0da1 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -404,7 +404,7 @@ export const pollConnectionTestStatus = async ( error: pollResult.message || "Connection test failed.", }; } - } catch (error) { + } catch (_error) { return { success: false, error: "Failed to check connection test status." }; } }; diff --git a/ui/actions/integrations/saml.ts b/ui/actions/integrations/saml.ts index 88febcbc44..05a58e9e5e 100644 --- a/ui/actions/integrations/saml.ts +++ b/ui/actions/integrations/saml.ts @@ -210,7 +210,7 @@ export const initiateSamlAuth = async (email: string) => { errorData.errors?.[0]?.detail || "An error occurred during SAML authentication.", }; - } catch (error) { + } catch (_error) { return { success: false, error: "Failed to connect to authentication service.", diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts new file mode 100644 index 0000000000..bc8005edf4 --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.adapter.ts @@ -0,0 +1,72 @@ +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { formatLabel } from "@/lib/categories"; + +import { ComplianceWatchlistResponse } from "./compliance-watchlist.types"; + +export interface EnrichedComplianceWatchlistItem { + id: string; + complianceId: string; + label: string; + icon: ReturnType; + score: number; + requirementsPassed: number; + requirementsFailed: number; + requirementsManual: number; + totalRequirements: number; +} + +/** + * Formats compliance_id into a human-readable label + * e.g., "aws_account_security_onboarding_aws" β†’ "AWS Account Security Onboarding" + * + * Uses the shared formatLabel utility from lib/categories.ts which handles: + * - Acronyms (≀3 chars like AWS, CIS, ISO, PCI, SOC, etc.) + * - Special cases (4+ char acronyms like GDPR, HIPAA, NIST, etc.) + * - Version patterns (e.g., "v1", "v2") + */ +function formatComplianceLabel(complianceId: string): string { + // Remove trailing provider suffix (e.g., "_aws", "_gcp", "_azure") + const withoutProvider = complianceId + .replace(/_aws$/i, "") + .replace(/_gcp$/i, "") + .replace(/_azure$/i, "") + .replace(/_kubernetes$/i, ""); + + return formatLabel(withoutProvider, "_"); +} + +export function adaptComplianceWatchlistResponse( + response: ComplianceWatchlistResponse | undefined, +): EnrichedComplianceWatchlistItem[] { + if (!response?.data) { + return []; + } + + return response.data.map((item) => { + const { + compliance_id, + requirements_passed, + requirements_failed, + requirements_manual, + total_requirements, + } = item.attributes; + + // Defensive conversion: API types are number but JSON parsing edge cases may return strings + const totalReqs = Number(total_requirements) || 0; + const passedReqs = Number(requirements_passed) || 0; + const score = + totalReqs > 0 ? Math.round((passedReqs / totalReqs) * 100) : 0; + + return { + id: item.id, + complianceId: compliance_id, + label: formatComplianceLabel(compliance_id), + icon: getComplianceIcon(compliance_id), + score, + requirementsPassed: requirements_passed, + requirementsFailed: requirements_failed, + requirementsManual: requirements_manual, + totalRequirements: total_requirements, + }; + }); +} diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts new file mode 100644 index 0000000000..647ee99e45 --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.ts @@ -0,0 +1,31 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +import { ComplianceWatchlistResponse } from "./compliance-watchlist.types"; + +export const getComplianceWatchlist = async ({ + filters = {}, +}: { + filters?: Record; +} = {}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/overviews/compliance-watchlist`); + + // Append filter parameters (provider_id, provider_type, etc.) + // Exclude filter[search] as this endpoint doesn't support text search + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]" && value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching compliance watchlist:", error); + return undefined; + } +}; diff --git a/ui/actions/overview/compliance-watchlist/compliance-watchlist.types.ts b/ui/actions/overview/compliance-watchlist/compliance-watchlist.types.ts new file mode 100644 index 0000000000..7ba99150ca --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/compliance-watchlist.types.ts @@ -0,0 +1,24 @@ +export const COMPLIANCE_WATCHLIST_OVERVIEW_TYPE = { + WATCHLIST_OVERVIEW: "compliance-watchlist-overviews", +} as const; + +type ComplianceWatchlistOverviewType = + (typeof COMPLIANCE_WATCHLIST_OVERVIEW_TYPE)[keyof typeof COMPLIANCE_WATCHLIST_OVERVIEW_TYPE]; + +export interface ComplianceWatchlistOverviewAttributes { + compliance_id: string; + requirements_passed: number; + requirements_failed: number; + requirements_manual: number; + total_requirements: number; +} + +export interface ComplianceWatchlistOverview { + type: ComplianceWatchlistOverviewType; + id: string; + attributes: ComplianceWatchlistOverviewAttributes; +} + +export interface ComplianceWatchlistResponse { + data: ComplianceWatchlistOverview[]; +} diff --git a/ui/actions/overview/compliance-watchlist/index.ts b/ui/actions/overview/compliance-watchlist/index.ts new file mode 100644 index 0000000000..f7943ced53 --- /dev/null +++ b/ui/actions/overview/compliance-watchlist/index.ts @@ -0,0 +1,9 @@ +export { getComplianceWatchlist } from "./compliance-watchlist"; +export { + adaptComplianceWatchlistResponse, + type EnrichedComplianceWatchlistItem, +} from "./compliance-watchlist.adapter"; +export type { + ComplianceWatchlistOverview, + ComplianceWatchlistResponse, +} from "./compliance-watchlist.types"; diff --git a/ui/actions/overview/index.ts b/ui/actions/overview/index.ts index fa3b434389..38cbec0a15 100644 --- a/ui/actions/overview/index.ts +++ b/ui/actions/overview/index.ts @@ -1,8 +1,8 @@ -// Re-export all overview actions from feature-based subfolders export * from "./attack-surface"; export * from "./findings"; export * from "./providers"; export * from "./regions"; +export * from "./resources-inventory"; export * from "./risk-plot"; export * from "./risk-radar"; export * from "./services"; diff --git a/ui/actions/overview/resources-inventory/index.ts b/ui/actions/overview/resources-inventory/index.ts new file mode 100644 index 0000000000..1da050a782 --- /dev/null +++ b/ui/actions/overview/resources-inventory/index.ts @@ -0,0 +1,12 @@ +export { getResourceGroupOverview } from "./resources-inventory"; +export { + adaptResourceGroupOverview, + RESOURCE_GROUP_IDS, + type ResourceGroupId, + type ResourceInventoryItem, +} from "./resources-inventory.adapter"; +export type { + ResourceGroupOverview, + ResourceGroupOverviewResponse, + SeverityBreakdown, +} from "./types"; diff --git a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts new file mode 100644 index 0000000000..5e6f5fd1f2 --- /dev/null +++ b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts @@ -0,0 +1,249 @@ +import { LucideIcon } from "lucide-react"; +import { + Activity, + BarChart3, + Bot, + Boxes, + Building2, + CloudCog, + Container, + Database, + FolderOpen, + GitBranch, + MessageSquare, + Network, + Server, + Shield, + SquareFunction, + UserRoundSearch, + Webhook, +} from "lucide-react"; + +import { + ResourceGroupOverview, + ResourceGroupOverviewResponse, + SeverityBreakdown, +} from "./types"; + +// Resource group IDs matching API values from ResourceGroup field specification +export const RESOURCE_GROUP_IDS = { + COMPUTE: "compute", + CONTAINER: "container", + SERVERLESS: "serverless", + DATABASE: "database", + STORAGE: "storage", + NETWORK: "network", + IAM: "IAM", + MESSAGING: "messaging", + SECURITY: "security", + MONITORING: "monitoring", + API_GATEWAY: "api_gateway", + AI_ML: "ai_ml", + GOVERNANCE: "governance", + COLLABORATION: "collaboration", + DEVOPS: "devops", + ANALYTICS: "analytics", +} as const; + +export type ResourceGroupId = + (typeof RESOURCE_GROUP_IDS)[keyof typeof RESOURCE_GROUP_IDS]; + +export interface ResourceInventoryItem { + id: string; + label: string; + icon: LucideIcon; + totalResources: number; + totalFindings: number; + failedFindings: number; + newFailedFindings: number; + severity: SeverityBreakdown; +} + +interface ResourceGroupConfig { + label: string; + icon: LucideIcon; +} + +const RESOURCE_GROUP_CONFIG: Record = { + [RESOURCE_GROUP_IDS.COMPUTE]: { + label: "Compute", + icon: Server, + }, + [RESOURCE_GROUP_IDS.CONTAINER]: { + label: "Container", + icon: Container, + }, + [RESOURCE_GROUP_IDS.SERVERLESS]: { + label: "Serverless", + icon: SquareFunction, + }, + [RESOURCE_GROUP_IDS.DATABASE]: { + label: "Database", + icon: Database, + }, + [RESOURCE_GROUP_IDS.STORAGE]: { + label: "Storage", + icon: FolderOpen, + }, + [RESOURCE_GROUP_IDS.NETWORK]: { + label: "Network", + icon: Network, + }, + [RESOURCE_GROUP_IDS.IAM]: { + label: "IAM", + icon: UserRoundSearch, + }, + [RESOURCE_GROUP_IDS.MESSAGING]: { + label: "Messaging", + icon: MessageSquare, + }, + [RESOURCE_GROUP_IDS.SECURITY]: { + label: "Security", + icon: Shield, + }, + [RESOURCE_GROUP_IDS.MONITORING]: { + label: "Monitoring", + icon: Activity, + }, + [RESOURCE_GROUP_IDS.API_GATEWAY]: { + label: "API Gateway", + icon: Webhook, + }, + [RESOURCE_GROUP_IDS.AI_ML]: { + label: "AI/ML", + icon: Bot, + }, + [RESOURCE_GROUP_IDS.GOVERNANCE]: { + label: "Governance", + icon: Building2, + }, + [RESOURCE_GROUP_IDS.COLLABORATION]: { + label: "Collaboration", + icon: Boxes, + }, + [RESOURCE_GROUP_IDS.DEVOPS]: { + label: "DevOps", + icon: GitBranch, + }, + [RESOURCE_GROUP_IDS.ANALYTICS]: { + label: "Analytics", + icon: BarChart3, + }, +}; + +// Default icon for unknown resource groups +const DEFAULT_ICON = CloudCog; + +// Order in which resource groups should be displayed +const RESOURCE_GROUP_ORDER: ResourceGroupId[] = [ + RESOURCE_GROUP_IDS.COMPUTE, + RESOURCE_GROUP_IDS.CONTAINER, + RESOURCE_GROUP_IDS.SERVERLESS, + RESOURCE_GROUP_IDS.DATABASE, + RESOURCE_GROUP_IDS.STORAGE, + RESOURCE_GROUP_IDS.NETWORK, + RESOURCE_GROUP_IDS.IAM, + RESOURCE_GROUP_IDS.MESSAGING, + RESOURCE_GROUP_IDS.SECURITY, + RESOURCE_GROUP_IDS.MONITORING, + RESOURCE_GROUP_IDS.API_GATEWAY, + RESOURCE_GROUP_IDS.AI_ML, + RESOURCE_GROUP_IDS.GOVERNANCE, + RESOURCE_GROUP_IDS.COLLABORATION, + RESOURCE_GROUP_IDS.DEVOPS, + RESOURCE_GROUP_IDS.ANALYTICS, +]; + +function mapResourceInventoryItem( + item: ResourceGroupOverview, +): ResourceInventoryItem { + const id = item.id; + const config = RESOURCE_GROUP_CONFIG[id as ResourceGroupId]; + + return { + id, + label: config?.label || formatResourceGroupLabel(id), + icon: config?.icon || DEFAULT_ICON, + totalResources: item.attributes.resources_count, + totalFindings: item.attributes.total_findings, + failedFindings: item.attributes.failed_findings, + newFailedFindings: item.attributes.new_failed_findings, + severity: item.attributes.severity, + }; +} + +/** + * Formats a resource group ID into a human-readable label. + * Handles snake_case and capitalizes appropriately. + */ +function formatResourceGroupLabel(id: string): string { + return id + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Adapts the resource group overview API response to a format suitable for the UI. + * Returns the items in a consistent order as defined by RESOURCE_GROUP_ORDER. + * + * @param response - The resource group overview API response + * @returns An array of ResourceInventoryItem objects sorted by the predefined order + */ +export function adaptResourceGroupOverview( + response: ResourceGroupOverviewResponse | undefined, +): ResourceInventoryItem[] { + if (!response?.data || response.data.length === 0) { + return []; + } + + // Create a map for quick lookup + const itemsMap = new Map(); + for (const item of response.data) { + itemsMap.set(item.id, item); + } + + // Return items in the predefined order + const sortedItems: ResourceInventoryItem[] = []; + for (const id of RESOURCE_GROUP_ORDER) { + const item = itemsMap.get(id); + if (item) { + sortedItems.push(mapResourceInventoryItem(item)); + } + } + + // Include any items that might be in the response but not in our predefined order + for (const item of response.data) { + if (!RESOURCE_GROUP_ORDER.includes(item.id as ResourceGroupId)) { + sortedItems.push(mapResourceInventoryItem(item)); + } + } + + return sortedItems; +} + +/** + * Returns all resource groups with default/empty values. + * Useful for showing all groups even when no data is available. + */ +export function getEmptyResourceInventoryItems(): ResourceInventoryItem[] { + return RESOURCE_GROUP_ORDER.map((id) => { + const config = RESOURCE_GROUP_CONFIG[id]; + return { + id, + label: config.label, + icon: config.icon, + totalResources: 0, + totalFindings: 0, + failedFindings: 0, + newFailedFindings: 0, + severity: { + informational: 0, + low: 0, + medium: 0, + high: 0, + critical: 0, + }, + }; + }); +} diff --git a/ui/actions/overview/resources-inventory/resources-inventory.ts b/ui/actions/overview/resources-inventory/resources-inventory.ts new file mode 100644 index 0000000000..7cd353df39 --- /dev/null +++ b/ui/actions/overview/resources-inventory/resources-inventory.ts @@ -0,0 +1,34 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +import { ResourceGroupOverviewResponse } from "./types"; + +export const getResourceGroupOverview = async ({ + filters = {}, +}: { + filters?: Record; +} = {}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/overviews/resource-groups`); + + // Handle multiple filters + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]" && value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching resource group overview:", error); + return undefined; + } +}; diff --git a/ui/actions/overview/resources-inventory/types/index.ts b/ui/actions/overview/resources-inventory/types/index.ts new file mode 100644 index 0000000000..4e67619ee4 --- /dev/null +++ b/ui/actions/overview/resources-inventory/types/index.ts @@ -0,0 +1 @@ +export * from "./resources-inventory.types"; diff --git a/ui/actions/overview/resources-inventory/types/resources-inventory.types.ts b/ui/actions/overview/resources-inventory/types/resources-inventory.types.ts new file mode 100644 index 0000000000..90f94455f9 --- /dev/null +++ b/ui/actions/overview/resources-inventory/types/resources-inventory.types.ts @@ -0,0 +1,33 @@ +// GET /api/v1/overviews/resource-groups endpoint + +interface OverviewResponseMeta { + version: string; +} + +export interface SeverityBreakdown { + informational: number; + low: number; + medium: number; + high: number; + critical: number; +} + +export interface ResourceGroupOverviewAttributes { + id: string; + total_findings: number; + failed_findings: number; + new_failed_findings: number; + resources_count: number; + severity: SeverityBreakdown; +} + +export interface ResourceGroupOverview { + type: "resource-group-overview"; + id: string; + attributes: ResourceGroupOverviewAttributes; +} + +export interface ResourceGroupOverviewResponse { + data: ResourceGroupOverview[]; + meta: OverviewResponseMeta; +} diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index db0ce8a2be..4665e9db39 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -48,6 +48,89 @@ export const getProviders = async ({ } }; +/** + * Fetches all providers by iterating through all pages. + * This is useful when you need the complete list of providers without pagination limits, + * such as for dropdown menus or selection lists. + */ +export const getAllProviders = async ({ + query = "", + sort = "", + filters = {}, +}: { + query?: string; + sort?: string; + filters?: Record; +} = {}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const pageSize = 100; // Use larger page size to minimize API calls + const maxPages = 50; // Safety limit: 50 pages Γ— 100 = 5000 providers max + let currentPage = 1; + const allProviders: ProvidersApiResponse["data"] = []; + let lastResponse: ProvidersApiResponse | undefined; + let hasMorePages = true; + + try { + while (hasMorePages && currentPage <= maxPages) { + const url = new URL(`${apiBaseUrl}/providers?include=provider_groups`); + url.searchParams.append("page[number]", currentPage.toString()); + url.searchParams.append("page[size]", pageSize.toString()); + + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]") { + url.searchParams.append(key, String(value)); + } + }); + + const response = await fetch(url.toString(), { headers }); + const data = (await handleApiResponse(response)) as + | ProvidersApiResponse + | undefined; + + if (!data?.data || data.data.length === 0) { + hasMorePages = false; + continue; + } + + allProviders.push(...data.data); + lastResponse = data; + + // Check if we've fetched all pages + const totalPages = data.meta?.pagination?.pages || 1; + if (currentPage >= totalPages) { + hasMorePages = false; + } else { + currentPage++; + } + } + + // Return combined response with all providers + if (lastResponse) { + return { + ...lastResponse, + data: allProviders, + meta: { + ...lastResponse.meta, + pagination: { + ...lastResponse.meta?.pagination, + page: 1, + pages: 1, + count: allProviders.length, + }, + }, + }; + } + + return undefined; + } catch (error) { + console.error("Error fetching all providers:", error); + return undefined; + } +}; + export const getProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: false }); const providerId = formData.get("id"); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index c71e74c623..be2aec7770 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -1,7 +1,5 @@ "use server"; -import { Spacer } from "@heroui/spacer"; - import { getLatestFindings } from "@/actions/findings/findings"; import { LighthouseBanner } from "@/components/lighthouse/banner"; import { LinkToFindings } from "@/components/overview"; @@ -59,22 +57,19 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { }; return ( -
+
-
-
-

+
+
+

Latest new failing findings

-

+

Showing the latest 10 new failing findings by severity.

-
-
- ; +} + +export function ResourcesInventoryCardItem({ + item, + filters = {}, +}: ResourcesInventoryCardItemProps) { + const hasFailedFindings = item.failedFindings > 0; + const hasResources = item.totalResources > 0; + + // Build URL with current filters + resource group specific filters + const buildResourcesUrl = () => { + if (!hasResources) return null; + + const params = new URLSearchParams(); + + // Add group specific filter + params.set("filter[groups__in]", item.id); + + // Add current page filters (provider, account, etc.) + // Transform provider_id__in to provider__in for resources endpoint + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && !params.has(key)) { + const transformedKey = + key === "filter[provider_id__in]" ? "filter[provider__in]" : key; + params.set(transformedKey, String(value)); + } + }); + + return `/resources?${params.toString()}`; + }; + + const resourcesUrl = buildResourcesUrl(); + + // Build stats array for the card content + const stats: StatItem[] = []; + if (hasFailedFindings && item.newFailedFindings > 0) { + stats.push({ + icon: Bell, + label: `${item.newFailedFindings} New`, + }); + } + + // Empty state when no resources + if (!hasResources) { + const cardContent = ( + + ); + + return cardContent; + } + + // Card with findings data + const cardContent = ( + + ); + + if (resourcesUrl) { + return ( + + {cardContent} + + ); + } + + return cardContent; +} diff --git a/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory.tsx b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory.tsx new file mode 100644 index 0000000000..79a0deb03a --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory.tsx @@ -0,0 +1,84 @@ +import Link from "next/link"; + +import { ResourceInventoryItem } from "@/actions/overview"; +import { Card, CardContent, CardTitle } from "@/components/shadcn"; + +import { ResourcesInventoryCardItem } from "./resources-inventory-card-item"; + +interface ResourcesInventoryProps { + items: ResourceInventoryItem[]; + filters?: Record; +} + +const MAX_VISIBLE_GROUPS = 8; + +export function ResourcesInventory({ + items, + filters, +}: ResourcesInventoryProps) { + const isEmpty = items.length === 0; + + // Sort by failedFindings (desc), then by totalResources (desc) to prioritize groups with issues + const sortedItems = [...items].sort((a, b) => { + if (b.failedFindings !== a.failedFindings) { + return b.failedFindings - a.failedFindings; + } + return b.totalResources - a.totalResources; + }); + + // Take top 8 most relevant groups + const visibleItems = sortedItems.slice(0, MAX_VISIBLE_GROUPS); + const firstRow = visibleItems.slice(0, 4); + const secondRow = visibleItems.slice(4, 8); + + return ( + +
+ Resource Inventory + + View All Resources + +
+ + {isEmpty ? ( +
+

+ No resource inventory data available. +

+
+ ) : ( + <> + {/* First row */} +
+ {firstRow.map((item) => ( + + ))} +
+ {/* Second row */} + {secondRow.length > 0 && ( +
+ {secondRow.map((item) => ( + + ))} +
+ )} + + )} +
+
+ ); +} diff --git a/ui/app/(prowler)/_overview/resources-inventory/index.ts b/ui/app/(prowler)/_overview/resources-inventory/index.ts new file mode 100644 index 0000000000..2d17f23f5c --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/index.ts @@ -0,0 +1,2 @@ +export { ResourcesInventorySSR } from "./resources-inventory.ssr"; +export { ResourcesInventorySkeleton } from "./resources-inventory-skeleton"; diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx new file mode 100644 index 0000000000..461fe1ae5a --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx @@ -0,0 +1,59 @@ +import { Card, CardContent, CardTitle } from "@/components/shadcn"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; + +function ResourceCardSkeleton() { + return ( +
+ {/* Header */} +
+
+ + +
+ +
+ {/* Content */} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ ); +} + +export function ResourcesInventorySkeleton() { + return ( + +
+ Resource Inventory + +
+ + {/* First row */} +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ {/* Second row */} +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx new file mode 100644 index 0000000000..a95f65f9d8 --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx @@ -0,0 +1,20 @@ +import { + adaptResourceGroupOverview, + getResourceGroupOverview, +} from "@/actions/overview"; + +import { pickFilterParams } from "../_lib/filter-params"; +import { SSRComponentProps } from "../_types"; +import { ResourcesInventory } from "./_components/resources-inventory"; + +export const ResourcesInventorySSR = async ({ + searchParams, +}: SSRComponentProps) => { + const filters = pickFilterParams(searchParams); + + const response = await getResourceGroupOverview({ filters }); + + const items = adaptResourceGroupOverview(response); + + return ; +}; diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx index 864c00585d..cf25498d58 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx @@ -32,7 +32,7 @@ export const TimeRangeSelector = ({ isLoading = false, }: TimeRangeSelectorProps) => { return ( -
+
{Object.entries(TIME_RANGE_OPTIONS).map(([key, range]) => ( {" "} - {emptyState.description} - - )} -

+ {emptyState?.description && ctaHref && ( +

+ Visit the{" "} + {" "} + {emptyState.description} +

+ )}
) : ( <> @@ -149,7 +148,7 @@ export const WatchlistCard = ({ } }} className={cn( - "flex h-[54px] items-center justify-between gap-2 px-3 py-[11px]", + "flex h-[54px] min-w-0 items-center justify-between gap-2 px-3 py-[11px]", !isLast && "border-border-neutral-tertiary border-b", isClickable && "hover:bg-bg-neutral-tertiary cursor-pointer", @@ -161,10 +160,10 @@ export const WatchlistCard = ({
)} -

+

{item.label}

-
+

{ const filters = pickFilterParams(searchParams); + const response = await getComplianceWatchlist({ filters }); + const enrichedData = adaptComplianceWatchlistResponse(response); - const response = await getCompliancesOverview({ filters }); - const { data } = adaptComplianceOverviewsResponse(response); - - // Filter out ProwlerThreatScore and limit to 5 items - const items = data - .filter((item) => item.framework !== "ProwlerThreatScore") - .slice(0, 5) - .map((compliance) => ({ - id: compliance.id, - framework: compliance.framework, - label: compliance.label, - icon: compliance.icon, - score: compliance.score, + // Filter out ProwlerThreatScore and pass all items to client + // Client handles sorting and limiting to display count + const items = enrichedData + .filter((item) => !item.complianceId.toLowerCase().includes("threatscore")) + .map((item) => ({ + id: item.id, + framework: item.complianceId, + label: item.label, + icon: item.icon, + score: item.score, })); return ; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/_components/index.ts new file mode 100644 index 0000000000..9dab45a6b5 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/index.ts @@ -0,0 +1,2 @@ +export { VerticalSteps } from "./vertical-steps"; +export { WorkflowAttackPaths } from "./workflow-attack-paths"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/_components/vertical-steps.tsx b/ui/app/(prowler)/attack-paths/(workflow)/_components/vertical-steps.tsx new file mode 100644 index 0000000000..7a11c11484 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/vertical-steps.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useControlledState } from "@react-stately/utils"; +import { domAnimation, LazyMotion, m } from "framer-motion"; +import type { + ComponentProps, + CSSProperties, + HTMLAttributes, + ReactNode, +} from "react"; +import { forwardRef } from "react"; + +import { cn } from "@/lib/utils"; + +export type VerticalStepProps = { + className?: string; + description?: ReactNode; + title?: ReactNode; +}; + +export const STEP_COLORS = { + primary: "primary", + secondary: "secondary", + success: "success", + warning: "warning", + danger: "danger", + default: "default", +} as const; + +type StepColor = (typeof STEP_COLORS)[keyof typeof STEP_COLORS]; + +export interface VerticalStepsProps extends HTMLAttributes { + /** + * An array of steps. + * + * @default [] + */ + steps?: VerticalStepProps[]; + /** + * The color of the steps. + * + * @default "primary" + */ + color?: StepColor; + /** + * The current step index. + */ + currentStep?: number; + /** + * The default step index. + * + * @default 0 + */ + defaultStep?: number; + /** + * Whether to hide the progress bars. + * + * @default false + */ + hideProgressBars?: boolean; + /** + * The custom class for the steps wrapper. + */ + className?: string; + /** + * The custom class for the step. + */ + stepClassName?: string; + /** + * Callback function when the step index changes. + */ + onStepChange?: (stepIndex: number) => void; +} + +function CheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} + +export const VerticalSteps = forwardRef( + ( + { + color = "primary", + steps = [], + defaultStep = 0, + onStepChange, + currentStep: currentStepProp, + hideProgressBars = false, + stepClassName, + className, + ...props + }, + ref, + ) => { + const [currentStep, setCurrentStep] = useControlledState( + currentStepProp, + defaultStep, + onStepChange, + ); + + let userColor; + let fgColor; + + const colorsVars = [ + "[--active-fg-color:var(--step-fg-color)]", + "[--active-border-color:var(--step-color)]", + "[--active-color:var(--step-color)]", + "[--complete-background-color:var(--step-color)]", + "[--complete-border-color:var(--step-color)]", + "[--inactive-border-color:hsl(var(--heroui-default-300))]", + "[--inactive-color:hsl(var(--heroui-default-300))]", + ]; + + switch (color) { + case "primary": + userColor = "[--step-color:hsl(var(--heroui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]"; + break; + case "secondary": + userColor = "[--step-color:hsl(var(--heroui-secondary))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]"; + break; + case "success": + userColor = "[--step-color:hsl(var(--heroui-success))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]"; + break; + case "warning": + userColor = "[--step-color:hsl(var(--heroui-warning))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]"; + break; + case "danger": + userColor = "[--step-color:hsl(var(--heroui-error))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]"; + break; + case "default": + userColor = "[--step-color:hsl(var(--heroui-default))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]"; + break; + default: + userColor = "[--step-color:hsl(var(--heroui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]"; + break; + } + + if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor); + if (!className?.includes("--step-color")) colorsVars.unshift(userColor); + if (!className?.includes("--inactive-bar-color")) + colorsVars.push("[--inactive-bar-color:hsl(var(--heroui-default-300))]"); + + const colors = colorsVars; + + return ( +

+ ); + }, +); + +VerticalSteps.displayName = "VerticalSteps"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx b/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx new file mode 100644 index 0000000000..9e1f3684ca --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +import { VerticalSteps } from "./vertical-steps"; + +/** + * Workflow steps component for Attack Paths wizard + * Shows progress and navigation steps for the two-step process + */ +export const WorkflowAttackPaths = () => { + const pathname = usePathname(); + + // Determine current step based on pathname + const isQueryBuilderStep = pathname.includes("query-builder"); + + const currentStep = isQueryBuilderStep ? 1 : 0; // 0-indexed + + const steps = [ + { + title: "Select Attack Paths Scan", + description: "Choose an AWS account and its latest Attack Paths scan", + }, + { + title: "Build Query & Visualize", + description: "Create a query and view the Attack Paths graph", + }, + ]; + + const progressPercentage = (currentStep / (steps.length - 1)) * 100; + + return ( +
+
+
+
+
+

+ Step {currentStep + 1} of {steps.length} +

+
+ + +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx new file mode 100644 index 0000000000..8557ab5369 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx @@ -0,0 +1,21 @@ +import { Navbar } from "@/components/ui/nav-bar/navbar"; + +/** + * Workflow layout for Attack Paths + * Displays content with navbar + */ +export default function AttackPathsWorkflowLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + +
+ {/* Content */} +
{children}
+
+ + ); +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/execute-button.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/execute-button.tsx new file mode 100644 index 0000000000..07caf5547a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/execute-button.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Play } from "lucide-react"; + +import { Button } from "@/components/shadcn"; + +interface ExecuteButtonProps { + isLoading: boolean; + isDisabled: boolean; + onExecute: () => void; +} + +/** + * Execute query button component + * Triggers query execution with loading state + */ +export const ExecuteButton = ({ + isLoading, + isDisabled, + onExecute, +}: ExecuteButtonProps) => { + return ( + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx new file mode 100644 index 0000000000..10842492e8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -0,0 +1,1173 @@ +"use client"; + +import type { D3ZoomEvent, ZoomBehavior } from "d3"; +import { select, zoom, zoomIdentity } from "d3"; +import dagre from "dagre"; +import { useTheme } from "next-themes"; +import { + forwardRef, + type Ref, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; + +import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; + +import { + formatNodeLabel, + getNodeBorderColor, + getNodeColor, + getPathEdges, + GRAPH_ALERT_BORDER_COLOR, + GRAPH_EDGE_COLOR_DARK, + GRAPH_EDGE_COLOR_LIGHT, + GRAPH_EDGE_HIGHLIGHT_COLOR, +} from "../../_lib"; + +export interface AttackPathGraphRef { + zoomIn: () => void; + zoomOut: () => void; + resetZoom: () => void; + getZoomLevel: () => number; + getSVGElement: () => SVGSVGElement | null; +} + +interface AttackPathGraphProps { + data: AttackPathGraphData; + onNodeClick?: (node: GraphNode) => void; + selectedNodeId?: string | null; + isFilteredView?: boolean; + ref?: Ref; +} + +/** + * Node data type used throughout the graph visualization + */ +type NodeData = { id: string; x: number; y: number; data: GraphNode }; + +// Node dimensions - modern rounded pill style +const NODE_WIDTH = 180; +const NODE_HEIGHT = 50; +const NODE_RADIUS = 25; // Fully rounded ends for pill shape +const HEXAGON_WIDTH = 200; // Width for finding hexagons +const HEXAGON_HEIGHT = 55; // Height for finding hexagons + +/** + * D3 + Dagre hierarchical graph visualization for attack paths + * Renders rounded rectangle nodes with dashed edges + */ +const AttackPathGraphComponent = forwardRef< + AttackPathGraphRef, + AttackPathGraphProps +>(({ data, onNodeClick, selectedNodeId, isFilteredView = false }, ref) => { + const svgRef = useRef(null); + const [zoomLevel, setZoomLevel] = useState(1); + const { resolvedTheme } = useTheme(); + const zoomBehaviorRef = useRef | null>( + null, + ); + const containerRef = useRef + > | null>(null); + const svgSelectionRef = useRef + > | null>(null); + const hiddenNodeIdsRef = useRef>(new Set()); + const onNodeClickRef = useRef(onNodeClick); + const nodeShapesRef = useRef + > | null>(null); + const linkElementsRef = useRef + > | null>(null); + const resourcesWithFindingsRef = useRef>(new Set()); + const selectedNodeIdRef = useRef(null); + const edgesDataRef = useRef< + Array<{ + sourceId: string; + targetId: string; + }> + >([]); + + // Get edge color based on current theme + const edgeColor = + resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT; + + // Keep selectedNodeIdRef in sync with selectedNodeId + useEffect(() => { + selectedNodeIdRef.current = selectedNodeId ?? null; + }, [selectedNodeId]); + + // Update ref when onNodeClick changes + useEffect(() => { + onNodeClickRef.current = onNodeClick; + }, [onNodeClick]); + + // Update selected node styling and edge highlighting without re-rendering + useEffect(() => { + if (nodeShapesRef.current) { + nodeShapesRef.current + .attr("stroke", (d: NodeData) => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindingsRef.current.has(d.id); + + // Resources with findings always keep red border + if (!isFinding && hasFindings) { + return GRAPH_ALERT_BORDER_COLOR; + } + // Selected nodes get highlight color (orange) + if (d.id === selectedNodeId) { + return GRAPH_EDGE_HIGHLIGHT_COLOR; + } + // Default border color + return getNodeBorderColor(d.data.labels, d.data.properties); + }) + .attr("stroke-width", (d: NodeData) => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindingsRef.current.has(d.id); + const isSelected = d.id === selectedNodeId; + + if (isSelected) return 4; + if (!isFinding && hasFindings) return 2.5; + return isFinding ? 2 : 1.5; + }) + .attr("filter", (d: NodeData) => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindingsRef.current.has(d.id); + const isSelected = d.id === selectedNodeId; + + if (isSelected) return "url(#selectedGlow)"; + if (!isFinding && hasFindings) return "url(#redGlow)"; + return isFinding ? "url(#glow)" : null; + }) + .attr("class", (d: NodeData) => { + const isSelected = d.id === selectedNodeId; + return isSelected ? "node-shape selected-node" : "node-shape"; + }); + } + + // Update edge highlighting for selected node - highlight entire path + if (linkElementsRef.current && edgesDataRef.current.length > 0) { + const pathEdges = selectedNodeId + ? getPathEdges(selectedNodeId, edgesDataRef.current) + : new Set(); + + linkElementsRef.current.each(function (edgeData: { + sourceId: string; + targetId: string; + }) { + const edgeId = `${edgeData.sourceId}-${edgeData.targetId}`; + const isInPath = pathEdges.has(edgeId); + select(this) + .attr("stroke", isInPath ? GRAPH_EDGE_HIGHLIGHT_COLOR : edgeColor) + .attr( + "marker-end", + isInPath ? "url(#arrowhead-highlight)" : "url(#arrowhead)", + ); + }); + } + }, [selectedNodeId, edgeColor]); + + useImperativeHandle(ref, () => ({ + zoomIn: () => { + if (svgSelectionRef.current && zoomBehaviorRef.current) { + svgSelectionRef.current + .transition() + .duration(300) + .call(zoomBehaviorRef.current.scaleBy, 1.3); + } + }, + zoomOut: () => { + if (svgSelectionRef.current && zoomBehaviorRef.current) { + svgSelectionRef.current + .transition() + .duration(300) + .call(zoomBehaviorRef.current.scaleBy, 0.77); + } + }, + resetZoom: () => { + if ( + svgSelectionRef.current && + zoomBehaviorRef.current && + containerRef.current + ) { + const bounds = containerRef.current.node()?.getBBox(); + if (!bounds) return; + + const fullWidth = svgRef.current?.clientWidth || 800; + const fullHeight = svgRef.current?.clientHeight || 500; + + const midX = bounds.x + bounds.width / 2; + const midY = bounds.y + bounds.height / 2; + const scale = + 0.8 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); + const tx = fullWidth / 2 - scale * midX; + const ty = fullHeight / 2 - scale * midY; + + svgSelectionRef.current + .transition() + .duration(300) + .call( + zoomBehaviorRef.current.transform, + zoomIdentity.translate(tx, ty).scale(scale), + ); + } + }, + getZoomLevel: () => zoomLevel, + getSVGElement: () => svgRef.current, + })); + + useEffect(() => { + if (!svgRef.current || !data.nodes || data.nodes.length === 0) return; + + // Set dimensions based on container size + const width = svgRef.current.clientWidth || 800; + const height = svgRef.current.clientHeight || 500; + + // Clear previous content + select(svgRef.current).selectAll("*").remove(); + + // Create SVG + const svg = select(svgRef.current) + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]); + + // Create container for zoom/pan + const container = svg.append("g") as unknown as ReturnType< + typeof select + >; + containerRef.current = container; + svgSelectionRef.current = svg as unknown as ReturnType< + typeof select + >; + + // Container relationships (reverse direction for layout purposes) + const containerRelations = new Set([ + "RUNS_IN", + "BELONGS_TO", + "LOCATED_IN", + "PART_OF", + ]); + + // Create dagre graph + const g = new dagre.graphlib.Graph(); + g.setGraph({ + rankdir: "LR", // Left to right + nodesep: 80, // Vertical spacing between nodes + ranksep: 150, // Horizontal spacing between ranks + marginx: 50, + marginy: 50, + }); + g.setDefaultEdgeLabel(() => ({})); + + // Initially hide finding nodes - they are shown when user clicks on a node + // In filtered view, show all nodes since they're already filtered to the selected path + const initialHiddenNodes = new Set(); + if (!isFilteredView) { + data.nodes.forEach((node) => { + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + if (isFinding) { + initialHiddenNodes.add(node.id); + } + }); + } + hiddenNodeIdsRef.current = initialHiddenNodes; + + // Create a map to store original node data + const nodeDataMap = new Map(data.nodes.map((node) => [node.id, node])); + + // Add nodes to dagre graph with appropriate sizes + data.nodes.forEach((node) => { + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + g.setNode(node.id, { + label: node.id, + width: isFinding ? HEXAGON_WIDTH : NODE_WIDTH, + height: isFinding ? HEXAGON_HEIGHT : NODE_HEIGHT, + }); + }); + + // Add edges to dagre graph + if (data.edges && Array.isArray(data.edges)) { + data.edges.forEach((edge) => { + const source = edge.source; + const target = edge.target; + let sourceId = + typeof source === "string" + ? source + : typeof source === "object" && source !== null + ? (source as GraphNode).id + : ""; + let targetId = + typeof target === "string" + ? target + : typeof target === "object" && target !== null + ? (target as GraphNode).id + : ""; + + // Reverse container relationships for proper hierarchy + if (containerRelations.has(edge.type)) { + [sourceId, targetId] = [targetId, sourceId]; + } + + if (sourceId && targetId) { + g.setEdge(sourceId, targetId, { + originalSource: + typeof edge.source === "string" + ? edge.source + : (edge.source as GraphNode).id, + originalTarget: + typeof edge.target === "string" + ? edge.target + : (edge.target as GraphNode).id, + }); + } + }); + } + + // Run dagre layout + dagre.layout(g); + + // Draw edges + const edgesData: Array<{ + source: { x: number; y: number }; + target: { x: number; y: number }; + id: string; + sourceId: string; + targetId: string; + }> = []; + g.edges().forEach((e) => { + const sourceNode = g.node(e.v); + const targetNode = g.node(e.w); + + edgesData.push({ + source: { x: sourceNode.x, y: sourceNode.y }, + target: { x: targetNode.x, y: targetNode.y }, + id: `${e.v}-${e.w}`, + sourceId: e.v, + targetId: e.w, + }); + }); + + // Store edges data in ref for path highlighting + edgesDataRef.current = edgesData.map((e) => ({ + sourceId: e.sourceId, + targetId: e.targetId, + })); + + // Add defs for filters and markers FIRST (before using them) + const defs = svg.append("defs"); + + // Glow filter for nodes + const glowFilter = defs.append("filter").attr("id", "glow"); + glowFilter + .append("feGaussianBlur") + .attr("stdDeviation", "3") + .attr("result", "coloredBlur"); + const feMerge = glowFilter.append("feMerge"); + feMerge.append("feMergeNode").attr("in", "coloredBlur"); + feMerge.append("feMergeNode").attr("in", "SourceGraphic"); + + // Edge glow filter + const edgeGlowFilter = defs.append("filter").attr("id", "edgeGlow"); + edgeGlowFilter + .append("feGaussianBlur") + .attr("stdDeviation", "2") + .attr("result", "coloredBlur"); + const edgeFeMerge = edgeGlowFilter.append("feMerge"); + edgeFeMerge.append("feMergeNode").attr("in", "coloredBlur"); + edgeFeMerge.append("feMergeNode").attr("in", "SourceGraphic"); + + // Red glow filter for resources with findings + const redGlowFilter = defs.append("filter").attr("id", "redGlow"); + redGlowFilter + .append("feDropShadow") + .attr("dx", "0") + .attr("dy", "0") + .attr("stdDeviation", "4") + .attr("flood-color", GRAPH_ALERT_BORDER_COLOR) + .attr("flood-opacity", "0.6"); + + // Orange glow filter for selected/filtered node + const selectedGlowFilter = defs.append("filter").attr("id", "selectedGlow"); + selectedGlowFilter + .append("feDropShadow") + .attr("dx", "0") + .attr("dy", "0") + .attr("stdDeviation", "6") + .attr("flood-color", GRAPH_EDGE_HIGHLIGHT_COLOR) + .attr("flood-opacity", "0.8"); + + // Arrow marker (theme-aware) - refX=10 places the arrow tip exactly at the line endpoint + defs + .append("marker") + .attr("id", "arrowhead") + .attr("viewBox", "0 0 10 10") + .attr("refX", 10) + .attr("refY", 5) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M 0 0 L 10 5 L 0 10 z") + .attr("fill", edgeColor); + + // Arrow marker (highlighted orange) for hover state + defs + .append("marker") + .attr("id", "arrowhead-highlight") + .attr("viewBox", "0 0 10 10") + .attr("refX", 10) + .attr("refY", 5) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M 0 0 L 10 5 L 0 10 z") + .attr("fill", GRAPH_EDGE_HIGHLIGHT_COLOR); + + // Add CSS animation for dashed lines, resource edge styles, and selected node pulse + svg.append("style").text(` + @keyframes dash { + to { + stroke-dashoffset: -20; + } + } + .animated-edge { + animation: dash 1s linear infinite; + } + .resource-edge { + stroke-opacity: 1; + } + @keyframes selectedPulse { + 0%, 100% { + stroke-opacity: 1; + stroke-width: 4px; + } + 50% { + stroke-opacity: 0.6; + stroke-width: 6px; + } + } + .selected-node { + animation: selectedPulse 1.2s ease-in-out infinite; + filter: url(#selectedGlow); + } + `); + + const linkGroup = container.append("g").attr("class", "links"); + + // Calculate edge endpoints based on node shape + const getEdgePoints = ( + sourceId: string, + targetId: string, + source: { x: number; y: number }, + target: { x: number; y: number }, + ) => { + const sourceNode = nodeDataMap.get(sourceId); + const targetNode = nodeDataMap.get(targetId); + + const sourceIsFinding = sourceNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const targetIsFinding = targetNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const sourceIsInternet = sourceNode?.labels.some( + (label) => label.toLowerCase() === "internet", + ); + const targetIsInternet = targetNode?.labels.some( + (label) => label.toLowerCase() === "internet", + ); + + // Get appropriate widths based on node type + // Internet nodes are circles with radius = NODE_HEIGHT * 0.8 + const sourceHalfWidth = sourceIsInternet + ? NODE_HEIGHT * 0.8 + : sourceIsFinding + ? HEXAGON_WIDTH / 2 + : NODE_WIDTH / 2; + const targetHalfWidth = targetIsInternet + ? NODE_HEIGHT * 0.8 + : targetIsFinding + ? HEXAGON_WIDTH / 2 + : NODE_WIDTH / 2; + + // Source exits from right side + const x1 = source.x + sourceHalfWidth; + const y1 = source.y; + + // Target enters from left side - line ends at node edge, arrow extends from there + const x2 = target.x - targetHalfWidth; + const y2 = target.y; + + return { x1, y1, x2, y2 }; + }; + + // Helper to check if a node is a finding + const isNodeFinding = (nodeId: string) => { + const node = nodeDataMap.get(nodeId); + return node?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + }; + + const linkElements = linkGroup + .selectAll("line") + .data(edgesData) + .enter() + .append("line") + .attr( + "x1", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).x1, + ) + .attr( + "y1", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).y1, + ) + .attr( + "x2", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).x2, + ) + .attr( + "y2", + (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).y2, + ) + .attr("stroke", edgeColor) + .attr("stroke-width", 3) + .attr("stroke-linecap", "round") + .attr("stroke-dasharray", (d) => { + // Dashed lines only for edges connected to findings + const hasFinding = + isNodeFinding(d.sourceId) || isNodeFinding(d.targetId); + return hasFinding ? "8,6" : null; + }) + .attr("class", (d) => { + // Animate dashed lines + const hasFinding = + isNodeFinding(d.sourceId) || isNodeFinding(d.targetId); + return hasFinding ? "animated-edge" : "resource-edge"; + }) + .attr("marker-end", "url(#arrowhead)") + .style("visibility", (d) => { + const sourceIsFinding = isNodeFinding(d.sourceId); + const targetIsFinding = isNodeFinding(d.targetId); + + // Hide edges connected to findings in full view (shown when user clicks on a node or in filtered view) + if (!isFilteredView && (sourceIsFinding || targetIsFinding)) { + return "hidden"; + } + return "visible"; + }); + + // Store linkElements reference for hover interactions + // D3 selection types don't match our ref type exactly; safe cast for internal use + linkElementsRef.current = linkElements as unknown as ReturnType< + typeof select + >; + + // Draw nodes + const nodesData = g.nodes().map((v) => { + const node = g.node(v); + return { + id: v, + x: node.x, + y: node.y, + data: nodeDataMap.get(v)!, + }; + }); + + const nodeGroup = container.append("g").attr("class", "nodes"); + + const nodeElements = nodeGroup + .selectAll("g.node") + .data(nodesData) + .enter() + .append("g") + .attr("class", "node") + .attr("transform", (d) => `translate(${d.x},${d.y})`) + .attr("cursor", "pointer") + .style("display", (d) => { + // Hide findings in full view (they are shown when user clicks on a node or in filtered view) + return hiddenNodeIdsRef.current.has(d.id) ? "none" : null; + }) + .on("mouseenter", function (_event: PointerEvent, d) { + // Highlight entire path from this node + const pathEdges = getPathEdges(d.id, edgesData); + linkElements.each(function (edgeData) { + const edgeId = `${edgeData.sourceId}-${edgeData.targetId}`; + if (pathEdges.has(edgeId)) { + select(this) + .attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR) + .attr("marker-end", "url(#arrowhead-highlight)"); + } + }); + + // Change node border to highlight color on hover + const nodeGroup = select(this); + const nodeShape = nodeGroup.select(".node-shape"); + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindings.has(d.id); + + // Don't change border for resources with findings (keep red) + if (!hasFindings || isFinding) { + nodeShape.attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR); + } + }) + .on("mouseleave", function (_event: PointerEvent, d) { + const selectedId = selectedNodeIdRef.current; + + // Reset edges: keep selected node's path highlighted + const selectedPathEdges = selectedId + ? getPathEdges(selectedId, edgesData) + : new Set(); + + linkElements.each(function (edgeData) { + const edgeId = `${edgeData.sourceId}-${edgeData.targetId}`; + if (selectedPathEdges.has(edgeId)) { + select(this) + .attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR) + .attr("marker-end", "url(#arrowhead-highlight)"); + } else { + select(this) + .attr("stroke", edgeColor) + .attr("marker-end", "url(#arrowhead)"); + } + }); + + // Reset node border + const nodeGroup = select(this); + const nodeShape = nodeGroup.select(".node-shape"); + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const hasFindings = resourcesWithFindings.has(d.id); + + // Determine the correct border color + if (!isFinding && hasFindings) { + nodeShape.attr("stroke", GRAPH_ALERT_BORDER_COLOR); + } else if (d.id === selectedId) { + nodeShape.attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR); + } else { + nodeShape.attr( + "stroke", + getNodeBorderColor(d.data.labels, d.data.properties), + ); + } + }) + .on("click", function (event: PointerEvent, d) { + event.stopPropagation(); + + // Toggle visibility of connected finding nodes + const node = d.data; + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + if (!isFinding) { + // Find connected findings for THIS node + const connectedFindings = new Set(); + data.edges?.forEach((edge) => { + const sourceId = + typeof edge.source === "string" + ? edge.source + : (edge.source as GraphNode).id; + const targetId = + typeof edge.target === "string" + ? edge.target + : (edge.target as GraphNode).id; + + if (sourceId === node.id || targetId === node.id) { + const otherId = sourceId === node.id ? targetId : sourceId; + const otherNode = data.nodes.find((n) => n.id === otherId); + if ( + otherNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ) + ) { + connectedFindings.add(otherId); + } + } + }); + + // Clear hidden nodes and hide ALL findings + hiddenNodeIdsRef.current.clear(); + data.nodes.forEach((n) => { + const isNodeFinding = n.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + if (isNodeFinding) { + hiddenNodeIdsRef.current.add(n.id); + } + }); + + // Show ONLY the findings connected to the clicked node + connectedFindings.forEach((findingId) => { + hiddenNodeIdsRef.current.delete(findingId); + }); + + // Update node visibility + nodeElements.style( + "display", + function (nodeData: { + id: string; + x: number; + y: number; + data: GraphNode; + }) { + return hiddenNodeIdsRef.current.has(nodeData.id) ? "none" : null; + }, + ); + + // Update edge visibility + linkElements.style( + "visibility", + function (edgeData: { + source: { x: number; y: number }; + target: { x: number; y: number }; + id: string; + sourceId: string; + targetId: string; + }) { + // Resource-to-resource edges are ALWAYS visible + const sourceIsFinding = isNodeFinding(edgeData.sourceId); + const targetIsFinding = isNodeFinding(edgeData.targetId); + + if (!sourceIsFinding && !targetIsFinding) { + return "visible"; + } + + // Finding edges only visible when finding is not hidden + return hiddenNodeIdsRef.current.has(edgeData.sourceId) || + hiddenNodeIdsRef.current.has(edgeData.targetId) + ? "hidden" + : "visible"; + }, + ); + + // Auto-adjust view to show the selected node and its findings + setTimeout(() => { + if ( + svgSelectionRef.current && + zoomBehaviorRef.current && + containerRef.current && + svgRef.current + ) { + // Calculate bounding box of visible nodes (clicked node + its findings) + const visibleNodeIds = new Set([ + node.id, + ...Array.from(connectedFindings), + ]); + const visibleNodesData = nodesData.filter((n) => + visibleNodeIds.has(n.id), + ); + + if (visibleNodesData.length > 0) { + // Find min/max coordinates of visible nodes + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + visibleNodesData.forEach((n) => { + minX = Math.min(minX, n.x - NODE_WIDTH / 2); + maxX = Math.max(maxX, n.x + NODE_WIDTH / 2); + minY = Math.min(minY, n.y - NODE_HEIGHT / 2); + maxY = Math.max(maxY, n.y + NODE_HEIGHT / 2); + }); + + // Add padding + const padding = 80; + minX -= padding; + maxX += padding; + minY -= padding; + maxY += padding; + + // Get actual SVG dimensions from the DOM + const svgRect = svgRef.current.getBoundingClientRect(); + const fullWidth = svgRect.width; + const fullHeight = svgRect.height; + + const boxWidth = maxX - minX; + const boxHeight = maxY - minY; + const midX = minX + boxWidth / 2; + const midY = minY + boxHeight / 2; + + // Calculate scale to fit all visible nodes + const scale = + 0.9 / Math.max(boxWidth / fullWidth, boxHeight / fullHeight); + const tx = fullWidth / 2 - scale * midX; + const ty = fullHeight / 2 - scale * midY; + + svgSelectionRef.current + .transition() + .duration(500) + .call( + zoomBehaviorRef.current.transform, + zoomIdentity.translate(tx, ty).scale(scale), + ); + } + } + }, 50); + } + + onNodeClickRef.current?.(d.data); + }); + + // Add tooltip + nodeElements.append("title").text((d: (typeof nodesData)[0]): string => { + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const label = + d.data.labels && d.data.labels.length > 0 + ? formatNodeLabel(d.data.labels[0]) + : d.id; + + if (isFinding) { + return `${label}\nClick to view finding details`; + } else { + return `${label}\nClick to view related findings`; + } + }); + + // Build a set of resource nodes that have findings connected to them + const resourcesWithFindings = new Set(); + data.edges?.forEach((edge) => { + const sourceId = + typeof edge.source === "string" + ? edge.source + : (edge.source as GraphNode).id; + const targetId = + typeof edge.target === "string" + ? edge.target + : (edge.target as GraphNode).id; + + const sourceNode = nodeDataMap.get(sourceId); + const targetNode = nodeDataMap.get(targetId); + + const sourceIsFinding = sourceNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + const targetIsFinding = targetNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + + // If one end is a finding, the other is a resource with findings + if (sourceIsFinding && !targetIsFinding) { + resourcesWithFindings.add(targetId); + } + if (targetIsFinding && !sourceIsFinding) { + resourcesWithFindings.add(sourceId); + } + }); + + // Store in ref for use in selection updates + resourcesWithFindingsRef.current = resourcesWithFindings; + + // Add shapes - hexagons for findings, rounded pill shapes for resources + nodeElements.each(function (d) { + const group = select(this); + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + const nodeColor = getNodeColor(d.data.labels, d.data.properties); + const borderColor = getNodeBorderColor(d.data.labels, d.data.properties); + const hasFindings = resourcesWithFindings.has(d.id); + + if (isFinding) { + // Hexagon for findings - always has glow + const w = HEXAGON_WIDTH; + const h = HEXAGON_HEIGHT; + const sideInset = w * 0.15; + const hexPath = ` + M ${-w / 2 + sideInset} ${-h / 2} + L ${w / 2 - sideInset} ${-h / 2} + L ${w / 2} 0 + L ${w / 2 - sideInset} ${h / 2} + L ${-w / 2 + sideInset} ${h / 2} + L ${-w / 2} 0 + Z + `; + const isSelected = d.id === selectedNodeId; + group + .append("path") + .attr("d", hexPath) + .attr("fill", nodeColor) + .attr("fill-opacity", 0.85) + .attr("stroke", isSelected ? GRAPH_EDGE_HIGHLIGHT_COLOR : borderColor) + .attr("stroke-width", isSelected ? 4 : 2) + .attr("filter", isSelected ? "url(#selectedGlow)" : "url(#glow)") + .attr( + "class", + isSelected ? "node-shape selected-node" : "node-shape", + ); + } else { + // Check if this is an Internet node + const isInternet = d.data.labels.some( + (label) => label.toLowerCase() === "internet", + ); + + const isSelected = d.id === selectedNodeId; + + // Resources with findings get red border and red glow (even when selected) + // Selected nodes get orange border + const strokeColor = hasFindings + ? GRAPH_ALERT_BORDER_COLOR + : isSelected + ? GRAPH_EDGE_HIGHLIGHT_COLOR + : borderColor; + + // Determine filter: selected takes priority, then hasFindings, then default + const nodeFilter = isSelected + ? "url(#selectedGlow)" + : hasFindings + ? "url(#redGlow)" + : "url(#glow)"; + + const nodeClass = isSelected + ? "node-shape selected-node" + : "node-shape"; + + if (isInternet) { + // Globe shape for Internet nodes - larger than regular nodes + const radius = NODE_HEIGHT * 0.8; + + // Main circle + group + .append("circle") + .attr("cx", 0) + .attr("cy", 0) + .attr("r", radius) + .attr("fill", nodeColor) + .attr("fill-opacity", 0.85) + .attr("stroke", strokeColor) + .attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5) + .attr("filter", nodeFilter) + .attr("class", nodeClass); + + // Horizontal ellipse (equator) + group + .append("ellipse") + .attr("cx", 0) + .attr("cy", 0) + .attr("rx", radius) + .attr("ry", radius * 0.35) + .attr("fill", "none") + .attr("stroke", strokeColor) + .attr("stroke-width", 1) + .attr("stroke-opacity", 0.5); + + // Vertical ellipse (meridian) + group + .append("ellipse") + .attr("cx", 0) + .attr("cy", 0) + .attr("rx", radius * 0.35) + .attr("ry", radius) + .attr("fill", "none") + .attr("stroke", strokeColor) + .attr("stroke-width", 1) + .attr("stroke-opacity", 0.5); + } else { + // Rounded pill shape for other resources + group + .append("rect") + .attr("x", -NODE_WIDTH / 2) + .attr("y", -NODE_HEIGHT / 2) + .attr("width", NODE_WIDTH) + .attr("height", NODE_HEIGHT) + .attr("rx", NODE_RADIUS) + .attr("ry", NODE_RADIUS) + .attr("fill", nodeColor) + .attr("fill-opacity", 0.85) + .attr("stroke", strokeColor) + .attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5) + .attr("filter", nodeFilter) + .attr("class", nodeClass); + } + } + }); + + // Store references for updating selection later + const nodeShapes = nodeElements.selectAll(".node-shape"); + nodeShapesRef.current = nodeShapes as unknown as ReturnType< + typeof select + >; + + // Add label text - white text on all nodes (backgrounds are dark enough) + nodeElements.each(function (d) { + const group = select(this); + const isFinding = d.data.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + // Create text container - white text with shadow for readability + const textGroup = group + .append("text") + .attr("pointer-events", "none") + .attr("text-anchor", "middle") + .attr("dominant-baseline", "middle") + .attr("fill", "#ffffff") + .style("text-shadow", "0 1px 2px rgba(0,0,0,0.5)"); + + if (isFinding) { + // For findings: show check_title/name (severity is shown by color) + const title = String( + d.data.properties?.check_title || + d.data.properties?.name || + d.data.properties?.id || + "Finding", + ); + const maxChars = 24; + const displayTitle = + title.length > maxChars + ? title.substring(0, maxChars) + "..." + : title; + + textGroup + .append("tspan") + .attr("x", 0) + .attr("font-size", "11px") + .attr("font-weight", "600") + .text(displayTitle); + } else { + // For resources: show name with type below + const name = String( + d.data.properties?.name || + d.data.properties?.id || + (d.data.labels && d.data.labels.length > 0 + ? formatNodeLabel(d.data.labels[0]) + : "Unknown"), + ); + const maxChars = 22; + const displayName = + name.length > maxChars ? name.substring(0, maxChars) + "..." : name; + + // Name + textGroup + .append("tspan") + .attr("x", 0) + .attr("dy", "-0.3em") + .attr("font-size", "11px") + .attr("font-weight", "600") + .text(displayName); + + // Type label - slightly transparent white + const type = + d.data.labels && d.data.labels.length > 0 + ? formatNodeLabel(d.data.labels[0]) + : ""; + if (type) { + textGroup + .append("tspan") + .attr("x", 0) + .attr("dy", "1.3em") + .attr("font-size", "9px") + .attr("fill", "rgba(255,255,255,0.8)") + .text(type); + } + } + }); + + // Add zoom behavior + const zoomBehavior = zoom().on( + "zoom", + (event: D3ZoomEvent) => { + const transform = event.transform; + container.attr("transform", transform.toString()); + setZoomLevel(transform.k); + }, + ); + zoomBehaviorRef.current = zoomBehavior; + + svg.call(zoomBehavior); + + // Enable Ctrl + mouse wheel zoom only (disable regular scroll zoom) + svg.on("wheel.zoom", null); + svg.on("dblclick.zoom", null); + + // Custom wheel handler that only zooms when Ctrl is pressed + svg.on("wheel", function (event: WheelEvent) { + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + const currentTransform = container.attr("transform"); + const k = currentTransform + ? parseFloat(currentTransform.match(/scale\(([^)]+)\)/)?.[1] || "1") + : 1; + const scaleFactor = event.deltaY > 0 ? 0.75 : 1.35; + const newK = Math.max(0.1, Math.min(10, k * scaleFactor)); + + if (zoomBehaviorRef.current && svgSelectionRef.current) { + const svgNode = svgRef.current; + if (svgNode) { + const rect = svgNode.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + svgSelectionRef.current + .transition() + .duration(100) + .call(zoomBehaviorRef.current.scaleTo, newK, [mouseX, mouseY]); + } + } + } + }); + + // Auto-fit to screen + setTimeout(() => { + if ( + svgSelectionRef.current && + zoomBehaviorRef.current && + containerRef.current + ) { + const bounds = containerRef.current.node()?.getBBox(); + if (!bounds) return; + + const fullWidth = svgRef.current?.clientWidth || 800; + const fullHeight = svgRef.current?.clientHeight || 500; + + const midX = bounds.x + bounds.width / 2; + const midY = bounds.y + bounds.height / 2; + const scale = + 0.8 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); + const tx = fullWidth / 2 - scale * midX; + const ty = fullHeight / 2 - scale * midY; + + svgSelectionRef.current.call( + zoomBehaviorRef.current.transform, + zoomIdentity.translate(tx, ty).scale(scale), + ); + } + }, 100); + // D3's imperative rendering model requires controlled re-renders. + // We intentionally only re-render on data/view changes, not on callback refs + // (onNodeClick, selectedNodeId) which would cause unnecessary D3 re-renders. + // edgeColor is included to re-render when theme changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, isFilteredView, edgeColor]); + + return ( + + ); +}); + +AttackPathGraphComponent.displayName = "AttackPathGraph"; + +export const AttackPathGraph = AttackPathGraphComponent; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx new file mode 100644 index 0000000000..872cd57445 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Download, Minimize2, ZoomIn, ZoomOut } from "lucide-react"; + +import { Button } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; + +interface GraphControlsProps { + onZoomIn: () => void; + onZoomOut: () => void; + onFitToScreen: () => void; + onExport: () => void; +} + +/** + * Controls for graph visualization (zoom, pan, export) + * Positioned as floating toolbar above graph + */ +export const GraphControls = ({ + onZoomIn, + onZoomOut, + onFitToScreen, + onExport, +}: GraphControlsProps) => { + return ( +
+
+ + + + + + Zoom in + + + + + + + Zoom out + + + + + + + Fit graph to view + + + + + + + Export graph + + +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx new file mode 100644 index 0000000000..98fb5c6c7c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useTheme } from "next-themes"; + +import { Card, CardContent } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import type { AttackPathGraphData } from "@/types/attack-paths"; + +import { + getNodeBorderColor, + getNodeColor, + GRAPH_EDGE_COLOR_DARK, + GRAPH_EDGE_COLOR_LIGHT, + GRAPH_NODE_BORDER_COLORS, + GRAPH_NODE_COLORS, +} from "../../_lib/graph-colors"; + +interface LegendItem { + label: string; + color: string; + borderColor: string; + description: string; + shape: "rectangle" | "hexagon" | "cloud"; +} + +// Map node labels to human-readable names and descriptions +const nodeTypeDescriptions: Record< + string, + { name: string; description: string } +> = { + // Findings + ProwlerFinding: { + name: "Finding", + description: "Security findings from Prowler scans", + }, + // AWS Account + AWSAccount: { + name: "AWS Account", + description: "AWS account root node", + }, + // Compute + EC2Instance: { + name: "EC2 Instance", + description: "Elastic Compute Cloud instance", + }, + LambdaFunction: { + name: "Lambda Function", + description: "AWS Lambda serverless function", + }, + // Storage + S3Bucket: { + name: "S3 Bucket", + description: "Simple Storage Service bucket", + }, + // IAM + IAMRole: { + name: "IAM Role", + description: "Identity and Access Management role", + }, + IAMPolicy: { + name: "IAM Policy", + description: "Identity and Access Management policy", + }, + AWSRole: { + name: "AWS Role", + description: "AWS IAM role", + }, + AWSPolicy: { + name: "AWS Policy", + description: "AWS IAM policy", + }, + AWSInlinePolicy: { + name: "AWS Inline Policy", + description: "AWS IAM inline policy", + }, + AWSPolicyStatement: { + name: "AWS Policy Statement", + description: "AWS IAM policy statement", + }, + AWSPrincipal: { + name: "AWS Principal", + description: "AWS IAM principal entity", + }, + // Networking + SecurityGroup: { + name: "Security Group", + description: "AWS security group for network access control", + }, + EC2SecurityGroup: { + name: "EC2 Security Group", + description: "EC2 security group for network access control", + }, + IpPermissionInbound: { + name: "IP Permission Inbound", + description: "Inbound IP permission rule", + }, + IpRule: { + name: "IP Rule", + description: "IP address rule", + }, + Internet: { + name: "Internet", + description: "Internet gateway or public access", + }, + // Tags + AWSTag: { + name: "AWS Tag", + description: "AWS resource tag", + }, + Tag: { + name: "Tag", + description: "Resource tag", + }, +}; + +/** + * Extract unique node types from graph data + */ +function extractNodeTypes( + nodes: AttackPathGraphData["nodes"] | undefined, +): string[] { + if (!nodes) return []; + + const nodeTypes = new Set(); + nodes.forEach((node) => { + node.labels.forEach((label) => { + nodeTypes.add(label); + }); + }); + + return Array.from(nodeTypes).sort(); +} + +/** + * Severity legend items - colors work in both light and dark themes + */ +const severityLegendItems: LegendItem[] = [ + { + label: "Critical", + color: GRAPH_NODE_COLORS.critical, + borderColor: GRAPH_NODE_BORDER_COLORS.critical, + description: "Critical severity finding", + shape: "hexagon", + }, + { + label: "High", + color: GRAPH_NODE_COLORS.high, + borderColor: GRAPH_NODE_BORDER_COLORS.high, + description: "High severity finding", + shape: "hexagon", + }, + { + label: "Medium", + color: GRAPH_NODE_COLORS.medium, + borderColor: GRAPH_NODE_BORDER_COLORS.medium, + description: "Medium severity finding", + shape: "hexagon", + }, + { + label: "Low", + color: GRAPH_NODE_COLORS.low, + borderColor: GRAPH_NODE_BORDER_COLORS.low, + description: "Low severity finding", + shape: "hexagon", + }, +]; + +/** + * Generate legend items from graph data + */ +function generateLegendItems( + nodeTypes: string[], + hasFindings: boolean, +): LegendItem[] { + const items: LegendItem[] = []; + const seenTypes = new Set(); + + // Add severity items if there are findings + if (hasFindings) { + items.push(...severityLegendItems); + } + + // Helper to format unknown node types (e.g., "AWSPolicyStatement" -> "AWS Policy Statement") + const formatNodeTypeName = (nodeType: string): string => { + return nodeType + .replace(/([A-Z])/g, " $1") // Add space before capitals + .replace(/^ /, "") // Remove leading space + .replace(/AWS /g, "AWS ") // Keep AWS together + .replace(/EC2 /g, "EC2 ") // Keep EC2 together + .replace(/S3 /g, "S3 ") // Keep S3 together + .replace(/IAM /g, "IAM ") // Keep IAM together + .replace(/IP /g, "IP ") // Keep IP together + .trim(); + }; + + nodeTypes.forEach((nodeType) => { + if (seenTypes.has(nodeType)) return; + seenTypes.add(nodeType); + + // Skip findings - we show severity colors instead + const isFinding = nodeType.toLowerCase().includes("finding"); + if (isFinding) return; + + const description = nodeTypeDescriptions[nodeType]; + + // Determine shape based on node type + const isInternet = nodeType.toLowerCase() === "internet"; + const shape: "rectangle" | "hexagon" | "cloud" = isInternet + ? "cloud" + : "rectangle"; + + if (description) { + items.push({ + label: description.name, + color: getNodeColor([nodeType]), + borderColor: getNodeBorderColor([nodeType]), + description: description.description, + shape, + }); + } else { + // Format unknown node types nicely + const formattedName = formatNodeTypeName(nodeType); + items.push({ + label: formattedName, + color: getNodeColor([nodeType]), + borderColor: getNodeBorderColor([nodeType]), + description: `${formattedName} node`, + shape, + }); + } + }); + + return items; +} + +/** + * Hexagon shape component for legend + */ +const HexagonShape = ({ + color, + borderColor, +}: { + color: string; + borderColor: string; +}) => ( + +); + +/** + * Pill shape component for legend + */ +const PillShape = ({ + color, + borderColor, +}: { + color: string; + borderColor: string; +}) => ( + +); + +/** + * Globe shape component for legend (used for Internet nodes) + */ +const GlobeShape = ({ + color, + borderColor, +}: { + color: string; + borderColor: string; +}) => ( + +); + +/** + * Edge line component for legend + */ +const EdgeLine = ({ + dashed, + edgeColor, +}: { + dashed: boolean; + edgeColor: string; +}) => ( + +); + +interface GraphLegendProps { + data?: AttackPathGraphData; +} + +/** + * Legend for attack path graph node types and edge styles + */ +export const GraphLegend = ({ data }: GraphLegendProps) => { + const { resolvedTheme } = useTheme(); + const nodeTypes = extractNodeTypes(data?.nodes); + + // Get edge color based on current theme + const edgeColor = + resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT; + + // Check if there are any findings in the data + const hasFindings = nodeTypes.some((type) => + type.toLowerCase().includes("finding"), + ); + + const legendItems = generateLegendItems(nodeTypes, hasFindings); + + if (legendItems.length === 0) { + return null; + } + + return ( + + +
+ {/* Node types section */} +
+ + {legendItems.map((item) => ( + + +
+ {item.shape === "hexagon" ? ( + + ) : item.shape === "cloud" ? ( + + ) : ( + + )} + + {item.label} + +
+
+ {item.description} +
+ ))} +
+
+ + {/* Edge types section */} +
+ + + +
+ + + Resource Connection + +
+
+ + Connection between infrastructure resources + +
+ + {hasFindings && ( + + +
+ + + Finding Connection + +
+
+ + Connection to a security finding + +
+ )} +
+
+ + {/* Zoom control hint */} +
+ + Ctrl + + + + + Scroll to zoom + +
+
+
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx new file mode 100644 index 0000000000..cf56231a05 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; + +/** + * Loading skeleton for graph visualization + * Shows while graph data is being fetched and processed + */ +export const GraphLoading = () => { + return ( +
+
+
+ + + +
+

+ Loading Attack Paths graph... +

+
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts new file mode 100644 index 0000000000..ae529f31cd --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts @@ -0,0 +1,5 @@ +export type { AttackPathGraphRef } from "./attack-path-graph"; +export { AttackPathGraph } from "./attack-path-graph"; +export { GraphControls } from "./graph-controls"; +export { GraphLegend } from "./graph-legend"; +export { GraphLoading } from "./graph-loading"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts new file mode 100644 index 0000000000..eac86fccc7 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts @@ -0,0 +1,7 @@ +export { ExecuteButton } from "./execute-button"; +export * from "./graph"; +export * from "./node-detail"; +export { QueryParametersForm } from "./query-parameters-form"; +export { QuerySelector } from "./query-selector"; +export { ScanListTable } from "./scan-list-table"; +export { ScanStatusBadge } from "./scan-status-badge"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts new file mode 100644 index 0000000000..c5895fe51f --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts @@ -0,0 +1,4 @@ +export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel"; +export { NodeOverview } from "./node-overview"; +export { NodeRelationships } from "./node-relationships"; +export { NodeRemediation } from "./node-remediation"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx new file mode 100644 index 0000000000..4d8885e65c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button, Card, CardContent } from "@/components/shadcn"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet/sheet"; +import type { GraphNode } from "@/types/attack-paths"; + +import { NodeFindings } from "./node-findings"; +import { NodeOverview } from "./node-overview"; +import { NodeResources } from "./node-resources"; + +interface NodeDetailPanelProps { + node: GraphNode | null; + allNodes?: GraphNode[]; + onClose?: () => void; +} + +/** + * Node details content component (reusable) + */ +export const NodeDetailContent = ({ + node, + allNodes = [], +}: { + node: GraphNode; + allNodes?: GraphNode[]; +}) => { + const isProwlerFinding = node?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + return ( +
+ {/* Node Overview Section */} + + +

+ Node Overview +

+ +
+
+ + {/* Related Findings Section - Only show for non-Finding nodes */} + {!isProwlerFinding && ( + + +

+ Related Findings +

+
+ Findings connected to this node +
+ +
+
+ )} + + {/* Affected Resources Section - Only show for Finding nodes */} + {isProwlerFinding && ( + + +

+ Affected Resources +

+
+ Resources affected by this finding +
+ +
+
+ )} +
+ ); +}; + +/** + * Right-side sheet panel for node details + * Shows comprehensive information about selected graph node + * Uses shadcn Sheet component for sliding panel from right + */ +export const NodeDetailPanel = ({ + node, + allNodes = [], + onClose, +}: NodeDetailPanelProps) => { + const isOpen = node !== null; + + const isProwlerFinding = node?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + return ( + !open && onClose?.()}> + + +
+
+ Node Details + + {String(node?.properties?.name || node?.id.substring(0, 20))} + +
+ {node && isProwlerFinding && ( + + )} +
+
+ + {node && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx new file mode 100644 index 0000000000..bb424a818c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { SeverityBadge } from "@/components/ui/table/severity-badge"; +import type { GraphNode } from "@/types/attack-paths"; + +const SEVERITY_LEVELS = { + informational: "informational", + low: "low", + medium: "medium", + high: "high", + critical: "critical", +} as const; + +type Severity = (typeof SEVERITY_LEVELS)[keyof typeof SEVERITY_LEVELS]; + +interface NodeFindingsProps { + node: GraphNode; + allNodes?: GraphNode[]; +} + +/** + * Node findings section showing related findings for the selected node + * Displays findings that are connected to the node via HAS_FINDING edges + */ +export const NodeFindings = ({ node, allNodes = [] }: NodeFindingsProps) => { + // Get finding IDs from the node's findings array (populated by adapter) + const findingIds = node.findings || []; + + // Get the actual finding nodes + const findingNodes = allNodes.filter((n) => findingIds.includes(n.id)); + + if (findingNodes.length === 0) { + return null; + } + + const normalizeSeverity = ( + severity?: string | number | boolean | string[] | number[] | null, + ): Severity => { + const sev = String( + Array.isArray(severity) ? severity[0] : severity || "", + ).toLowerCase(); + if (sev in SEVERITY_LEVELS) { + return sev as Severity; + } + return "informational"; + }; + + return ( +
    + {findingNodes.map((finding) => { + // Get the finding name (check_title preferred, then name) + const findingName = String( + finding.properties?.check_title || + finding.properties?.name || + finding.properties?.finding_id || + "Unknown Finding", + ); + // Use properties.id for display, fallback to graph node id + const findingId = String(finding.properties?.id || finding.id); + + return ( +
  • +
    +
    +
    + {finding.properties?.severity && ( + + )} +
    + {findingName} +
    +
    +

    + ID: {findingId} +

    +
    + + View Full Finding β†’ + +
    + {finding.properties?.description && ( +
    + {String(finding.properties.description)} +
    + )} +
  • + ); + })} +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx new file mode 100644 index 0000000000..e614ce521a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { InfoField } from "@/components/ui/entities"; +import { DateWithTime } from "@/components/ui/entities/date-with-time"; +import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths"; + +import { formatNodeLabels } from "../../_lib"; + +interface NodeOverviewProps { + node: GraphNode; +} + +/** + * Node overview section showing basic node information + */ +export const NodeOverview = ({ node }: NodeOverviewProps) => { + const renderValue = (value: GraphNodePropertyValue) => { + if (value === null || value === undefined || value === "") { + return "-"; + } + if (Array.isArray(value)) { + return value.join(", "); + } + return String(value); + }; + + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + return ( +
+
+ {formatNodeLabels(node.labels)} + {isFinding && node.properties.check_title && ( + + {String(node.properties.check_title)} + + )} + {isFinding && node.properties.id && ( + + + + )} +
+ + {/* Display all properties */} +
+

+ Properties +

+
+ {Object.entries(node.properties).map(([key, value]) => { + // Skip internal properties + if (key.startsWith("_")) { + return null; + } + + // Skip check_title and id for findings as they're shown prominently above + if (isFinding && (key === "check_title" || key === "id")) { + return null; + } + + // Format timestamp values + const isTimestamp = + key.includes("date") || + key.includes("time") || + key.includes("at") || + key.includes("seen"); + + return ( + + {isTimestamp && typeof value === "number" ? ( + + ) : isTimestamp && + typeof value === "string" && + value.match(/^\d+$/) ? ( + + ) : typeof value === "object" ? ( + + {JSON.stringify(value).substring(0, 50)}... + + ) : ( + renderValue(value) + )} + + ); + })} +
+
+
+ ); +}; + +// Helper function to format property names +function formatPropertyName(name: string): string { + return name + .replace(/([A-Z])/g, " $1") + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + .trim(); +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx new file mode 100644 index 0000000000..7370204990 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { GraphEdge } from "@/types/attack-paths"; + +interface NodeRelationshipsProps { + incomingEdges: GraphEdge[]; + outgoingEdges: GraphEdge[]; +} + +/** + * Format edge type to human-readable label + * e.g., "HAS_FINDING" -> "Has Finding" + */ +function formatEdgeType(edgeType: string): string { + return edgeType + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +interface EdgeItemProps { + edge: GraphEdge; + isOutgoing: boolean; +} + +/** + * Reusable edge item component + */ +function EdgeItem({ edge, isOutgoing }: EdgeItemProps) { + const targetId = + typeof edge.target === "string" ? edge.target : String(edge.target); + const sourceId = + typeof edge.source === "string" ? edge.source : String(edge.source); + const displayId = (isOutgoing ? targetId : sourceId).substring(0, 30); + + return ( +
+ + {displayId} + + + {formatEdgeType(edge.type)} + +
+ ); +} + +/** + * Node relationships section showing incoming and outgoing edges + */ +export const NodeRelationships = ({ + incomingEdges, + outgoingEdges, +}: NodeRelationshipsProps) => { + return ( +
+ {/* Outgoing Relationships */} +
+

+ Outgoing Relationships ({outgoingEdges.length}) +

+ {outgoingEdges.length > 0 ? ( +
+ {outgoingEdges.map((edge) => ( + + ))} +
+ ) : ( +

+ No outgoing relationships +

+ )} +
+ + {/* Incoming Relationships */} +
+

+ Incoming Relationships ({incomingEdges.length}) +

+ {incomingEdges.length > 0 ? ( +
+ {incomingEdges.map((edge) => ( + + ))} +
+ ) : ( +

+ No incoming relationships +

+ )} +
+
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx new file mode 100644 index 0000000000..e3421e5a43 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; + +import { Badge } from "@/components/shadcn/badge/badge"; + +interface Finding { + id: string; + title: string; + severity: "critical" | "high" | "medium" | "low" | "info"; + status: "PASS" | "FAIL" | "MANUAL"; +} + +interface NodeRemediationProps { + findings: Finding[]; +} + +/** + * Node remediation section showing related Prowler findings + */ +export const NodeRemediation = ({ findings }: NodeRemediationProps) => { + const getSeverityVariant = (severity: string) => { + switch (severity) { + case "critical": + return "destructive"; + case "high": + return "default"; + case "medium": + return "secondary"; + case "low": + return "outline"; + default: + return "default"; + } + }; + + const getStatusVariant = (status: string) => { + if (status === "PASS") return "default"; + if (status === "FAIL") return "destructive"; + return "secondary"; + }; + + return ( +
+ {findings.map((finding) => ( +
+
+
+
+ {finding.title} +
+

+ ID: {finding.id.substring(0, 12)}... +

+
+
+ + {finding.severity} + + + {finding.status} + +
+
+
+ + View Full Finding β†’ + +
+
+ ))} +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-resources.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-resources.tsx new file mode 100644 index 0000000000..47f1f8db5f --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-resources.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { cn } from "@/lib/utils"; +import type { GraphNode } from "@/types/attack-paths"; + +interface NodeResourcesProps { + node: GraphNode; + allNodes?: GraphNode[]; +} + +/** + * Node resources section showing affected resources for the selected finding node + * Displays resources that are connected to the finding node via HAS_FINDING edges + */ +export const NodeResources = ({ node, allNodes = [] }: NodeResourcesProps) => { + // Get resource IDs from the node's resources array (populated by adapter) + const resourceIds = node.resources || []; + + // Get the actual resource nodes + const resourceNodes = allNodes.filter((n) => resourceIds.includes(n.id)); + + if (resourceNodes.length === 0) { + return null; + } + + const getResourceTypeColor = (labels: string[]): string => { + const label = (labels[0] || "").toLowerCase(); + switch (label) { + case "s3bucket": + case "awsaccount": + case "ec2instance": + case "iamrole": + case "lambdafunction": + case "securitygroup": + return "bg-bg-data-aws"; + default: + return "bg-bg-data-muted"; + } + }; + + return ( +
    + {resourceNodes.map((resource) => { + // Use properties.id for display, fallback to graph node id + const resourceId = String(resource.properties?.id || resource.id); + + return ( +
  • +
    +
    +
    + {resource.labels && ( + + {resource.labels[0]} + + )} +
    + {String(resource.properties?.name || resourceId)} +
    +
    +

    + ID: {resourceId} +

    +
    +
    + {resource.properties?.arn && ( +
    + ARN: {String(resource.properties.arn)} +
    + )} +
  • + ); + })} +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx new file mode 100644 index 0000000000..ccbcf60547 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Controller, useFormContext } from "react-hook-form"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +interface QueryParametersFormProps { + selectedQuery: AttackPathQuery | null | undefined; +} + +/** + * Dynamic form component for query parameters + * Renders form fields based on selected query's parameters + */ +export const QueryParametersForm = ({ + selectedQuery, +}: QueryParametersFormProps) => { + const { + control, + formState: { errors }, + } = useFormContext(); + + if (!selectedQuery || !selectedQuery.attributes.parameters.length) { + return ( +
+

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

+
+ ); + } + + return ( +
+

+ Query Parameters +

+ + {selectedQuery.attributes.parameters.map((param) => ( + { + if (param.data_type === "boolean") { + return ( +
+ +
+ ); + } + + const errorMessage = (() => { + const error = errors[param.name]; + if (error && typeof error.message === "string") { + return error.message; + } + return undefined; + })(); + + const descriptionId = `${param.name}-description`; + return ( +
+ + + {param.description && ( + + {param.description} + + )} + {errorMessage && ( + {errorMessage} + )} +
+ ); + }} + /> + ))} +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx new file mode 100644 index 0000000000..65acb8a84a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-selector.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn"; +import type { AttackPathQuery } from "@/types/attack-paths"; + +interface QuerySelectorProps { + queries: AttackPathQuery[]; + selectedQueryId: string | null; + onQueryChange: (queryId: string) => void; +} + +/** + * Query selector dropdown component + * Allows users to select from available Attack Paths queries + */ +export const QuerySelector = ({ + queries, + selectedQueryId, + onQueryChange, +}: QuerySelectorProps) => { + return ( + + ); +}; 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 new file mode 100644 index 0000000000..8e10305617 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/shadcn/button/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { DateWithTime } from "@/components/ui/entities/date-with-time"; +import { EntityInfo } from "@/components/ui/entities/entity-info"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import type { ProviderType } from "@/types"; +import type { AttackPathScan } from "@/types/attack-paths"; +import { SCAN_STATES } from "@/types/attack-paths"; + +import { ScanStatusBadge } from "./scan-status-badge"; + +interface ScanListTableProps { + scans: AttackPathScan[]; +} + +const TABLE_COLUMN_COUNT = 6; +const DEFAULT_PAGE_SIZE = 5; +const PAGE_SIZE_OPTIONS = [2, 5, 10, 15]; + +const baseLinkClass = + "relative block rounded border-0 bg-transparent px-3 py-1.5 text-button-primary outline-none transition-all duration-300 hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary focus:shadow-none dark:hover:bg-bg-neutral-secondary dark:hover:text-text-neutral-primary"; + +const disabledLinkClass = + "text-border-neutral-secondary dark:text-border-neutral-secondary hover:bg-transparent hover:text-border-neutral-secondary dark:hover:text-border-neutral-secondary cursor-default pointer-events-none"; + +/** + * Table displaying AWS account Attack Paths scans + * Shows scan metadata and allows selection of completed scans + */ +export const ScanListTable = ({ scans }: ScanListTableProps) => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const selectedScanId = searchParams.get("scanId"); + const currentPage = parseInt(searchParams.get("scanPage") ?? "1"); + const pageSize = parseInt( + searchParams.get("scanPageSize") ?? String(DEFAULT_PAGE_SIZE), + ); + const [selectedPageSize, setSelectedPageSize] = useState(String(pageSize)); + + const totalPages = Math.ceil(scans.length / pageSize); + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedScans = scans.slice(startIndex, endIndex); + + const handleSelectScan = (scanId: string) => { + const params = new URLSearchParams(searchParams); + params.set("scanId", scanId); + router.push(`${pathname}?${params.toString()}`); + }; + + const isSelectDisabled = (scan: AttackPathScan) => { + return ( + scan.attributes.state !== SCAN_STATES.COMPLETED || + selectedScanId === scan.id + ); + }; + + const getSelectButtonLabel = (scan: AttackPathScan) => { + if (selectedScanId === scan.id) { + return "Selected"; + } + if (scan.attributes.state === SCAN_STATES.SCHEDULED) { + return "Scheduled"; + } + if (scan.attributes.state === SCAN_STATES.EXECUTING) { + return "Waiting..."; + } + if (scan.attributes.state === SCAN_STATES.FAILED) { + return "Failed"; + } + return "Select"; + }; + + const createPageUrl = (pageNumber: number | string) => { + const params = new URLSearchParams(searchParams); + + // Preserve scanId if it exists + const scanId = searchParams.get("scanId"); + + if (+pageNumber > totalPages) { + return `${pathname}?${params.toString()}`; + } + + params.set("scanPage", pageNumber.toString()); + + // Ensure that scanId is preserved + if (scanId) params.set("scanId", scanId); + + return `${pathname}?${params.toString()}`; + }; + + const isFirstPage = currentPage === 1; + const isLastPage = currentPage === totalPages; + + return ( + <> +
+ + + + Provider / Account + Last Scan Date + Status + Progress + Duration + Action + + + + {scans.length === 0 ? ( + + + No Attack Paths scans available. + + + ) : ( + paginatedScans.map((scan) => { + const isDisabled = isSelectDisabled(scan); + const isSelected = selectedScanId === scan.id; + const duration = scan.attributes.duration + ? `${Math.floor(scan.attributes.duration / 60)}m ${scan.attributes.duration % 60}s` + : "-"; + + return ( + + + + + + {scan.attributes.completed_at ? ( + + ) : ( + "-" + )} + + + + + + + {scan.attributes.progress}% + + + + {duration} + + + + + + ); + }) + )} + +
+ + {/* Pagination Controls */} + {scans.length > 0 && ( +
+
+ {scans.length} scans in total +
+ {scans.length > DEFAULT_PAGE_SIZE && ( +
+ {/* Rows per page selector */} +
+

+ Rows per page +

+ +
+
+ Page {currentPage} of {totalPages} +
+
+ isFirstPage && e.preventDefault()} + > +
+
+ )} +
+ )} +
+

+ Only Attack Paths scans with "Completed" status can be + selected. Scans in progress will update automatically. +

+ + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx new file mode 100644 index 0000000000..74c7302126 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-status-badge.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Loader2 } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import type { ScanState } from "@/types/attack-paths"; + +interface ScanStatusBadgeProps { + status: ScanState; + progress?: number; +} + +/** + * Status badge for attack path scan status + * Shows visual indicator and text for scan progress + */ +export const ScanStatusBadge = ({ + status, + progress = 0, +}: ScanStatusBadgeProps) => { + if (status === "scheduled") { + return ( + + Scheduled + + ); + } + + if (status === "available") { + return ( + + Queued + + ); + } + + if (status === "executing") { + return ( + + + In Progress ({progress}%) + + ); + } + + if (status === "completed") { + return ( + + Completed + + ); + } + + return ( + + Failed + + ); +}; 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 new file mode 100644 index 0000000000..eec8e5f81a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts @@ -0,0 +1,3 @@ +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-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts new file mode 100644 index 0000000000..8af36bbcfb --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts @@ -0,0 +1,182 @@ +"use client"; + +import { create } from "zustand"; + +import type { + AttackPathGraphData, + GraphNode, + GraphState, +} from "@/types/attack-paths"; + +import { computeFilteredSubgraph } from "../_lib"; + +interface FilteredViewState { + isFilteredView: boolean; + filteredNodeId: string | null; + fullData: AttackPathGraphData | null; // Original data before filtering +} + +interface GraphStore extends GraphState, FilteredViewState { + setGraphData: (data: AttackPathGraphData) => void; + setSelectedNodeId: (nodeId: string | null) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setZoom: (zoomLevel: number) => void; + setPan: (panX: number, panY: number) => void; + setFilteredView: ( + isFiltered: boolean, + nodeId: string | null, + filteredData: AttackPathGraphData | null, + fullData: AttackPathGraphData | null, + ) => void; + reset: () => void; +} + +const initialState: GraphState & FilteredViewState = { + data: null, + selectedNodeId: null, + loading: false, + error: null, + zoomLevel: 1, + panX: 0, + panY: 0, + isFilteredView: false, + filteredNodeId: null, + fullData: null, +}; + +const useGraphStore = create((set) => ({ + ...initialState, + setGraphData: (data) => + set({ + data, + fullData: null, + error: null, + isFilteredView: false, + filteredNodeId: null, + }), + setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }), + setLoading: (loading) => set({ loading }), + setError: (error) => set({ error }), + setZoom: (zoomLevel) => set({ zoomLevel }), + setPan: (panX, panY) => set({ panX, panY }), + setFilteredView: (isFiltered, nodeId, filteredData, fullData) => + set({ + isFilteredView: isFiltered, + filteredNodeId: nodeId, + data: filteredData, + fullData, + selectedNodeId: nodeId, + }), + reset: () => set(initialState), +})); + +/** + * Custom hook for managing graph visualization state + * Handles graph data, node selection, zoom/pan, loading states, and filtered view + */ +export const useGraphState = () => { + const store = useGraphStore(); + + // Zustand store methods are stable, no need to memoize + const updateGraphData = (data: AttackPathGraphData) => { + store.setGraphData(data); + }; + + const selectNode = (nodeId: string | null) => { + store.setSelectedNodeId(nodeId); + }; + + const getSelectedNode = (): GraphNode | null => { + if (!store.data?.nodes || !store.selectedNodeId) return null; + return ( + store.data.nodes.find((node) => node.id === store.selectedNodeId) || null + ); + }; + + const startLoading = () => { + store.setLoading(true); + }; + + const stopLoading = () => { + store.setLoading(false); + }; + + const setError = (error: string | null) => { + store.setError(error); + }; + + const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => { + store.setZoom(zoomLevel); + store.setPan(panX, panY); + }; + + const resetGraph = () => { + store.reset(); + }; + + const clearGraph = () => { + store.setGraphData({ nodes: [], edges: [] }); + store.setSelectedNodeId(null); + store.setFilteredView(false, null, null, null); + }; + + /** + * Enter filtered view mode - redraws graph with only the selected path + * Stores full data so we can restore it when exiting filtered view + */ + const enterFilteredView = (nodeId: string) => { + if (!store.data) return; + + // Use fullData if we're already in filtered view, otherwise use current data + const sourceData = store.fullData || store.data; + const filteredData = computeFilteredSubgraph(sourceData, nodeId); + store.setFilteredView(true, nodeId, filteredData, sourceData); + }; + + /** + * Exit filtered view mode - restore full graph data + */ + const exitFilteredView = () => { + if (!store.isFilteredView || !store.fullData) return; + store.setFilteredView(false, null, store.fullData, null); + }; + + /** + * Get the node that was used to filter the view + */ + const getFilteredNode = (): GraphNode | null => { + if (!store.isFilteredView || !store.filteredNodeId) return null; + // Look in fullData since that's where the original node data is + const sourceData = store.fullData || store.data; + if (!sourceData) return null; + return ( + sourceData.nodes.find((node) => node.id === store.filteredNodeId) || null + ); + }; + + return { + data: store.data, + fullData: store.fullData, + selectedNodeId: store.selectedNodeId, + selectedNode: getSelectedNode(), + loading: store.loading, + error: store.error, + zoomLevel: store.zoomLevel, + panX: store.panX, + panY: store.panY, + isFilteredView: store.isFilteredView, + filteredNodeId: store.filteredNodeId, + filteredNode: getFilteredNode(), + updateGraphData, + selectNode, + startLoading, + stopLoading, + setError, + updateZoomAndPan, + resetGraph, + clearGraph, + enterFilteredView, + exitFilteredView, + }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts new file mode 100644 index 0000000000..b05c9d463c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-query-builder.ts @@ -0,0 +1,98 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +/** + * Custom hook for managing query builder form state + * Handles query selection, parameter validation, and form submission + */ +export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => { + const [selectedQuery, setSelectedQuery] = useState(null); + + // Generate dynamic Zod schema based on selected query parameters + const getValidationSchema = (queryId: string | null) => { + const schemaObject: Record = {}; + + if (queryId) { + const query = availableQueries.find((q) => q.id === queryId); + + if (query) { + query.attributes.parameters.forEach((param) => { + let fieldSchema: z.ZodTypeAny = z + .string() + .min(1, `${param.label} is required`); + + if (param.data_type === "number") { + fieldSchema = z.coerce.number().refine((val) => val >= 0, { + message: `${param.label} must be a non-negative number`, + }); + } else if (param.data_type === "boolean") { + fieldSchema = z.boolean().default(false); + } + + schemaObject[param.name] = fieldSchema; + }); + } + } + + return z.object(schemaObject); + }; + + const getDefaultValues = (queryId: string | null) => { + const defaults: Record = {}; + + const query = availableQueries.find((q) => q.id === queryId); + if (query) { + query.attributes.parameters.forEach((param) => { + defaults[param.name] = param.data_type === "boolean" ? false : ""; + }); + } + + return defaults; + }; + + const form = useForm({ + resolver: zodResolver(getValidationSchema(selectedQuery)), + mode: "onChange", + defaultValues: getDefaultValues(selectedQuery), + }); + + // Update form when selectedQuery changes + useEffect(() => { + form.reset(getDefaultValues(selectedQuery), { + keepDirtyValues: false, + }); + }, [selectedQuery]); // eslint-disable-line react-hooks/exhaustive-deps + + const selectedQueryData = availableQueries.find( + (q) => q.id === selectedQuery, + ); + + const handleQueryChange = (queryId: string) => { + setSelectedQuery(queryId); + form.reset(); + }; + + const getQueryParameters = () => { + return form.getValues(); + }; + + const isFormValid = () => { + return form.formState.isValid; + }; + + return { + selectedQuery, + selectedQueryData, + availableQueries, + form, + handleQueryChange, + getQueryParameters, + isFormValid, + }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts new file mode 100644 index 0000000000..787cb56617 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-wizard-state.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback } from "react"; +import { create } from "zustand"; + +import type { WizardState } from "@/types/attack-paths"; + +interface WizardStore extends WizardState { + setCurrentStep: (step: 1 | 2) => void; + setSelectedScanId: (scanId: string) => void; + setSelectedQuery: (queryId: string) => void; + setQueryParameters: ( + parameters: Record, + ) => void; + reset: () => void; +} + +const initialState: WizardState = { + currentStep: 1, + selectedScanId: null, + selectedQuery: null, + queryParameters: {}, +}; + +const useWizardStore = create((set) => ({ + ...initialState, + setCurrentStep: (step) => set({ currentStep: step }), + setSelectedScanId: (scanId) => set({ selectedScanId: scanId }), + setSelectedQuery: (queryId) => set({ selectedQuery: queryId }), + setQueryParameters: (parameters) => set({ queryParameters: parameters }), + reset: () => set(initialState), +})); + +/** + * Custom hook for managing Attack Paths wizard state + * Handles step navigation, scan selection, and query configuration + */ +export const useWizardState = () => { + const router = useRouter(); + + const store = useWizardStore(); + + // Derive current step from URL path + const currentStep: 1 | 2 = + typeof window !== "undefined" + ? window.location.pathname.includes("query-builder") + ? 2 + : 1 + : 1; + + const goToSelectScan = useCallback(() => { + store.setCurrentStep(1); + router.push("/attack-paths/select-scan"); + }, [router, store]); + + const goToQueryBuilder = useCallback( + (scanId: string) => { + store.setSelectedScanId(scanId); + store.setCurrentStep(2); + router.push(`/attack-paths/query-builder?scanId=${scanId}`); + }, + [router, store], + ); + + const updateQueryParameters = useCallback( + (parameters: Record) => { + store.setQueryParameters(parameters); + }, + [store], + ); + + const getScanIdFromUrl = useCallback(() => { + const params = new URLSearchParams( + typeof window !== "undefined" ? window.location.search : "", + ); + return params.get("scanId") || store.selectedScanId; + }, [store.selectedScanId]); + + return { + currentStep, + selectedScanId: store.selectedScanId || getScanIdFromUrl(), + selectedQuery: store.selectedQuery, + queryParameters: store.queryParameters, + goToSelectScan, + goToQueryBuilder, + setSelectedQuery: store.setSelectedQuery, + updateQueryParameters, + reset: store.reset, + }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts new file mode 100644 index 0000000000..fd04b6a31e --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts @@ -0,0 +1,145 @@ +/** + * Export utilities for attack path graphs + * Handles exporting graph visualization to various formats + */ + +/** + * Helper function to download a blob as a file + * @param blob The blob to download + * @param filename The name of the file + */ +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +/** + * Export graph as SVG image + * @param svgElement The SVG element to export + * @param filename The name of the file to download + */ +export const exportGraphAsSVG = ( + svgElement: SVGSVGElement | null, + filename: string = "attack-path-graph.svg", +) => { + if (!svgElement) return; + + try { + // Clone the SVG element to avoid modifying the original + const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement; + + // Find the main container group (first g element with transform) + const containerGroup = clonedSvg.querySelector("g"); + if (!containerGroup) { + throw new Error("Could not find graph container"); + } + + // Get the bounding box of the actual graph content + // We need to get it from the original SVG since cloned elements don't have computed geometry + const originalContainer = svgElement.querySelector("g"); + if (!originalContainer) { + throw new Error("Could not find original graph container"); + } + + const bbox = originalContainer.getBBox(); + + // Add padding around the content + const padding = 50; + const contentWidth = bbox.width + padding * 2; + const contentHeight = bbox.height + padding * 2; + + // Set the SVG dimensions to fit the content + clonedSvg.setAttribute("width", `${contentWidth}`); + clonedSvg.setAttribute("height", `${contentHeight}`); + clonedSvg.setAttribute( + "viewBox", + `${bbox.x - padding} ${bbox.y - padding} ${contentWidth} ${contentHeight}`, + ); + + // Remove the zoom transform from the container - the viewBox now handles positioning + containerGroup.removeAttribute("transform"); + + // Add white background for better visibility + const bgRect = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ); + bgRect.setAttribute("x", `${bbox.x - padding}`); + bgRect.setAttribute("y", `${bbox.y - padding}`); + bgRect.setAttribute("width", `${contentWidth}`); + bgRect.setAttribute("height", `${contentHeight}`); + bgRect.setAttribute("fill", "#1c1917"); // Dark background matching the app + clonedSvg.insertBefore(bgRect, clonedSvg.firstChild); + + const svgData = new XMLSerializer().serializeToString(clonedSvg); + const blob = new Blob([svgData], { type: "image/svg+xml" }); + downloadBlob(blob, filename); + } catch (error) { + console.error("Failed to export graph as SVG:", error); + throw new Error("Failed to export graph"); + } +}; + +/** + * Export graph as PNG image + * @param svgElement The SVG element to export + * @param filename The name of the file to download + */ +export const exportGraphAsPNG = async ( + svgElement: SVGSVGElement | null, + filename: string = "attack-path-graph.png", +) => { + if (!svgElement) return; + + try { + const svgData = new XMLSerializer().serializeToString(svgElement); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + + if (!ctx) throw new Error("Could not get canvas context"); + + const svg = new Image(); + svg.onload = () => { + canvas.width = svg.width; + canvas.height = svg.height; + ctx.drawImage(svg, 0, 0); + canvas.toBlob((blob) => { + if (blob) { + downloadBlob(blob, filename); + } + }); + }; + svg.onerror = () => { + throw new Error("Failed to load SVG for PNG conversion"); + }; + svg.src = `data:image/svg+xml;base64,${btoa(svgData)}`; + } catch (error) { + console.error("Failed to export graph as PNG:", error); + throw new Error("Failed to export graph"); + } +}; + +/** + * Export graph data as JSON + * @param graphData The graph data to export + * @param filename The name of the file to download + */ +export const exportGraphAsJSON = ( + graphData: Record, + filename: string = "attack-path-graph.json", +) => { + try { + const jsonString = JSON.stringify(graphData, null, 2); + const blob = new Blob([jsonString], { type: "application/json" }); + downloadBlob(blob, filename); + } catch (error) { + console.error("Failed to export graph as JSON:", error); + throw new Error("Failed to export graph"); + } +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts new file mode 100644 index 0000000000..02871e270e --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts @@ -0,0 +1,25 @@ +/** + * Formatting utilities for attack path graph nodes + */ + +/** + * Format camelCase labels to space-separated text + * e.g., "ProwlerFinding" -> "Prowler Finding", "AWSAccount" -> "Aws Account" + */ +export function formatNodeLabel(label: string): string { + return label + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/([a-z\d])([A-Z])/g, "$1 $2") + .trim() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Format multiple node labels into a readable string + * e.g., ["ProwlerFinding"] -> "Prowler Finding" + */ +export function formatNodeLabels(labels: string[]): string { + return labels.map(formatNodeLabel).join(", "); +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts new file mode 100644 index 0000000000..b49b058850 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts @@ -0,0 +1,141 @@ +/** + * Color constants for attack path graph visualization + * Colors chosen to work well in both light and dark themes + */ + +/** + * Node fill colors - darker versions of design system severity colors + * Darkened to ensure white text has proper contrast (WCAG AA) + */ +export const GRAPH_NODE_COLORS = { + // Finding severities - darkened versions for white text readability + critical: "#cc0055", // Darker pink (from #ff006a) + high: "#c45a3a", // Darker coral (from #f77852) + medium: "#b8860b", // Dark goldenrod (from #fec94d) + low: "#8b9a3e", // Olive/dark yellow-green (from #fdfbd4) + info: "#2563eb", // Darker blue (from #3c8dff) + // Node types + prowlerFinding: "#ea580c", + awsAccount: "#f59e0b", // Amber 500 - AWS orange + attackPattern: "#16a34a", + summary: "#16a34a", + // Infrastructure + ec2Instance: "#0891b2", // Cyan 600 + s3Bucket: "#0284c7", // Sky 600 + iamRole: "#7c3aed", // Violet 600 + iamPolicy: "#7c3aed", + lambdaFunction: "#d97706", // Amber 600 + securityGroup: "#0891b2", + default: "#0891b2", +} as const; + +/** + * Node border colors - using original design system colors as borders (lighter than fill) + */ +export const GRAPH_NODE_BORDER_COLORS = { + critical: "#ff006a", // Original --bg-data-critical + high: "#f77852", // Original --bg-data-high + medium: "#fec94d", // Original --bg-data-medium + low: "#c4d4a0", // Lighter olive + info: "#3c8dff", // Original --bg-data-info + prowlerFinding: "#fb923c", + awsAccount: "#fbbf24", // Amber 400 + attackPattern: "#4ade80", + summary: "#4ade80", + ec2Instance: "#22d3ee", // Cyan 400 + s3Bucket: "#38bdf8", // Sky 400 + iamRole: "#a78bfa", // Violet 400 + iamPolicy: "#a78bfa", + lambdaFunction: "#fbbf24", + securityGroup: "#22d3ee", + default: "#22d3ee", +} as const; + +// Edge colors per theme +export const GRAPH_EDGE_COLOR_DARK = "#ffffff"; // White for dark theme +export const GRAPH_EDGE_COLOR_LIGHT = "#1e293b"; // Slate 800 for light theme +export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#f97316"; // Orange 500 (on hover) +export const GRAPH_EDGE_GLOW_COLOR = "#fb923c"; +export const GRAPH_SELECTION_COLOR = "#ffffff"; +export const GRAPH_BORDER_COLOR = "#374151"; +export const GRAPH_ALERT_BORDER_COLOR = "#ef4444"; // Red 500 - for resources with findings + +/** + * Get node fill color based on labels and properties + */ +export const getNodeColor = ( + labels: string[], + properties?: Record, +): string => { + const isFinding = labels.some((l) => l.toLowerCase().includes("finding")); + if (isFinding && properties?.severity) { + const severity = String(properties.severity).toLowerCase(); + if (severity === "critical") return GRAPH_NODE_COLORS.critical; + if (severity === "high") return GRAPH_NODE_COLORS.high; + if (severity === "medium") return GRAPH_NODE_COLORS.medium; + if (severity === "low") return GRAPH_NODE_COLORS.low; + if (severity === "informational" || severity === "info") + return GRAPH_NODE_COLORS.info; + return GRAPH_NODE_COLORS.prowlerFinding; + } + + if (labels.some((l) => l.toLowerCase().includes("attackpattern"))) + return GRAPH_NODE_COLORS.attackPattern; + if (labels.includes("AWSAccount")) return GRAPH_NODE_COLORS.awsAccount; + if (labels.includes("EC2Instance")) return GRAPH_NODE_COLORS.ec2Instance; + if (labels.includes("S3Bucket")) return GRAPH_NODE_COLORS.s3Bucket; + if (labels.includes("IAMRole")) return GRAPH_NODE_COLORS.iamRole; + if (labels.includes("IAMPolicy")) return GRAPH_NODE_COLORS.iamPolicy; + if (labels.includes("LambdaFunction")) + return GRAPH_NODE_COLORS.lambdaFunction; + if (labels.includes("SecurityGroup")) return GRAPH_NODE_COLORS.securityGroup; + + return GRAPH_NODE_COLORS.default; +}; + +/** + * Get node border color based on labels and properties + */ +export const getNodeBorderColor = ( + labels: string[], + properties?: Record, +): string => { + const isFinding = labels.some((l) => l.toLowerCase().includes("finding")); + if (isFinding && properties?.severity) { + const severity = String(properties.severity).toLowerCase(); + if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical; + if (severity === "high") return GRAPH_NODE_BORDER_COLORS.high; + if (severity === "medium") return GRAPH_NODE_BORDER_COLORS.medium; + if (severity === "low") return GRAPH_NODE_BORDER_COLORS.low; + if (severity === "informational" || severity === "info") + return GRAPH_NODE_BORDER_COLORS.info; + return GRAPH_NODE_BORDER_COLORS.prowlerFinding; + } + + if (labels.some((l) => l.toLowerCase().includes("attackpattern"))) + return GRAPH_NODE_BORDER_COLORS.attackPattern; + if (labels.includes("AWSAccount")) return GRAPH_NODE_BORDER_COLORS.awsAccount; + if (labels.includes("EC2Instance")) + return GRAPH_NODE_BORDER_COLORS.ec2Instance; + if (labels.includes("S3Bucket")) return GRAPH_NODE_BORDER_COLORS.s3Bucket; + if (labels.includes("IAMRole")) return GRAPH_NODE_BORDER_COLORS.iamRole; + if (labels.includes("IAMPolicy")) return GRAPH_NODE_BORDER_COLORS.iamPolicy; + if (labels.includes("LambdaFunction")) + return GRAPH_NODE_BORDER_COLORS.lambdaFunction; + if (labels.includes("SecurityGroup")) + return GRAPH_NODE_BORDER_COLORS.securityGroup; + + return GRAPH_NODE_BORDER_COLORS.default; +}; + +/** + * Check if a background color is light (for determining text color) + */ +export const isLightBackground = (backgroundColor: string): boolean => { + const hex = backgroundColor.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts new file mode 100644 index 0000000000..ac5c219bfb --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts @@ -0,0 +1,187 @@ +/** + * Utility functions for attack path graph operations + */ + +import type { AttackPathGraphData } from "@/types/attack-paths"; + +/** + * Type for edge node reference - can be a string ID or an object with id property + * Note: We use `object` to match GraphEdge type from attack-paths.ts + */ +export type EdgeNodeRef = string | object; + +/** + * Helper to get edge source/target ID from string or object + */ +export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => { + if (typeof nodeRef === "string") { + return nodeRef; + } + // Edge node references are objects with an id property + return (nodeRef as { id: string }).id; +}; + +/** + * Compute a filtered subgraph containing only the path through the target node. + * This follows the directed graph structure of attack paths: + * - Upstream: traces back to the root (AWS Account) + * - Downstream: traces forward to leaf nodes + * - Also includes findings connected to the selected node + */ +export const computeFilteredSubgraph = ( + fullData: AttackPathGraphData, + targetNodeId: string, +): AttackPathGraphData => { + const nodes = fullData.nodes; + const edges = fullData.edges || []; + + // Build directed adjacency lists + const forwardEdges = new Map>(); // source -> targets + const backwardEdges = new Map>(); // target -> sources + nodes.forEach((node) => { + forwardEdges.set(node.id, new Set()); + backwardEdges.set(node.id, new Set()); + }); + + edges.forEach((edge) => { + const sourceId = getEdgeNodeId(edge.source); + const targetId = getEdgeNodeId(edge.target); + forwardEdges.get(sourceId)?.add(targetId); + backwardEdges.get(targetId)?.add(sourceId); + }); + + const visibleNodeIds = new Set(); + visibleNodeIds.add(targetNodeId); + + // Traverse upstream (backward) - find all ancestors + const traverseUpstream = (nodeId: string) => { + const sources = backwardEdges.get(nodeId); + if (sources) { + sources.forEach((sourceId) => { + if (!visibleNodeIds.has(sourceId)) { + visibleNodeIds.add(sourceId); + traverseUpstream(sourceId); + } + }); + } + }; + + // Traverse downstream (forward) - find all descendants + const traverseDownstream = (nodeId: string) => { + const targets = forwardEdges.get(nodeId); + if (targets) { + targets.forEach((targetId) => { + if (!visibleNodeIds.has(targetId)) { + visibleNodeIds.add(targetId); + traverseDownstream(targetId); + } + }); + } + }; + + // Start traversal from the target node + traverseUpstream(targetNodeId); + traverseDownstream(targetNodeId); + + // Also include findings directly connected to the selected node + edges.forEach((edge) => { + const sourceId = getEdgeNodeId(edge.source); + const targetId = getEdgeNodeId(edge.target); + const sourceNode = nodes.find((n) => n.id === sourceId); + const targetNode = nodes.find((n) => n.id === targetId); + + const sourceIsFinding = sourceNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + const targetIsFinding = targetNode?.labels.some((l) => + l.toLowerCase().includes("finding"), + ); + + // Include findings connected to the selected node + if (sourceId === targetNodeId && targetIsFinding) { + visibleNodeIds.add(targetId); + } + if (targetId === targetNodeId && sourceIsFinding) { + visibleNodeIds.add(sourceId); + } + }); + + // Filter nodes and edges to only include visible ones + const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id)); + const filteredEdges = edges.filter((edge) => { + const sourceId = getEdgeNodeId(edge.source); + const targetId = getEdgeNodeId(edge.target); + return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); + }); + + return { + nodes: filteredNodes, + edges: filteredEdges, + }; +}; + +/** + * Find edges in the path from a given node. + * Upstream: follows only ONE parent path (first parent at each level) to avoid lighting up siblings + * Downstream: follows ALL children recursively + * + * Uses pre-built adjacency maps for O(1) lookups instead of O(n) array searches per traversal step. + * + * @param nodeId - The starting node ID + * @param edges - Array of edges with sourceId and targetId + * @returns Set of edge IDs in the format "sourceId-targetId" + */ +export const getPathEdges = ( + nodeId: string, + edges: Array<{ sourceId: string; targetId: string }>, +): Set => { + // Build adjacency maps once - O(n) + const parentMap = new Map(); + const childrenMap = new Map< + string, + Array<{ sourceId: string; targetId: string }> + >(); + + edges.forEach((edge) => { + // First parent only (matches original behavior of find()) + if (!parentMap.has(edge.targetId)) { + parentMap.set(edge.targetId, edge); + } + const children = childrenMap.get(edge.sourceId) || []; + children.push(edge); + childrenMap.set(edge.sourceId, children); + }); + + const pathEdgeIds = new Set(); + const visitedNodes = new Set(); + + // Traverse upstream - only follow ONE parent at each level (first found) + // This creates a single path to the root, not all paths + const traverseUpstream = (currentNodeId: string) => { + if (visitedNodes.has(`up-${currentNodeId}`)) return; + visitedNodes.add(`up-${currentNodeId}`); + + const parentEdge = parentMap.get(currentNodeId); // O(1) lookup + if (parentEdge) { + pathEdgeIds.add(`${parentEdge.sourceId}-${parentEdge.targetId}`); + traverseUpstream(parentEdge.sourceId); + } + }; + + // Traverse downstream (find ALL targets from this node) + const traverseDownstream = (currentNodeId: string) => { + if (visitedNodes.has(`down-${currentNodeId}`)) return; + visitedNodes.add(`down-${currentNodeId}`); + + const children = childrenMap.get(currentNodeId) || []; // O(1) lookup + children.forEach((edge) => { + pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`); + traverseDownstream(edge.targetId); + }); + }; + + traverseUpstream(nodeId); + traverseDownstream(nodeId); + + return pathEdgeIds; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts new file mode 100644 index 0000000000..abecf7a3c2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts @@ -0,0 +1,23 @@ +export { + exportGraphAsJSON, + exportGraphAsPNG, + exportGraphAsSVG, +} from "./export"; +export { formatNodeLabel, formatNodeLabels } from "./format"; +export { + getNodeBorderColor, + getNodeColor, + GRAPH_ALERT_BORDER_COLOR, + GRAPH_EDGE_COLOR_DARK, + GRAPH_EDGE_COLOR_LIGHT, + GRAPH_EDGE_HIGHLIGHT_COLOR, + GRAPH_NODE_BORDER_COLORS, + GRAPH_NODE_COLORS, + GRAPH_SELECTION_COLOR, +} from "./graph-colors"; +export { + computeFilteredSubgraph, + type EdgeNodeRef, + getEdgeNodeId, + getPathEdges, +} from "./graph-utils"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx new file mode 100644 index 0000000000..a817b9a715 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx @@ -0,0 +1,626 @@ +"use client"; + +import { ArrowLeft, Maximize2, X } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { FormProvider } from "react-hook-form"; + +import { + executeQuery, + getAttackPathScans, + getAvailableQueries, +} from "@/actions/attack-paths"; +import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; +import { AutoRefresh } from "@/components/scans"; +import { Button, Card, CardContent } from "@/components/shadcn"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + useToast, +} from "@/components/ui"; +import type { + AttackPathQuery, + AttackPathScan, + GraphNode, +} from "@/types/attack-paths"; + +import { + AttackPathGraph, + ExecuteButton, + GraphControls, + GraphLegend, + GraphLoading, + NodeDetailContent, + QueryParametersForm, + QuerySelector, + ScanListTable, +} from "./_components"; +import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph"; +import { useGraphState } from "./_hooks/use-graph-state"; +import { useQueryBuilder } from "./_hooks/use-query-builder"; +import { exportGraphAsSVG, formatNodeLabel } from "./_lib"; + +/** + * Attack Paths Analysis + * Allows users to select a scan, build a query, and visualize the Attack Paths graph + */ +export default function AttackPathAnalysisPage() { + const searchParams = useSearchParams(); + const scanId = searchParams.get("scanId"); + const graphState = useGraphState(); + const { toast } = useToast(); + + const [scansLoading, setScansLoading] = useState(true); + const [scans, setScans] = useState([]); + const [queriesLoading, setQueriesLoading] = useState(true); + const [queriesError, setQueriesError] = useState(null); + const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); + const graphRef = useRef(null); + const fullscreenGraphRef = useRef(null); + const hasResetRef = useRef(false); + const nodeDetailsRef = useRef(null); + const graphContainerRef = useRef(null); + + const [queries, setQueries] = useState([]); + + // Use custom hook for query builder form state and validation + const queryBuilder = useQueryBuilder(queries); + + // Reset graph state when component mounts + useEffect(() => { + if (!hasResetRef.current) { + hasResetRef.current = true; + graphState.resetGraph(); + } + }, [graphState]); + + // 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 === "executing" || + scan.attributes.state === "scheduled", + ); + + // Callback to refresh scans (used by AutoRefresh component) + const refreshScans = useCallback(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) { + setQueriesError("No scan selected"); + setQueriesLoading(false); + return; + } + + setQueriesLoading(true); + try { + const queriesData = await getAvailableQueries(scanId); + if (queriesData?.data) { + setQueries(queriesData.data); + setQueriesError(null); + } else { + setQueriesError("Failed to load available queries"); + toast({ + title: "Error", + description: "Failed to load queries for this scan", + variant: "destructive", + }); + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Unknown error"; + setQueriesError(errorMsg); + toast({ + title: "Error", + description: "Failed to load queries", + variant: "destructive", + }); + } finally { + setQueriesLoading(false); + } + }; + + loadQueries(); + }, [scanId, toast]); + + const handleQueryChange = (queryId: string) => { + queryBuilder.handleQueryChange(queryId); + }; + + const showErrorToast = (title: string, description: string) => { + toast({ + title, + description, + variant: "destructive", + }); + }; + + const handleExecuteQuery = async () => { + if (!scanId || !queryBuilder.selectedQuery) { + showErrorToast("Error", "Please select both a scan and a query"); + return; + } + + // Validate form before executing query + const isValid = await queryBuilder.form.trigger(); + if (!isValid) { + showErrorToast( + "Validation Error", + "Please fill in all required parameters", + ); + return; + } + + graphState.startLoading(); + graphState.setError(null); + + try { + const parameters = queryBuilder.getQueryParameters() as Record< + string, + string | number | boolean + >; + const result = await executeQuery( + scanId, + queryBuilder.selectedQuery, + parameters, + ); + + if (result?.data?.attributes) { + const graphData = adaptQueryResultToGraphData(result.data.attributes); + graphState.updateGraphData(graphData); + toast({ + title: "Success", + description: "Query executed successfully", + variant: "default", + }); + + // Scroll to graph after successful query execution + setTimeout(() => { + graphContainerRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + } else { + graphState.resetGraph(); + graphState.setError("No data returned from query"); + showErrorToast("Error", "Query returned no data"); + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Failed to execute query"; + graphState.resetGraph(); + graphState.setError(errorMsg); + showErrorToast("Error", errorMsg); + } finally { + graphState.stopLoading(); + } + }; + + const handleNodeClick = (node: GraphNode) => { + // Enter filtered view showing only paths containing this node + graphState.enterFilteredView(node.id); + + // For findings, also scroll to the details section + const isFinding = node.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + + if (isFinding) { + setTimeout(() => { + nodeDetailsRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, 100); + } + }; + + const handleBackToFullView = () => { + graphState.exitFilteredView(); + }; + + const handleCloseDetails = () => { + graphState.selectNode(null); + }; + + const handleGraphExport = (svgElement: SVGSVGElement | null) => { + try { + if (svgElement) { + exportGraphAsSVG(svgElement, "attack-path-graph.svg"); + toast({ + title: "Success", + description: "Graph exported as SVG", + variant: "default", + }); + } else { + throw new Error("Could not find graph element"); + } + } catch (error) { + toast({ + title: "Error", + description: + error instanceof Error ? error.message : "Failed to export graph", + variant: "destructive", + }); + } + }; + + return ( +
+ {/* Auto-refresh scans when there's an executing scan */} + + + {/* Header */} +
+

+ Attack Paths Analysis +

+

+ Select a scan, build a query, and visualize Attack Paths in your + infrastructure. +

+
+ + {/* Top Section - Scans Table and Query Builder (2 columns) */} +
+ {/* Scans Table Section - Left Column */} +
+ {scansLoading ? ( +
+

Loading scans...

+
+ ) : scans.length === 0 ? ( +
+

No scans available

+
+ ) : ( + Loading scans...
}> + + + )} +
+ + {/* Query Builder Section - Right Column */} +
+ {!scanId ? ( +

+ Select a scan from the table on the left to begin. +

+ ) : queriesLoading ? ( +

Loading queries...

+ ) : queriesError ? ( +

+ {queriesError} +

+ ) : ( + <> + + + + {queryBuilder.selectedQuery && ( + + )} + + +
+ +
+ + {graphState.error && ( +
+ {graphState.error} +
+ )} + + )} +
+
+ + {/* Bottom Section - Graph Visualization (Full Width) */} +
+ {graphState.loading ? ( + + ) : graphState.data && + graphState.data.nodes && + graphState.data.nodes.length > 0 ? ( + <> + {/* Info message and controls */} +
+ {graphState.isFilteredView ? ( +
+ +
+ + + Showing paths for:{" "} + + {graphState.filteredNode?.properties?.name || + graphState.filteredNode?.properties?.id || + "Selected node"} + + +
+
+ ) : ( +
+ + + Click on any node to filter and view its connected paths + +
+ )} + + {/* Graph controls and fullscreen button together */} +
+ graphRef.current?.zoomIn()} + onZoomOut={() => graphRef.current?.zoomOut()} + onFitToScreen={() => graphRef.current?.resetZoom()} + onExport={() => + handleGraphExport(graphRef.current?.getSVGElement() || null) + } + /> + + {/* Fullscreen button */} +
+ + + + + + + + Graph Fullscreen View + + +
+ fullscreenGraphRef.current?.zoomIn()} + onZoomOut={() => + fullscreenGraphRef.current?.zoomOut() + } + onFitToScreen={() => + fullscreenGraphRef.current?.resetZoom() + } + onExport={() => + handleGraphExport( + fullscreenGraphRef.current?.getSVGElement() || + null, + ) + } + /> +
+
+
+ +
+ {/* Node Detail Panel - Side by side */} + {graphState.selectedNode && ( +
+ + +
+

+ Node Details +

+ +
+

+ {graphState.selectedNode?.labels.some( + (label) => + label.toLowerCase().includes("finding"), + ) + ? graphState.selectedNode?.properties + ?.check_title || + graphState.selectedNode?.properties?.id || + "Unknown Finding" + : graphState.selectedNode?.properties + ?.name || + graphState.selectedNode?.properties?.id || + "Unknown Resource"} +

+
+
+

+ Type +

+

+ {graphState.selectedNode?.labels + .map(formatNodeLabel) + .join(", ")} +

+
+
+
+
+
+ )} +
+
+
+
+
+
+ + {/* Graph in the middle */} +
+ +
+ + {/* Legend below */} +
+ +
+ + ) : ( +
+

+ Select a query and click "Execute Query" to visualize + the Attack Paths graph +

+
+ )} +
+ + {/* Node Detail Panel - Below Graph */} + {graphState.selectedNode && graphState.data && ( +
+
+
+

Node Details

+

+ {String( + graphState.selectedNode.labels.some((label) => + label.toLowerCase().includes("finding"), + ) + ? graphState.selectedNode.properties?.check_title || + graphState.selectedNode.properties?.id || + "Unknown Finding" + : graphState.selectedNode.properties?.name || + graphState.selectedNode.properties?.id || + "Unknown Resource", + )} +

+
+
+ {graphState.selectedNode.labels.some((label) => + label.toLowerCase().includes("finding"), + ) && ( + + )} + +
+
+ + +
+ )} +
+ ); +} diff --git a/ui/app/(prowler)/attack-paths/page.tsx b/ui/app/(prowler)/attack-paths/page.tsx new file mode 100644 index 0000000000..3fe92b08f8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; + +/** + * Landing page for Attack Paths feature + * Redirects to the integrated attack path analysis view + */ +export default function AttackPathsPage() { + redirect("/attack-paths/query-builder"); +} diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 974b8edede..294d637b24 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -6,6 +6,7 @@ import { getComplianceOverviewMetadataInfo, getComplianceRequirements, } from "@/actions/compliances"; +import { getThreatScore } from "@/actions/overview"; import { ClientAccordionWrapper, ComplianceDownloadButton, @@ -15,6 +16,8 @@ import { // SectionsFailureRateCard, // SectionsFailureRateCardSkeleton, SkeletonAccordion, + ThreatScoreBreakdownCard, + ThreatScoreBreakdownCardSkeleton, TopFailedSectionsCard, TopFailedSectionsCardSkeleton, } from "@/components/compliance"; @@ -22,6 +25,7 @@ import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance" import { ContentLayout } from "@/components/ui"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types"; +import { cn } from "@/lib/utils"; import { AttributesData, Framework, @@ -86,14 +90,33 @@ export default async function ComplianceDetail({ const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; + // Detect if this is a ThreatScore compliance view + const isThreatScore = complianceId?.includes("prowler_threatscore"); + + // Fetch ThreatScore data if applicable + let threatScoreData = null; + if (isThreatScore && selectedScanId) { + const threatScoreResponse = await getThreatScore({ + filters: { "filter[scan_id]": selectedScanId }, + }); + + if (threatScoreResponse?.data && threatScoreResponse.data.length > 0) { + const snapshot = threatScoreResponse.data[0]; + threatScoreData = { + overallScore: parseFloat(snapshot.attributes.overall_score), + sectionScores: snapshot.attributes.section_scores, + }; + } + } + // Use compliance_name from attributes if available, otherwise fallback to formatted title const complianceName = attributesData?.data?.[0]?.attributes?.compliance_name; const finalPageTitle = complianceName ? `${complianceName}` : pageTitle; return ( -
-
+
+
+
) : null; @@ -124,7 +148,18 @@ export default async function ComplianceDetail({ key={searchParamsKey} fallback={
-
+ {/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */} +
+ {isThreatScore && ( +
+ +
+ )} {/* */} @@ -139,6 +174,7 @@ export default async function ComplianceDetail({ region={regionFilter} filter={cisProfileFilter} attributesData={attributesData} + threatScoreData={threatScoreData} /> @@ -151,12 +187,17 @@ const SSRComplianceContent = async ({ region, filter, attributesData, + threatScoreData, }: { complianceId: string; scanId: string; region?: string; filter?: string; attributesData: AttributesData; + threatScoreData: { + overallScore: number; + sectionScores: Record; + } | null; }) => { const requirementsData = await getComplianceRequirements({ complianceId, @@ -168,7 +209,7 @@ const SSRComplianceContent = async ({ if (!scanId || type === "tasks") { return (
-
+
{/* */} @@ -199,7 +240,22 @@ const SSRComplianceContent = async ({ return (
-
+ {/* Charts section */} + {/* Mobile: each card on own row | Tablet: ThreatScore full row, others share row | Desktop: all 3 in one row */} +
+ {threatScoreData && ( +
+ +
+ )} 0) { + const snapshot = threatScoreResponse.data[0]; + threatScoreData = { + score: parseFloat(snapshot.attributes.overall_score), + sectionScores: snapshot.attributes.section_scores, + }; + } } return ( {selectedScanId ? ( <> -
-
-
- -
- {threatScoreData && - typeof selectedScanId === "string" && - selectedScan && ( -
- -
- )} +
+
+
+ {threatScoreData && + typeof selectedScanId === "string" && + selectedScan && ( +
+ +
+ )}
}> ; }) { const resolvedSearchParams = await searchParams; - const { searchParamsKey, encodedSort } = - extractSortAndKey(resolvedSearchParams); + const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - const [metadataInfoData, providersData, scansData] = await Promise.all([ - (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ - query, - sort: encodedSort, - filters, - }), - getProviders({ pageSize: 50 }), - getScans({ pageSize: 50 }), - ]); + // Check if there's a specific finding ID to fetch + const findingId = resolvedSearchParams.id?.toString(); - // Extract unique regions, services, categories from the new endpoint + const [metadataInfoData, providersData, scansData, findingByIdData] = + await Promise.all([ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + sort: encodedSort, + filters, + }), + getProviders({ pageSize: 50 }), + getScans({ pageSize: 50 }), + findingId + ? getFindingById(findingId, "resources,scan.provider") + : Promise.resolve(null), + ]); + + // Process the finding data to match the expected structure + const processedFinding = findingByIdData?.data + ? (() => { + const finding = findingByIdData.data; + const included = findingByIdData.included || []; + + // Build dictionaries from included data + type IncludedItem = { + type: string; + id: string; + attributes: Record; + relationships?: { + provider?: { data?: { id: string } }; + }; + }; + + const resourceDict: Record = {}; + const scanDict: Record = {}; + const providerDict: Record = {}; + + included.forEach((item: IncludedItem) => { + if (item.type === "resources") { + resourceDict[item.id] = { + id: item.id, + attributes: item.attributes, + }; + } else if (item.type === "scans") { + scanDict[item.id] = item; + } else if (item.type === "providers") { + providerDict[item.id] = { + id: item.id, + attributes: item.attributes, + }; + } + }); + + const scanId = finding.relationships?.scan?.data?.id; + const resourceId = finding.relationships?.resources?.data?.[0]?.id; + const scan = scanId ? scanDict[scanId] : undefined; + const providerId = scan?.relationships?.provider?.data?.id; + const resource = resourceId ? resourceDict[resourceId] : undefined; + const provider = providerId ? providerDict[providerId] : undefined; + + return { + ...finding, + relationships: { + scan: scan + ? { data: scan, attributes: scan.attributes } + : undefined, + resource: resource, + provider: provider, + }, + } as FindingProps; + })() + : null; + + // Extract unique regions, services, categories, groups from the new endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; const uniqueResourceTypes = metadataInfoData?.data?.attributes?.resource_types || []; const uniqueCategories = metadataInfoData?.data?.attributes?.categories || []; + const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; // Extract provider IDs and details using helper functions const providerIds = providersData ? extractProviderIds(providersData) : []; const providerDetails = providersData - ? (createProviderDetailsMappingById(providerIds, providersData) as { - [id: string]: FilterEntity; - }[]) + ? createProviderDetailsMappingById(providerIds, providersData) : []; // Extract scan UUIDs with "completed" state and more than one resource @@ -84,22 +147,27 @@ export default async function Findings({ return ( - - - }> - - + +
+ +
+ }> + + +
+ {processedFinding && }
); } diff --git a/ui/app/(prowler)/invitations/page.tsx b/ui/app/(prowler)/invitations/page.tsx index 5db341b896..57cc268354 100644 --- a/ui/app/(prowler)/invitations/page.tsx +++ b/ui/app/(prowler)/invitations/page.tsx @@ -1,6 +1,5 @@ -import { Spacer } from "@heroui/spacer"; import Link from "next/link"; -import React, { Suspense } from "react"; +import { Suspense } from "react"; import { getInvitations } from "@/actions/invitations/invitation"; import { getRoles } from "@/actions/roles"; @@ -28,21 +27,22 @@ export default async function Invitations({ -
- +
+
+ - + +
+ + }> + +
- - - }> - - ); } diff --git a/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx b/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx index 2c4c6a6657..6446c726a0 100644 --- a/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx +++ b/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx @@ -7,7 +7,7 @@ import { ConnectLLMProvider } from "@/components/lighthouse/connect-llm-provider import { SelectBedrockAuthMethod } from "@/components/lighthouse/select-bedrock-auth-method"; import type { LighthouseProvider } from "@/types/lighthouse"; -const BEDROCK_AUTH_MODES = { +export const BEDROCK_AUTH_MODES = { IAM: "iam", API_KEY: "api_key", } as const; diff --git a/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx b/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx index fa058f9c09..61579abcb0 100644 --- a/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx +++ b/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx @@ -14,8 +14,8 @@ import { import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form"; import { WorkflowConnectLLM } from "@/components/lighthouse/workflow"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { NavigationHeader } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import type { LighthouseProvider } from "@/types/lighthouse"; interface ConnectLLMLayoutProps { @@ -63,8 +63,8 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) { return ( <> - - + {/* Delete Confirmation Modal */} -
- +
diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rules-columns.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rules-columns.tsx index 7bd06fb1ec..929f69eea2 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rules-columns.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rules-columns.tsx @@ -91,11 +91,7 @@ export const createMuteRulesColumns = ( }, { id: "actions", - header: () => ( -
- Actions -
- ), + header: () => null, cell: ({ row }) => { return ( - + )} {/* Delete Confirmation Modal */} {selectedMuteRule && ( -
- + )} ); diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 245fae9ef6..7f475687e3 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -13,6 +13,10 @@ import { import { CheckFindingsSSR } from "./_overview/check-findings"; import { GraphsTabsWrapper } from "./_overview/graphs-tabs/graphs-tabs-wrapper"; import { RiskPipelineViewSkeleton } from "./_overview/graphs-tabs/risk-pipeline-view"; +import { + ResourcesInventorySkeleton, + ResourcesInventorySSR, +} from "./_overview/resources-inventory"; import { RiskSeverityChartSkeleton, RiskSeverityChartSSR, @@ -24,6 +28,7 @@ import { import { StatusChartSkeleton } from "./_overview/status-chart"; import { ThreatScoreSkeleton, ThreatScoreSSR } from "./_overview/threat-score"; import { + ComplianceWatchlistSSR, ServiceWatchlistSSR, WatchlistCardSkeleton, } from "./_overview/watchlist"; @@ -58,18 +63,35 @@ export default async function Home({
- }> - + }> +
- }> - - - }> - - + {/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */} +
+
+ }> + + +
+
+ }> + + +
+
+ + {/* Charts column: Attack Surface on top, Findings Over Time below */} +
+ }> + + + }> + + +
diff --git a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx index 91afadbc6e..cd6af05043 100644 --- a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx +++ b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx @@ -1,5 +1,7 @@ +import { redirect } from "next/navigation"; import React from "react"; +import { getProvider } from "@/actions/providers/providers"; import { CredentialsUpdateInfo } from "@/components/providers"; import { UpdateViaCredentialsForm, @@ -20,9 +22,24 @@ interface Props { export default async function UpdateCredentialsPage({ searchParams }: Props) { const resolvedSearchParams = await searchParams; - const { type: providerType, via } = resolvedSearchParams; + const { type: providerType, via, id: providerId } = resolvedSearchParams; + + if (!providerId) { + redirect("/providers"); + } + const formType = getProviderFormType(providerType, via); + const formData = new FormData(); + formData.append("id", providerId); + const providerResponse = await getProvider(formData); + + if (providerResponse?.errors) { + redirect("/providers"); + } + + const providerUid = providerResponse?.data?.attributes?.uid; + switch (formType) { case "selector": return ( @@ -30,14 +47,27 @@ export default async function UpdateCredentialsPage({ searchParams }: Props) { ); case "credentials": - return ; + return ( + + ); case "role": - return ; + return ( + + ); case "service-account": return ( - + ); default: diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index e6726c2fd5..6e52449c04 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -1,4 +1,3 @@ -import { Spacer } from "@heroui/spacer"; import { Suspense } from "react"; import { getProviders } from "@/actions/providers"; @@ -26,20 +25,20 @@ export default async function Providers({ return ( - - - - - }> - - +
+ + + }> + + +
); } const ProvidersActions = () => { return ( -
+
diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index e0d9e1ceec..e390b98682 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,17 +1,19 @@ -import { Spacer } from "@heroui/spacer"; import { Suspense } from "react"; +import { getProviders } from "@/actions/providers"; import { getLatestMetadataInfo, getLatestResources, getMetadataInfo, + getResourceById, getResources, } from "@/actions/resources"; -import { FilterControls } from "@/components/filters"; +import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet"; +import { ResourcesFilters } from "@/components/resources/resources-filters"; import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources"; -import { ColumnResources } from "@/components/resources/table/column-resources"; +import { ResourcesTableWithSelection } from "@/components/resources/table"; import { ContentLayout } from "@/components/ui"; -import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { FilterTransitionWrapper } from "@/contexts"; import { createDict, extractFiltersAndQuery, @@ -27,53 +29,73 @@ export default async function Resources({ searchParams: Promise; }) { const resolvedSearchParams = await searchParams; - const { searchParamsKey, encodedSort } = - extractSortAndKey(resolvedSearchParams); + const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at"); // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - const metadataInfoData = await ( - hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo - )({ - query, - filters: outputFilters, - sort: encodedSort, - }); + // Check if there's a specific resource ID to fetch + const resourceId = resolvedSearchParams.resourceId?.toString(); - // Extract unique regions, services, types, and names from the metadata endpoint + const [metadataInfoData, providersData, resourceByIdData] = await Promise.all( + [ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + filters: outputFilters, + sort: encodedSort, + }), + getProviders({ pageSize: 50 }), + resourceId + ? getResourceById(resourceId, { include: ["provider"] }) + : Promise.resolve(null), + ], + ); + + // Process the resource data to match the expected structure + const processedResource = resourceByIdData?.data + ? (() => { + const resource = resourceByIdData.data; + const providerDict = createDict("providers", resourceByIdData); + + const provider = { + data: providerDict[resource.relationships?.provider?.data?.id], + }; + + return { + ...resource, + relationships: { + ...resource.relationships, + provider, + }, + } as ResourceProps; + })() + : null; + + // Extract unique regions, services, groups from the metadata endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; - const uniqueResourceTypes = metadataInfoData?.data?.attributes?.types || []; + const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; return ( - - - - }> - - + +
+ +
+ }> + + +
+ {processedResource && ( + + )}
); } @@ -111,6 +133,7 @@ const SSRDataTable = async ({ "region", "service", "type", + "groups", "provider", "inserted_at", "updated_at", @@ -149,9 +172,7 @@ const SSRDataTable = async ({

{resourcesData.errors[0].detail}

)} - diff --git a/ui/app/(prowler)/roles/page.tsx b/ui/app/(prowler)/roles/page.tsx index e10912576e..259776c5a3 100644 --- a/ui/app/(prowler)/roles/page.tsx +++ b/ui/app/(prowler)/roles/page.tsx @@ -1,4 +1,3 @@ -import { Spacer } from "@heroui/spacer"; import Link from "next/link"; import { Suspense } from "react"; @@ -6,8 +5,7 @@ import { getRoles } from "@/actions/roles"; import { FilterControls } from "@/components/filters"; import { filterRoles } from "@/components/filters/data-filters"; import { AddIcon } from "@/components/icons"; -import { ColumnsRoles } from "@/components/roles/table"; -import { SkeletonTableRoles } from "@/components/roles/table"; +import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table"; import { Button } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; @@ -25,21 +23,21 @@ export default async function Roles({ -
- +
+
+ + +
- + }> + +
- - - }> - - ); } diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 3ba35d2a5d..c3f1489714 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,7 +1,6 @@ -import { Spacer } from "@heroui/spacer"; import { Suspense } from "react"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getScans, getScansByState } from "@/actions/scans"; import { auth } from "@/auth.config"; import { MutedFindingsConfigButton } from "@/components/providers"; @@ -33,9 +32,7 @@ export default async function Scans({ const filteredParams = { ...resolvedSearchParams }; delete filteredParams.scanId; - const providersData = await getProviders({ - pageSize: 50, - }); + const providersData = await getAllProviders(); const providerInfo = providersData?.data @@ -87,33 +84,32 @@ export default async function Scans({ <> - {!hasManageScansPermission ? ( - + {!hasManageScansPermission ? ( + + ) : thereIsNoProvidersConnected ? ( + <> + + + ) : ( + + )} + +
+ - ) : thereIsNoProvidersConnected ? ( - <> - - - - - ) : ( - - )} - - - -
- +
+ +
+ }> + +
- - }> - - ); diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 397e8ae94c..8691137ae9 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -1,15 +1,13 @@ -import { Spacer } from "@heroui/spacer"; import Link from "next/link"; import { Suspense } from "react"; import { getRoles } from "@/actions/roles/roles"; import { getUsers } from "@/actions/users/users"; import { FilterControls } from "@/components/filters"; -import { filterUsers } from "@/components/filters/data-filters"; import { AddIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; -import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { DataTable } from "@/components/ui/table"; import { ColumnsUser, SkeletonTableUser } from "@/components/users/table"; import { Role, SearchParamsProps, UserProps } from "@/types"; @@ -25,21 +23,20 @@ export default async function Users({ -
- +
+
+ +
- + }> + +
- - - }> - - ); } diff --git a/ui/app/api/auth/callback/github/route.ts b/ui/app/api/auth/callback/github/route.ts index d913844644..dd32951fab 100644 --- a/ui/app/api/auth/callback/github/route.ts +++ b/ui/app/api/auth/callback/github/route.ts @@ -50,14 +50,12 @@ export async function GET(req: Request) { return NextResponse.redirect(new URL("/", baseUrl)); } catch (error) { - // eslint-disable-next-line no-console console.error("SignIn error:", error); return NextResponse.redirect( new URL("/sign-in?error=AuthenticationFailed", baseUrl), ); } } catch (error) { - // eslint-disable-next-line no-console console.error("Error in Github callback:", error); return NextResponse.json( { error: (error as Error).message }, diff --git a/ui/app/api/auth/callback/google/route.ts b/ui/app/api/auth/callback/google/route.ts index 37fea85f00..2a02273284 100644 --- a/ui/app/api/auth/callback/google/route.ts +++ b/ui/app/api/auth/callback/google/route.ts @@ -50,14 +50,12 @@ export async function GET(req: Request) { return NextResponse.redirect(new URL("/", baseUrl)); } catch (error) { - // eslint-disable-next-line no-console console.error("SignIn error:", error); return NextResponse.redirect( new URL("/sign-in?error=AuthenticationFailed", baseUrl), ); } } catch (error) { - // eslint-disable-next-line no-console console.error("Error in Google callback:", error); return NextResponse.json( { error: (error as Error).message }, diff --git a/ui/auth.config.ts b/ui/auth.config.ts index ff3875b8ef..cc075abe3d 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -1,4 +1,5 @@ import { jwtDecode, type JwtPayload } from "jwt-decode"; +import { NextResponse } from "next/server"; import NextAuth, { type DefaultSession, type NextAuthConfig, @@ -277,6 +278,7 @@ export const authConfig = { callbacks: { authorized({ auth, request: { nextUrl } }) { const isLoggedIn = !!auth?.user; + const sessionError = auth?.error; const isSignUpPage = nextUrl.pathname === "/sign-up"; const isSignInPage = nextUrl.pathname === "/sign-in"; @@ -284,8 +286,15 @@ export const authConfig = { if (isSignUpPage || isSignInPage) return true; // For all other routes, require authentication + // Return NextResponse.redirect to preserve callbackUrl for post-login redirect if (!isLoggedIn) { - return false; // Will redirect to signIn page defined in pages config + const signInUrl = new URL("/sign-in", nextUrl.origin); + signInUrl.searchParams.set("callbackUrl", nextUrl.pathname); + // Include session error if present (e.g., RefreshAccessTokenError) + if (sessionError) { + signInUrl.searchParams.set("error", sessionError); + } + return NextResponse.redirect(signInUrl); } return true; diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx index 9b387ba948..89650f56c4 100644 --- a/ui/components/auth/oss/sign-in-form.tsx +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -32,6 +32,7 @@ export const SignInForm = ({ const router = useRouter(); const searchParams = useSearchParams(); const { toast } = useToast(); + const callbackUrl = searchParams.get("callbackUrl") || "/"; useEffect(() => { const samlError = searchParams.get("sso_saml_failed"); @@ -122,7 +123,7 @@ export const SignInForm = ({ }); if (result?.message === "Success") { - router.push("/"); + router.push(callbackUrl); } else if (result?.errors && "credentials" in result.errors) { const message = result.errors.credentials ?? "Invalid email or password"; diff --git a/ui/components/compliance/compliance-charts/chart-skeletons.tsx b/ui/components/compliance/compliance-charts/chart-skeletons.tsx index 63835cff27..55e9683551 100644 --- a/ui/components/compliance/compliance-charts/chart-skeletons.tsx +++ b/ui/components/compliance/compliance-charts/chart-skeletons.tsx @@ -4,7 +4,7 @@ export function RequirementsStatusCardSkeleton() { return ( @@ -24,10 +24,7 @@ export function RequirementsStatusCardSkeleton() { export function TopFailedSectionsCardSkeleton() { return ( - + diff --git a/ui/components/compliance/compliance-charts/requirements-status-card.tsx b/ui/components/compliance/compliance-charts/requirements-status-card.tsx index c4779c221a..45aca6de0d 100644 --- a/ui/components/compliance/compliance-charts/requirements-status-card.tsx +++ b/ui/components/compliance/compliance-charts/requirements-status-card.tsx @@ -55,7 +55,7 @@ export function RequirementsStatusCard({ return ( Requirements Status diff --git a/ui/components/compliance/compliance-charts/threatscore-breakdown-card.tsx b/ui/components/compliance/compliance-charts/threatscore-breakdown-card.tsx new file mode 100644 index 0000000000..53d28474c1 --- /dev/null +++ b/ui/components/compliance/compliance-charts/threatscore-breakdown-card.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Progress } from "@heroui/progress"; + +import type { SectionScores } from "@/actions/overview/threat-score"; +import { RadialChart } from "@/components/graphs/radial-chart"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; +import { + getScoreColor, + getScoreLabel, + getScoreLevel, + getScoreTextClass, + SCORE_COLORS, +} from "@/lib/compliance/score-utils"; + +export interface ThreatScoreBreakdownCardProps { + overallScore: number; + sectionScores: SectionScores; +} + +export function ThreatScoreBreakdownCard({ + overallScore, + sectionScores, +}: ThreatScoreBreakdownCardProps) { + const scoreLevel = getScoreLevel(overallScore); + const scoreColor = SCORE_COLORS[scoreLevel]; + + // Convert section scores to tooltip data for the radial chart + const tooltipData = Object.entries(sectionScores).map(([name, value]) => ({ + name, + value, + color: SCORE_COLORS[getScoreLevel(value)], + })); + + // Sort sections by score (lowest first to highlight areas needing attention) + const sortedSections = Object.entries(sectionScores).sort( + ([, a], [, b]) => a - b, + ); + + return ( + + + ThreatScore Breakdown + + {/* Mobile: vertical, Tablet: horizontal, Desktop: vertical */} + + {/* Overall Score - Large radial chart matching overview style */} +
+
+
+ +
+ {/* Overlaid label below percentage */} +
+ {getScoreLabel(overallScore)} +
+
+
+ + {/* Pillar Breakdown */} + +
+ + Score by Pillar + +
+
+ {sortedSections.map(([section, score]) => ( +
+
+ + {section} + + + {score.toFixed(1)}% + +
+ +
+ ))} +
+
+
+
+ ); +} + +const SKELETON_PILLAR_COUNT = 5; + +export function ThreatScoreBreakdownCardSkeleton() { + return ( + + +
+ + {/* Mobile: vertical, Tablet: horizontal, Desktop: vertical */} + +
+
+
+ +
+ {Array.from({ length: SKELETON_PILLAR_COUNT }, (_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ + + + ); +} diff --git a/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx b/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx index f2429cb597..723ed5778b 100644 --- a/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx +++ b/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx @@ -34,10 +34,7 @@ export function TopFailedSectionsCard({ : "Top Failed Sections"; return ( - + {title} diff --git a/ui/components/compliance/compliance-download-button.tsx b/ui/components/compliance/compliance-download-button.tsx index c469ddc511..50e7be0ec4 100644 --- a/ui/components/compliance/compliance-download-button.tsx +++ b/ui/components/compliance/compliance-download-button.tsx @@ -4,6 +4,11 @@ import { DownloadIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/shadcn/button/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { toast } from "@/components/ui"; import { COMPLIANCE_REPORT_BUTTON_LABELS, @@ -15,12 +20,15 @@ interface ComplianceDownloadButtonProps { scanId: string; reportType: ComplianceReportType; label?: string; + /** Show only icon with tooltip on mobile (sm and below) */ + iconOnlyOnMobile?: boolean; } export const ComplianceDownloadButton = ({ scanId, reportType, label, + iconOnlyOnMobile = false, }: ComplianceDownloadButtonProps) => { const [isDownloading, setIsDownloading] = useState(false); @@ -34,6 +42,47 @@ export const ComplianceDownloadButton = ({ }; const defaultLabel = COMPLIANCE_REPORT_BUTTON_LABELS[reportType]; + const buttonLabel = label || defaultLabel; + + if (iconOnlyOnMobile) { + return ( + <> + {/* Mobile and Tablet: Icon only with tooltip */} + + + + + {buttonLabel} + + {/* Desktop: Full button with label */} + + + ); + } return ( ); }; diff --git a/ui/components/compliance/compliance-header/compliance-header.tsx b/ui/components/compliance/compliance-header/compliance-header.tsx index b65a30893a..13511d78cb 100644 --- a/ui/components/compliance/compliance-header/compliance-header.tsx +++ b/ui/components/compliance/compliance-header/compliance-header.tsx @@ -1,6 +1,5 @@ "use client"; -import { Spacer } from "@heroui/spacer"; import Image from "next/image"; import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom"; @@ -72,10 +71,12 @@ export const ComplianceHeader = ({ return ( <> {hasContent && ( -
+
+ {/* Showed in the details page */} {selectedScan && } + {/* Showed in the compliance page */} {showProviders && } {!hideFilters && allFilters.length > 0 && ( @@ -95,7 +96,6 @@ export const ComplianceHeader = ({ )}
)} - {hasContent && } ); }; diff --git a/ui/components/compliance/compliance-header/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx index 1f00222ef3..086a9183ed 100644 --- a/ui/components/compliance/compliance-header/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -27,6 +27,7 @@ export const ComplianceScanInfo = ({ scan }: ComplianceScanInfoProps) => { entityAlias={scan.providerInfo.alias} entityId={scan.providerInfo.uid} showCopyAction={false} + maxWidth="w-[80px]" />
diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index bc92cdd2b9..54befb9dd6 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -7,6 +7,7 @@ export * from "./compliance-charts/chart-skeletons"; export * from "./compliance-charts/heatmap-chart"; export * from "./compliance-charts/requirements-status-card"; export * from "./compliance-charts/sections-failure-rate-card"; +export * from "./compliance-charts/threatscore-breakdown-card"; export * from "./compliance-charts/top-failed-sections-card"; export * from "./compliance-custom-details/cis-details"; export * from "./compliance-custom-details/ens-details"; diff --git a/ui/components/compliance/threatscore-badge.tsx b/ui/components/compliance/threatscore-badge.tsx index b9138ca4e2..a047f91bdd 100644 --- a/ui/components/compliance/threatscore-badge.tsx +++ b/ui/components/compliance/threatscore-badge.tsx @@ -2,14 +2,26 @@ import { Card, CardBody } from "@heroui/card"; import { Progress } from "@heroui/progress"; -import { DownloadIcon, FileTextIcon } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + DownloadIcon, + FileTextIcon, +} from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; +import type { SectionScores } from "@/actions/overview/threat-score"; import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo"; import { Button } from "@/components/shadcn/button/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/shadcn/collapsible"; import { toast } from "@/components/ui"; import { COMPLIANCE_REPORT_TYPES } from "@/lib/compliance/compliance-report-types"; +import { getScoreColor, getScoreTextClass } from "@/lib/compliance/score-utils"; import { downloadComplianceCsv, downloadComplianceReportPdf, @@ -21,6 +33,7 @@ interface ThreatScoreBadgeProps { scanId: string; provider: string; selectedScan?: ScanEntity; + sectionScores?: SectionScores; } export const ThreatScoreBadge = ({ @@ -28,26 +41,16 @@ export const ThreatScoreBadge = ({ scanId, provider, selectedScan, + sectionScores, }: ThreatScoreBadgeProps) => { const router = useRouter(); const searchParams = useSearchParams(); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const complianceId = `prowler_threatscore_${provider.toLowerCase()}`; - const getScoreColor = (): "success" | "warning" | "danger" => { - if (score >= 80) return "success"; - if (score >= 40) return "warning"; - return "danger"; - }; - - const getTextColor = () => { - if (score >= 80) return "text-success"; - if (score >= 40) return "text-warning"; - return "text-text-error"; - }; - const handleCardClick = () => { const title = "ProwlerThreatScore"; const version = "1.0"; @@ -105,27 +108,81 @@ export const ThreatScoreBadge = ({ shadow="sm" className="border-default-200 h-full border bg-transparent" > - + + + {sectionScores && Object.keys(sectionScores).length > 0 && ( + + + {isExpanded ? ( + <> + + Hide pillar breakdown + + ) : ( + <> + + Show pillar breakdown + + )} + + + {Object.entries(sectionScores) + .sort(([, a], [, b]) => a - b) + .map(([section, sectionScore]) => ( +
+ + {section} + + + + {sectionScore.toFixed(1)}% + +
+ ))} +
+
+ )} +
diff --git a/ui/components/filters/filter-controls.tsx b/ui/components/filters/filter-controls.tsx index 5099f05a2a..e8df5c30be 100644 --- a/ui/components/filters/filter-controls.tsx +++ b/ui/components/filters/filter-controls.tsx @@ -1,7 +1,5 @@ "use client"; -import { Spacer } from "@heroui/spacer"; - import { FilterOption } from "@/types"; import { DataTableFilterCustom } from "../ui/table"; @@ -33,7 +31,7 @@ export const FilterControls = ({ }: FilterControlsProps) => { return (
-
+
{search && } {providers && } @@ -45,7 +43,6 @@ export const FilterControls = ({
{customFilters && customFilters.length > 0 && ( <> - )} diff --git a/ui/components/filters/index.ts b/ui/components/filters/index.ts index 4d6fa55ebe..5d9f577590 100644 --- a/ui/components/filters/index.ts +++ b/ui/components/filters/index.ts @@ -1,4 +1,3 @@ -export * from "./active-filter-badge"; export * from "./clear-filters-button"; export * from "./custom-account-selection"; export * from "./custom-checkbox-muted-findings"; diff --git a/ui/components/findings/finding-details-sheet.tsx b/ui/components/findings/finding-details-sheet.tsx new file mode 100644 index 0000000000..ddea0e13c4 --- /dev/null +++ b/ui/components/findings/finding-details-sheet.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { FindingProps } from "@/types/components"; + +import { FindingDetail } from "./table/finding-detail"; + +interface FindingDetailsSheetProps { + finding: FindingProps; +} + +export const FindingDetailsSheet = ({ finding }: FindingDetailsSheetProps) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handleOpenChange = (open: boolean) => { + if (!open) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("id"); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + } + }; + + return ( + + + + Finding Details + + View the finding details + + + + + + ); +}; diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 571a976086..857a49f958 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -13,7 +13,7 @@ import { Button } from "@/components/shadcn"; import { ExpandableSection } from "@/components/ui/expandable-section"; import { DataTableFilterCustom } from "@/components/ui/table"; import { useRelatedFilters } from "@/hooks"; -import { getCategoryLabel } from "@/lib/categories"; +import { getCategoryLabel, getGroupLabel } from "@/lib/categories"; import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types"; import { ProviderProps } from "@/types/providers"; @@ -29,6 +29,7 @@ interface FindingsFiltersProps { uniqueServices: string[]; uniqueResourceTypes: string[]; uniqueCategories: string[]; + uniqueGroups: string[]; } export const FindingsFilters = ({ @@ -41,6 +42,7 @@ export const FindingsFilters = ({ uniqueServices, uniqueResourceTypes, uniqueCategories, + uniqueGroups, }: FindingsFiltersProps) => { const [isExpanded, setIsExpanded] = useState(false); @@ -80,6 +82,13 @@ export const FindingsFilters = ({ labelFormatter: getCategoryLabel, index: 5, }, + { + key: FilterType.RESOURCE_GROUPS, + labelCheckboxGroup: "Resource Group", + values: uniqueGroups, + labelFormatter: getGroupLabel, + index: 6, + }, { key: FilterType.SCAN, labelCheckboxGroup: "Scan ID", diff --git a/ui/components/findings/index.ts b/ui/components/findings/index.ts index 2e2bece9f7..a43ec5f622 100644 --- a/ui/components/findings/index.ts +++ b/ui/components/findings/index.ts @@ -1 +1,2 @@ +export * from "./finding-details-sheet"; export * from "./muted"; diff --git a/ui/components/findings/mute-findings-modal.tsx b/ui/components/findings/mute-findings-modal.tsx index 0f4b95e6ad..a3db0e81df 100644 --- a/ui/components/findings/mute-findings-modal.tsx +++ b/ui/components/findings/mute-findings-modal.tsx @@ -4,15 +4,16 @@ import { Input, Textarea } from "@heroui/input"; import { Dispatch, SetStateAction, - useActionState, useEffect, useRef, + useState, + useTransition, } from "react"; import { createMuteRule } from "@/actions/mute-rules"; import { MuteRuleActionState } from "@/actions/mute-rules/types"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { FormButtons } from "@/components/ui/form"; interface MuteFindingsModalProps { @@ -29,6 +30,8 @@ export function MuteFindingsModal({ onComplete, }: MuteFindingsModalProps) { const { toast } = useToast(); + const [state, setState] = useState(null); + const [isPending, startTransition] = useTransition(); // Use refs to avoid stale closures in useEffect const onCompleteRef = useRef(onComplete); @@ -37,18 +40,12 @@ export function MuteFindingsModal({ const onOpenChangeRef = useRef(onOpenChange); onOpenChangeRef.current = onOpenChange; - const [state, formAction, isPending] = useActionState< - MuteRuleActionState, - FormData - >(createMuteRule, null); - useEffect(() => { if (state?.success) { toast({ title: "Success", description: state.success, }); - // Call onComplete BEFORE closing the modal to ensure router.refresh() executes onCompleteRef.current?.(); onOpenChangeRef.current(false); } else if (state?.errors?.general) { @@ -65,13 +62,26 @@ export function MuteFindingsModal({ }; return ( - - + { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + startTransition(() => { + void (async () => { + const result = await createMuteRule(null, formData); + setState(result); + })(); + }); + }} + > - + ); } diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index aae94afe4d..a692fe20b7 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -15,8 +15,8 @@ import { sendFindingToJira, } from "@/actions/integrations/jira-dispatch"; import { JiraIcon } from "@/components/icons/services/IconServices"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { CustomBanner } from "@/components/ui/custom/custom-banner"; import { Form, @@ -215,8 +215,8 @@ export const SendToJiraModal = ({ // }, [issueTypes, searchIssueTypeValue]); return ( - - + ); }; diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index dca9087745..486a0505df 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -4,12 +4,11 @@ import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; import { Database } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { - DataTableRowActions, - FindingDetail, -} from "@/components/findings/table"; +import { FindingDetail } from "@/components/findings/table"; +import { DataTableRowActions } from "@/components/findings/table"; import { Checkbox } from "@/components/shadcn"; -import { DateWithTime, SnippetChip } from "@/components/ui/entities"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { DateWithTime } from "@/components/ui/entities"; import { DataTableColumnHeader, SeverityBadge, @@ -57,23 +56,10 @@ const FindingTitleCell = ({ row }: { row: { original: FindingProps } }) => { const isOpen = findingId === row.original.id; const { checktitle } = row.original.attributes.check_metadata; - const handleOpenChange = (open: boolean) => { - const params = new URLSearchParams(searchParams); - - if (open) { - params.set("id", row.original.id); - } else { - params.delete("id"); - } - - window.history.pushState({}, "", `?${params.toString()}`); - }; - return (

@@ -201,7 +187,7 @@ export function getColumnFindings( const resourceName = getResourceData(row, "name"); return ( - `...${value.slice(-10)}`} icon={} @@ -256,6 +242,23 @@ export function getColumnFindings( }, enableSorting: false, }, + // Region column + { + accessorKey: "region", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const region = getResourceData(row, "region"); + const regionText = typeof region === "string" ? region : "-"; + return ( +

+ {regionText} +

+ ); + }, + enableSorting: false, + }, // TODO: PROWLER-379 - Enable Impacted Resources column when backend supports grouped findings // { // accessorKey: "impactedResources", diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index e465930413..9055d7eb9a 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -1,12 +1,5 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; import { Row } from "@tanstack/react-table"; import { VolumeOff, VolumeX } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -16,15 +9,32 @@ import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { VerticalDotsIcon } from "@/components/icons"; import { JiraIcon } from "@/components/icons/services/IconServices"; -import type { FindingProps } from "@/types/components"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import { FindingsSelectionContext } from "./findings-selection-context"; -interface DataTableRowActionsProps { - row: Row; +export interface FindingRowData { + id: string; + attributes: { + muted?: boolean; + check_metadata?: { + checktitle?: string; + }; + }; } -export function DataTableRowActions({ row }: DataTableRowActionsProps) { +interface DataTableRowActionsProps { + row: Row; + onMuteComplete?: (findingIds: string[]) => void; +} + +export function DataTableRowActions({ + row, + onMuteComplete, +}: DataTableRowActionsProps) { const router = useRouter(); const finding = row.original; const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); @@ -67,11 +77,31 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { return "Mute this finding"; }; + const getMuteLabel = () => { + if (isMuted) return "Muted"; + if (!isMuted && isCurrentSelected && hasMultipleSelected) { + return ( + <> + Mute + + ({selectedFindingIds.length}) + + + ); + } + return "Mute"; + }; + const handleMuteComplete = () => { // Always clear selection when a finding is muted because: // 1. If the muted finding was selected, its index now points to a different finding // 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected clearSelection(); + if (onMuteComplete) { + onMuteComplete(getMuteIds()); + return; + } + router.refresh(); }; @@ -92,61 +122,43 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { />
- + + + } + ariaLabel="Finding actions" > - - - - - - - ) : ( - - ) - } - onPress={() => setIsMuteModalOpen(true)} - > - {isMuted ? "Muted" : "Mute"} - {!isMuted && isCurrentSelected && hasMultipleSelected && ( - - ({selectedFindingIds.length}) - - )} - - - } - onPress={() => setIsJiraModalOpen(true)} - > - Send to Jira - - - - + + ) : ( + + ) + } + label={getMuteLabel()} + description={getMuteDescription()} + disabled={isMuted} + onSelect={() => { + setIsMuteModalOpen(true); + }} + /> + } + label="Send to Jira" + description="Create a Jira issue for this finding" + onSelect={() => setIsJiraModalOpen(true)} + /> +
); diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index 62a2b17c37..c96e8e0279 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -1,6 +1,5 @@ "use client"; -import { Snippet } from "@heroui/snippet"; import { ExternalLink, Link, X } from "lucide-react"; import { usePathname, useSearchParams } from "next/navigation"; import type { ReactNode } from "react"; @@ -33,6 +32,7 @@ import { StatusFindingBadge, } from "@/components/ui/table/status-finding-badge"; import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils"; +import { cn } from "@/lib/utils"; import { FindingProps, ProviderType } from "@/types"; import { Muted } from "../muted"; @@ -147,7 +147,8 @@ export const FindingDetail = ({ {/* Row 3: First Seen */}
- + Time: +
@@ -180,28 +181,31 @@ export const FindingDetail = ({
- + - + - + + + +
{attributes.status === "FAIL" && ( - {attributes.check_metadata.risk} - +
)} @@ -251,11 +255,13 @@ export const FindingDetail = ({ {/* CLI Command section */} {attributes.check_metadata.remediation.code.cli && ( - +
{attributes.check_metadata.remediation.code.cli} - +
)} diff --git a/ui/components/graphs/map-chart.tsx b/ui/components/graphs/map-chart.tsx index 07d85ae146..e2a1a388db 100644 --- a/ui/components/graphs/map-chart.tsx +++ b/ui/components/graphs/map-chart.tsx @@ -36,7 +36,7 @@ const MAP_COLORS = { pointHover: "var(--bg-fail)", } as const; -const RISK_LEVELS = { +export const RISK_LEVELS = { LOW_HIGH: "low-high", HIGH: "high", CRITICAL: "critical", diff --git a/ui/components/graphs/radial-chart.tsx b/ui/components/graphs/radial-chart.tsx index a0a6360635..0f158d5b58 100644 --- a/ui/components/graphs/radial-chart.tsx +++ b/ui/components/graphs/radial-chart.tsx @@ -25,6 +25,7 @@ interface RadialChartProps { startAngle?: number; endAngle?: number; tooltipData?: TooltipItem[]; + showCenterLabel?: boolean; } interface RadialChartTooltipProps { @@ -48,7 +49,7 @@ const CustomTooltip = ({ active, payload }: RadialChartTooltipProps) => { return null; return ( -
+
{tooltipItems.map((item: TooltipItem, index: number) => (
@@ -81,6 +82,7 @@ export function RadialChart({ startAngle = 90, endAngle = -270, tooltipData, + showCenterLabel = true, }: RadialChartProps) { // Calculate the real barSize based on the difference const barSize = outerRadius - innerRadius; @@ -126,18 +128,20 @@ export function RadialChart({ isAnimationActive={false} /> - - {percentage}% - + {showCenterLabel && ( + + {percentage}% + + )} ); diff --git a/ui/components/graphs/shared/chart-legend.tsx b/ui/components/graphs/shared/chart-legend.tsx index f05bfbad57..78966c9828 100644 --- a/ui/components/graphs/shared/chart-legend.tsx +++ b/ui/components/graphs/shared/chart-legend.tsx @@ -1,3 +1,9 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; + export interface ChartLegendItem { label: string; color: string; @@ -18,36 +24,42 @@ export function ChartLegend({ const isInteractive = !!onItemClick; return ( -
+
{items.map((item, index) => { const dataKey = item.dataKey ?? item.label.toLowerCase(); const isSelected = selectedItem === dataKey; const isFaded = selectedItem !== null && !isSelected; return ( - + + + + + +

{item.label}

+
+
); })}
diff --git a/ui/components/graphs/threat-map.tsx b/ui/components/graphs/threat-map.tsx index 498a2bf5c3..42b44aa8d1 100644 --- a/ui/components/graphs/threat-map.tsx +++ b/ui/components/graphs/threat-map.tsx @@ -381,7 +381,6 @@ export function ThreatMap({ } svg.appendChild(pointsGroup); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ dimensions, data.locations, diff --git a/ui/components/icons/Icons.tsx b/ui/components/icons/Icons.tsx index ba353879ac..eb746e8e83 100644 --- a/ui/components/icons/Icons.tsx +++ b/ui/components/icons/Icons.tsx @@ -1119,7 +1119,7 @@ export const KubernetesIcon: React.FC = ({ }; export const LighthouseIcon: React.FC = ({ - size = 24, + size = 19, width, height, ...props @@ -1127,39 +1127,173 @@ export const LighthouseIcon: React.FC = ({ return ( - {/* Square container with rounded corners, broken top-right edge */} - - {/* Slightly smaller center star */} - - {/* Small star in top-right corner */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/ui/components/integrations/jira/jira-integration-form.tsx b/ui/components/integrations/jira/jira-integration-form.tsx index 90e00cbc6b..eee4412cf3 100644 --- a/ui/components/integrations/jira/jira-integration-form.tsx +++ b/ui/components/integrations/jira/jira-integration-form.tsx @@ -130,7 +130,7 @@ export const JiraIntegrationForm = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", diff --git a/ui/components/integrations/jira/jira-integrations-manager.tsx b/ui/components/integrations/jira/jira-integrations-manager.tsx index 0ad5f58f9a..e2583f158e 100644 --- a/ui/components/integrations/jira/jira-integrations-manager.tsx +++ b/ui/components/integrations/jira/jira-integrations-manager.tsx @@ -16,8 +16,8 @@ import { IntegrationSkeleton, } from "@/components/integrations/shared"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; @@ -78,7 +78,7 @@ export const JiraIntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -109,7 +109,7 @@ export const JiraIntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -160,7 +160,7 @@ export const JiraIntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -209,8 +209,8 @@ export const JiraIntegrationsManager = ({ return ( <> -
- + - - +
{/* Header with Add Button */} diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx index 6246f941fe..416919e7bd 100644 --- a/ui/components/integrations/s3/s3-integrations-manager.tsx +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -16,8 +16,8 @@ import { IntegrationSkeleton, } from "@/components/integrations/shared"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; @@ -92,7 +92,7 @@ export const S3IntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -123,7 +123,7 @@ export const S3IntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -158,7 +158,7 @@ export const S3IntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -209,8 +209,8 @@ export const S3IntegrationsManager = ({ return ( <> -
- + - - +
{/* Header with Add Button */} diff --git a/ui/components/integrations/saml/saml-integration-card.tsx b/ui/components/integrations/saml/saml-integration-card.tsx index ca5c752154..f1b5c2c966 100644 --- a/ui/components/integrations/saml/saml-integration-card.tsx +++ b/ui/components/integrations/saml/saml-integration-card.tsx @@ -5,8 +5,8 @@ import { useState } from "react"; import { deleteSamlConfig } from "@/actions/integrations"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { Card, CardContent, CardHeader } from "../../shadcn"; @@ -53,8 +53,8 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => { return ( <> {/* Configure SAML Modal */} - @@ -62,11 +62,11 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => { setIsOpen={setIsSamlModalOpen} samlConfig={samlConfig} /> - + {/* Delete Confirmation Modal */} - {
- + diff --git a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx index 270318008a..b3b4d965ad 100644 --- a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx +++ b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx @@ -17,8 +17,8 @@ import { IntegrationSkeleton, } from "@/components/integrations/shared"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; @@ -93,7 +93,7 @@ export const SecurityHubIntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -125,7 +125,7 @@ export const SecurityHubIntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -176,7 +176,7 @@ export const SecurityHubIntegrationsManager = ({ description: result.error, }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", @@ -259,8 +259,8 @@ export const SecurityHubIntegrationsManager = ({ return ( <> -
- + - - +
diff --git a/ui/components/invitations/invitation-details.tsx b/ui/components/invitations/invitation-details.tsx index c7b0c7b23f..38d831bbfa 100644 --- a/ui/components/invitations/invitation-details.tsx +++ b/ui/components/invitations/invitation-details.tsx @@ -34,12 +34,14 @@ const InfoField = ({ label: string; children: React.ReactNode; }) => ( -
+
{label} -
- {children} +
+ + {children} +
); @@ -87,18 +89,18 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => { Share this link with the user:

-
+
-

- {invitationLink} -

+

{invitationLink}

diff --git a/ui/components/invitations/table/column-invitations.tsx b/ui/components/invitations/table/column-invitations.tsx index 597712170e..2af64ef6fd 100644 --- a/ui/components/invitations/table/column-invitations.tsx +++ b/ui/components/invitations/table/column-invitations.tsx @@ -77,9 +77,7 @@ export const ColumnsInvitation: ColumnDef[] = [ }, { accessorKey: "actions", - header: ({ column }) => ( - - ), + header: () => null, id: "actions", cell: ({ row }) => { const roles = row.original.roles; diff --git a/ui/components/invitations/table/data-table-row-actions.tsx b/ui/components/invitations/table/data-table-row-actions.tsx index 320f84fc35..4ec5f50e79 100644 --- a/ui/components/invitations/table/data-table-row-actions.tsx +++ b/ui/components/invitations/table/data-table-row-actions.tsx @@ -19,7 +19,7 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom"; +import { Modal } from "@/components/shadcn/modal"; import { DeleteForm, EditForm } from "../forms"; @@ -44,8 +44,8 @@ export function DataTableRowActions({ return ( <> - @@ -56,15 +56,15 @@ export function DataTableRowActions({ roles={roles || []} setIsOpen={setIsEditOpen} /> - - + - +
& VariantProps) { return ( - /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */
{ const isConfigured = await isLighthouseConfigured(); return ; - } catch (error) { + } catch (_error) { return null; } }; diff --git a/ui/components/manage-groups/forms/add-group-form.tsx b/ui/components/manage-groups/forms/add-group-form.tsx index 676bd85fca..fa03f1f4eb 100644 --- a/ui/components/manage-groups/forms/add-group-form.tsx +++ b/ui/components/manage-groups/forms/add-group-form.tsx @@ -95,7 +95,7 @@ export const AddGroupForm = ({ description: "The group was created successfully.", }); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", diff --git a/ui/components/manage-groups/forms/edit-group-form.tsx b/ui/components/manage-groups/forms/edit-group-form.tsx index 2adca0be12..781a502e5b 100644 --- a/ui/components/manage-groups/forms/edit-group-form.tsx +++ b/ui/components/manage-groups/forms/edit-group-form.tsx @@ -131,7 +131,7 @@ export const EditGroupForm = ({ }); router.push("/manage-groups"); } - } catch (error) { + } catch (_error) { toast({ variant: "destructive", title: "Error", diff --git a/ui/components/manage-groups/table/data-table-row-actions.tsx b/ui/components/manage-groups/table/data-table-row-actions.tsx index af9d2e0ade..3ea2f9c9a1 100644 --- a/ui/components/manage-groups/table/data-table-row-actions.tsx +++ b/ui/components/manage-groups/table/data-table-row-actions.tsx @@ -18,7 +18,7 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom"; +import { Modal } from "@/components/shadcn/modal"; import { DeleteGroupForm } from "../forms"; @@ -37,14 +37,14 @@ export function DataTableRowActions({ return ( <> - - +
{ - return row.original; -}; +const baseColumns: ColumnDef[] = getColumnFindings( + {} as RowSelectionState, + 0, +).filter((column) => column.id !== "select" && column.id !== "actions"); -const getFindingsMetadata = (row: { original: FindingProps }) => { - return row.original.attributes.check_metadata; -}; - -const getResourceData = ( - row: { original: FindingProps }, - field: keyof FindingProps["relationships"]["resource"]["attributes"], -) => { - return ( - row.original.relationships?.resource?.attributes?.[field] || - `No ${field} found in resource` - ); -}; - -const getProviderData = ( - row: { original: FindingProps }, - field: keyof FindingProps["relationships"]["provider"]["attributes"], -) => { - return ( - row.original.relationships?.provider?.attributes?.[field] || - `No ${field} found in provider` - ); -}; - -const FindingDetailsCell = ({ row }: { row: any }) => { - const searchParams = useSearchParams(); - const findingId = searchParams.get("id"); - const isOpen = findingId === row.original.id; - - return ( -
- - } - title="Finding Details" - description="View the finding details" - defaultOpen={isOpen} - > - - -
- ); -}; - -export const ColumnNewFindingsToDate: ColumnDef[] = [ - { - id: "moreInfo", - header: ({ column }) => ( - - ), - cell: ({ row }) => , - enableSorting: false, - }, - { - accessorKey: "check", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { checktitle } = getFindingsMetadata(row); - const { - attributes: { muted, muted_reason }, - } = getFindingsData(row); - const { delta } = row.original.attributes; - return ( -
-
- {delta === "new" || delta === "changed" ? ( - - ) : ( -
- )} -

- {checktitle} -

-
- - - -
- ); - }, - enableSorting: false, - }, - { - accessorKey: "resourceName", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const resourceName = getResourceData(row, "name"); - - return ( - `...${value.slice(-10)}`} - icon={} - /> - ); - }, - enableSorting: false, - }, - { - accessorKey: "severity", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { severity }, - } = getFindingsData(row); - return ; - }, - enableSorting: false, - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { status }, - } = getFindingsData(row); - - return ; - }, - enableSorting: false, - }, - { - accessorKey: "updated_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { updated_at }, - } = getFindingsData(row); - return ( -
- -
- ); - }, - enableSorting: false, - }, - { - accessorKey: "region", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const region = getResourceData(row, "region"); - - return ( -
- {typeof region === "string" ? region : "Invalid region"} -
- ); - }, - enableSorting: false, - }, - { - accessorKey: "service", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { servicename } = getFindingsMetadata(row); - return

{servicename}

; - }, - enableSorting: false, - }, - { - accessorKey: "cloudProvider", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const provider = getProviderData(row, "provider"); - const alias = getProviderData(row, "alias"); - const uid = getProviderData(row, "uid"); - - return ( - <> - - - ); - }, - enableSorting: false, - }, -]; +export const ColumnNewFindingsToDate: ColumnDef[] = baseColumns; diff --git a/ui/components/providers/provider-info.tsx b/ui/components/providers/provider-info.tsx index f33225eb93..9043c8b8e8 100644 --- a/ui/components/providers/provider-info.tsx +++ b/ui/components/providers/provider-info.tsx @@ -52,7 +52,7 @@ export const ProviderInfo = ({ return (
- {getProviderLogo(provider)} +
{getProviderLogo(provider)}
{getIcon()} {providerAlias || providerUID}
diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 1c47d2feee..1866abe8ab 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -20,7 +20,7 @@ import { useState } from "react"; import { checkConnectionProvider } from "@/actions/providers/providers"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom"; +import { Modal } from "@/components/shadcn/modal"; import { EditForm } from "../forms"; import { DeleteForm } from "../forms/delete-form"; @@ -61,8 +61,8 @@ export function DataTableRowActions({ return ( <> - @@ -71,15 +71,15 @@ export function DataTableRowActions({ providerAlias={providerAlias} setIsOpen={setIsEditOpen} /> - - + - +
{ router.push(`/providers/add-credentials?type=${providerType}&id=${id}`); } } catch (error: unknown) { - // eslint-disable-next-line no-console console.error("Error during submission:", error); toast({ variant: "destructive", diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index d7f5344b61..9076b8bd9c 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -153,7 +153,7 @@ export const TestConnectionForm = ({ setIsRedirecting(true); router.push("/scans"); } - } catch (error) { + } catch (_error) { form.setError("providerId", { type: "server", message: "An unexpected error occurred. Please try again.", @@ -198,7 +198,6 @@ export const TestConnectionForm = ({ `/providers/add-credentials?type=${providerType}&id=${providerId}`, ); } catch (error) { - // eslint-disable-next-line no-console console.error("Failed to delete credentials:", error); } finally { setIsResettingCredentials(false); diff --git a/ui/components/providers/workflow/forms/update-via-credentials-form.tsx b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx index 613882b381..f838b71478 100644 --- a/ui/components/providers/workflow/forms/update-via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx @@ -7,8 +7,10 @@ import { BaseCredentialsForm } from "./base-credentials-form"; export const UpdateViaCredentialsForm = ({ searchParams, + providerUid, }: { searchParams: { type: string; id: string; secretId?: string }; + providerUid?: string; }) => { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -24,6 +26,7 @@ export const UpdateViaCredentialsForm = ({ { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -24,6 +26,7 @@ export const UpdateViaRoleForm = ({ { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -24,6 +26,7 @@ export const UpdateViaServiceAccountForm = ({ { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handleOpenChange = (open: boolean) => { + if (!open) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("resourceId"); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + } + }; + + return ( + + + + Resource Details + + View the resource details + + + + + + ); +}; diff --git a/ui/components/resources/resources-filters.tsx b/ui/components/resources/resources-filters.tsx new file mode 100644 index 0000000000..7b1520d78d --- /dev/null +++ b/ui/components/resources/resources-filters.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; + +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { CustomDatePicker } from "@/components/filters/custom-date-picker"; +import { Button } from "@/components/shadcn"; +import { ExpandableSection } from "@/components/ui/expandable-section"; +import { DataTableFilterCustom } from "@/components/ui/table"; +import { getGroupLabel } from "@/lib/categories"; +import { ProviderProps } from "@/types/providers"; + +interface ResourcesFiltersProps { + providers: ProviderProps[]; + uniqueRegions: string[]; + uniqueServices: string[]; + uniqueGroups: string[]; +} + +export const ResourcesFilters = ({ + providers, + uniqueRegions, + uniqueServices, + uniqueGroups, +}: ResourcesFiltersProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + // Custom filters for the expandable section + const customFilters = [ + { + key: "region__in", + labelCheckboxGroup: "Region", + values: uniqueRegions, + index: 1, + }, + { + key: "service__in", + labelCheckboxGroup: "Service", + values: uniqueServices, + index: 2, + }, + { + key: "groups__in", + labelCheckboxGroup: "Group", + values: uniqueGroups, + labelFormatter: getGroupLabel, + index: 3, + }, + ]; + + const hasCustomFilters = customFilters.length > 0; + + return ( +
+ {/* First row: Provider selectors + More Filters button + Clear Filters */} +
+
+ +
+
+ +
+ {hasCustomFilters && ( + + )} + +
+ + {/* Expandable filters section */} + {hasCustomFilters && ( + + } + hideClearButton + /> + + )} +
+ ); +}; diff --git a/ui/components/resources/skeleton/skeleton-table-resources.tsx b/ui/components/resources/skeleton/skeleton-table-resources.tsx index 4fa4b024ea..8a9fde1492 100644 --- a/ui/components/resources/skeleton/skeleton-table-resources.tsx +++ b/ui/components/resources/skeleton/skeleton-table-resources.tsx @@ -1,39 +1,130 @@ -import React from "react"; - -import { Card } from "@/components/shadcn/card/card"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -export const SkeletonTableResources = () => { +const SkeletonTableRow = () => { return ( - - {/* Table headers */} -
- - - - - - - -
- - {/* Table body */} -
- {[...Array(3)].map((_, index) => ( -
- - - - - - - + + {/* Name - clickable text with copy icon */} + +
+ + +
+ + {/* Provider Account - logo + alias + uid chip */} + +
+ +
+ +
+ + +
- ))} -
- +
+ + {/* Failed Findings - badge */} + + + + {/* Group */} + + + + {/* Type */} + + + + {/* Region */} + + + + {/* Service */} + + + + {/* Actions */} + + + + + ); +}; + +export const SkeletonTableResources = () => { + const rows = 10; + + return ( +
+ {/* Toolbar: Search + Total entries */} +
+ {/* Search icon button */} + + {/* Total entries */} + +
+ + {/* Table */} + + + + {/* Name */} + + {/* Provider Account */} + + {/* Failed Findings */} + + {/* Group */} + + {/* Type */} + + {/* Region */} + + {/* Service */} + + {/* Actions - empty header */} + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+ + + + + + + + + + + + + + +
+ + {/* Pagination */} +
+ {/* Rows per page */} +
+ + +
+ {/* Page info + navigation */} +
+ +
+ + + + +
+
+
+
); }; diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index d26fe4f5e5..7f19390b7e 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -1,13 +1,18 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Database } from "lucide-react"; +import { AlertTriangle, Eye, MoreVertical } from "lucide-react"; import { useSearchParams } from "next/navigation"; +import { useState } from "react"; -import { InfoIcon } from "@/components/icons"; -import { EntityInfo, SnippetChip } from "@/components/ui/entities"; -import { TriggerSheet } from "@/components/ui/sheet"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { EntityInfo } from "@/components/ui/entities"; import { DataTableColumnHeader } from "@/components/ui/table"; +import { getGroupLabel } from "@/lib/categories"; import { ProviderType, ResourceProps } from "@/types"; import { ResourceDetail } from "./resource-detail"; @@ -19,12 +24,6 @@ const getResourceData = ( return row.original.attributes?.[field]; }; -const getChipStyle = (count: number) => { - if (count === 0) return "bg-green-100 text-green-800"; - if (count >= 10) return "bg-red-100 text-red-800"; - if (count >= 1) return "bg-yellow-100 text-yellow-800"; -}; - const getProviderData = ( row: { original: ResourceProps }, field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"], @@ -35,61 +34,125 @@ const getProviderData = ( ); }; -const ResourceDetailsCell = ({ row }: { row: any }) => { +// Component for resource name that opens the detail drawer +const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => { const searchParams = useSearchParams(); const resourceId = searchParams.get("resourceId"); const isOpen = resourceId === row.original.id; + const resourceName = row.original.attributes?.name; + const resourceUid = row.original.attributes?.uid; + const displayName = + typeof resourceName === "string" && resourceName.trim().length > 0 + ? resourceName + : "Unnamed resource"; return ( -
- - } - title="Resource Details" - description="View the Resource details" +
+ - - + trigger={ +
+

+ {displayName} +

+
+ } + /> + {resourceUid && }
); }; +// Component for failed findings badge with warning style +const FailedFindingsBadge = ({ count }: { count: number }) => { + if (count === 0) { + return ( + + 0 + + ); + } + + return ( + + + {count} + + ); +}; + +// Row actions dropdown +const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const resourceName = row.original.attributes?.name || "Resource"; + + return ( + <> +
+ + + + } + ariaLabel="Resource actions" + > + } + label="View details" + description={`View details for ${resourceName}`} + onSelect={() => setIsDrawerOpen(true)} + /> + +
+ + } + /> + + ); +}; + +// Column definitions for resources table export const ColumnResources: ColumnDef[] = [ - { - id: "moreInfo", - header: ({ column }) => ( - - ), - cell: ({ row }) => , - enableSorting: false, - }, + // Name column { accessorKey: "resourceName", header: ({ column }) => ( - + + ), + cell: ({ row }) => , + enableSorting: false, + }, + // Provider Account column + { + accessorKey: "provider", + header: ({ column }) => ( + ), cell: ({ row }) => { - const resourceName = getResourceData(row, "name"); - const displayName = - typeof resourceName === "string" && resourceName.trim().length > 0 - ? resourceName - : "Unnamed resource"; - + const provider = getProviderData(row, "provider"); + const alias = getProviderData(row, "alias"); + const uid = getProviderData(row, "uid"); return ( - } + ); }, enableSorting: false, }, + // Failed Findings column { accessorKey: "failedFindings", header: ({ column }) => ( @@ -101,84 +164,94 @@ export const ColumnResources: ColumnDef[] = [ "failed_findings_count", ) as number; + return ; + }, + enableSorting: false, + }, + // Group column + { + accessorKey: "groups", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const groups = getResourceData(row, "groups") as string[] | null; + + if (!groups || groups.length === 0) { + return

-

; + } + + const displayLabel = getGroupLabel(groups[0]); + const extraCount = groups.length - 1; + return ( - - {failedFindingsCount} - +
+

+ {displayLabel} +

+ {extraCount > 0 && ( + + +{extraCount} + + )} +
); }, enableSorting: false, }, - { - accessorKey: "region", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const region = getResourceData(row, "region"); - - return ( -
- {typeof region === "string" ? region : "Invalid region"} -
- ); - }, - }, + // Type column { accessorKey: "type", header: ({ column }) => ( - + ), cell: ({ row }) => { const type = getResourceData(row, "type"); return ( -
- {typeof type === "string" ? type : "Invalid type"} -
+

+ {typeof type === "string" ? type : "-"} +

); }, }, + // Region column + { + accessorKey: "region", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const region = getResourceData(row, "region"); + + return ( +

+ {typeof region === "string" ? region : "-"} +

+ ); + }, + }, + // Service column { accessorKey: "service", header: ({ column }) => ( - + ), cell: ({ row }) => { const service = getResourceData(row, "service"); return ( -
- {typeof service === "string" ? service : "Invalid region"} -
+

+ {typeof service === "string" ? service : "-"} +

); }, }, + // Actions column { - accessorKey: "provider", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const provider = getProviderData(row, "provider"); - const alias = getProviderData(row, "alias"); - const uid = getProviderData(row, "uid"); - return ( - <> - - - ); - }, + id: "actions", + header: () =>
, + cell: ({ row }) => , enableSorting: false, }, ]; diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts index 5f18c9b1d4..a03bacf648 100644 --- a/ui/components/resources/table/index.ts +++ b/ui/components/resources/table/index.ts @@ -1,3 +1,5 @@ export * from "../skeleton/skeleton-table-resources"; export * from "./column-resources"; export * from "./resource-detail"; +export * from "./resource-findings-columns"; +export * from "./resources-table-with-selection"; diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index 6c37f64dc3..e96322a921 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -1,51 +1,53 @@ "use client"; -import { Snippet } from "@heroui/snippet"; -import { Spinner } from "@heroui/spinner"; -import { Tooltip } from "@heroui/tooltip"; -import { ExternalLink, InfoIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Row, RowSelectionState } from "@tanstack/react-table"; +import { Check, Copy, ExternalLink, Link, Loader2, X } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; -import { getFindingById } from "@/actions/findings"; +import { getFindingById, getLatestFindings } from "@/actions/findings"; import { getResourceById } from "@/actions/resources"; +import { FloatingMuteButton } from "@/components/findings/floating-mute-button"; import { FindingDetail } from "@/components/findings/table/finding-detail"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { DateWithTime, getProviderLogo, InfoField, } from "@/components/ui/entities"; -import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; +import { DataTable } from "@/components/ui/table"; import { createDict } from "@/lib"; +import { getGroupLabel } from "@/lib/categories"; import { buildGitFileUrl } from "@/lib/iac-utils"; -import { FindingProps, ProviderType, ResourceProps } from "@/types"; +import { + FindingProps, + MetaDataProps, + ProviderType, + ResourceProps, +} from "@/types"; -const SEVERITY_ORDER = { - critical: 0, - high: 1, - medium: 2, - low: 3, - informational: 4, -} as const; - -type SeverityLevel = keyof typeof SEVERITY_ORDER; - -interface ResourceFinding { - type: "findings"; - id: string; - attributes: { - status: "PASS" | "FAIL" | "MANUAL"; - severity: SeverityLevel; - check_metadata?: { - checktitle?: string; - }; - }; -} - -interface FindingReference { - id: string; -} +import { + getResourceFindingsColumns, + ResourceFinding, +} from "./resource-findings-columns"; const renderValue = (value: string | null | undefined) => { return value && value.trim() !== "" ? value : "-"; @@ -97,133 +99,264 @@ const buildCustomBreadcrumbs = ( return breadcrumbs; }; +interface ResourceDetailProps { + resourceDetails: ResourceProps; + trigger?: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + export const ResourceDetail = ({ - resourceId, - initialResourceData, -}: { - resourceId: string; - initialResourceData: ResourceProps; -}) => { + resourceDetails, + trigger, + open: controlledOpen, + defaultOpen = false, + onOpenChange, +}: ResourceDetailProps) => { const [findingsData, setFindingsData] = useState([]); + const [findingsMetadata, setFindingsMetadata] = + useState(null); const [resourceTags, setResourceTags] = useState>({}); const [findingsLoading, setFindingsLoading] = useState(true); + const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); + const [findingsReloadNonce, setFindingsReloadNonce] = useState(0); const [selectedFindingId, setSelectedFindingId] = useState( null, ); const [findingDetails, setFindingDetails] = useState( null, ); + const [findingDetailLoading, setFindingDetailLoading] = useState(false); + const [rowSelection, setRowSelection] = useState({}); + const [activeTab, setActiveTab] = useState("overview"); + const [metadataCopied, setMetadataCopied] = useState(false); + // Track internal open state for uncontrolled drawer (when using trigger) + const [internalOpen, setInternalOpen] = useState(defaultOpen); + // Drawer-local pagination and search state (not in URL to avoid page re-renders) + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [searchQuery, setSearchQuery] = useState(""); + const findingFetchRef = useRef(null); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + findingFetchRef.current?.abort(); + }; + }, []); + + // Determine if drawer is actually open: + // - If controlled (open prop provided), use that + // - If uncontrolled (using trigger), use internal state + // - If no trigger (inline mode), always consider it "open" + const isDrawerOpen = !trigger || (controlledOpen ?? internalOpen); + + // Handle open state changes for uncontrolled mode + const handleOpenChange = (newOpen: boolean) => { + setInternalOpen(newOpen); + onOpenChange?.(newOpen); + + // Reset all drawer state when closing + if (!newOpen) { + findingFetchRef.current?.abort(); + setActiveTab("overview"); + setSelectedFindingId(null); + setFindingDetails(null); + setFindingDetailLoading(false); + setRowSelection({}); + setCurrentPage(1); + setPageSize(10); + setSearchQuery(""); + setHasInitiallyLoaded(false); + } + }; + + const resource = resourceDetails; + const resourceId = resource.id; + const attributes = resource.attributes; + const providerData = resource.relationships.provider.data.attributes; + + const copyResourceUrl = () => { + const params = new URLSearchParams(searchParams.toString()); + params.set("resourceId", resourceId); + const url = `${window.location.origin}${pathname}?${params.toString()}`; + navigator.clipboard.writeText(url); + }; + + const copyMetadata = async (metadata: Record) => { + await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2)); + setMetadataCopied(true); + setTimeout(() => setMetadataCopied(false), 2000); + }; + + // Load resource tags (separate from findings) - only when drawer is open + useEffect(() => { + const loadResourceTags = async () => { + try { + const resourceData = await getResourceById(resourceId, { + fields: ["tags"], + }); + if (resourceData?.data) { + setResourceTags(resourceData.data.attributes.tags || {}); + } + } catch (err) { + console.error("Error loading resource tags:", err); + setResourceTags({}); + } + }; + + if (resourceId && isDrawerOpen) { + loadResourceTags(); + } + }, [resourceId, isDrawerOpen]); + + // Load findings with server-side pagination and search - only when drawer is open useEffect(() => { const loadFindings = async () => { setFindingsLoading(true); try { - const resourceData = await getResourceById(resourceId, { - include: ["findings"], - fields: ["tags", "findings"], + const findingsResponse = await getLatestFindings({ + page: currentPage, + pageSize, + query: searchQuery, + sort: "severity,-inserted_at", + filters: { + "filter[resource_uid]": attributes.uid, + "filter[status]": "FAIL", + }, }); - if (resourceData?.data) { - // Get tags from the detailed resource data - setResourceTags(resourceData.data.attributes.tags || {}); - - // Create dictionary for findings and expand them - if (resourceData.data.relationships?.findings) { - const findingsDict = createDict("findings", resourceData); - const findings = - resourceData.data.relationships.findings.data?.map( - (finding: FindingReference) => findingsDict[finding.id], - ) || []; - setFindingsData(findings as ResourceFinding[]); - } else { - setFindingsData([]); - } + if (findingsResponse?.data) { + setFindingsMetadata(findingsResponse.meta || null); + setFindingsData(findingsResponse.data as ResourceFinding[]); } else { setFindingsData([]); - setResourceTags({}); + setFindingsMetadata(null); } } catch (err) { console.error("Error loading findings:", err); setFindingsData([]); - setResourceTags({}); + setFindingsMetadata(null); } finally { setFindingsLoading(false); + setHasInitiallyLoaded(true); } }; - if (resourceId) { + if (attributes.uid && isDrawerOpen) { loadFindings(); } - }, [resourceId]); + }, [ + attributes.uid, + currentPage, + pageSize, + searchQuery, + isDrawerOpen, + findingsReloadNonce, + ]); const navigateToFinding = async (findingId: string) => { + // Cancel any in-flight request + if (findingFetchRef.current) { + findingFetchRef.current.abort(); + } + findingFetchRef.current = new AbortController(); + setSelectedFindingId(findingId); + setFindingDetailLoading(true); try { const findingData = await getFindingById( findingId, "resources,scan.provider", ); + + // Check if request was aborted + if (findingFetchRef.current?.signal.aborted) { + return; + } + if (findingData?.data) { - // Create dictionaries for resources, scans, and providers const resourceDict = createDict("resources", findingData); const scanDict = createDict("scans", findingData); const providerDict = createDict("providers", findingData); - // Expand the finding with its corresponding resource, scan, and provider const finding = findingData.data; const scan = scanDict[finding.relationships?.scan?.data?.id]; - const resource = + const foundResource = resourceDict[finding.relationships?.resources?.data?.[0]?.id]; const provider = providerDict[scan?.relationships?.provider?.data?.id]; const expandedFinding = { ...finding, - relationships: { scan, resource, provider }, + relationships: { scan, resource: foundResource, provider }, }; setFindingDetails(expandedFinding); } } catch (error) { + // Ignore abort errors + if (error instanceof Error && error.name === "AbortError") { + return; + } console.error("Error fetching finding:", error); + } finally { + // Only update loading state if this request wasn't aborted + if (!findingFetchRef.current?.signal.aborted) { + setFindingDetailLoading(false); + } } }; const handleBackToResource = () => { setSelectedFindingId(null); setFindingDetails(null); + setFindingDetailLoading(false); }; - if (!initialResourceData) { - return ( -
- -

- Loading resource details... -

-
- ); - } + const handleMuteComplete = (_findingIds?: string[]) => { + const ids = + _findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds; - const resource = initialResourceData; - const attributes = resource.attributes; - const providerData = resource.relationships.provider.data.attributes; + setRowSelection({}); + if (ids.length > 0) setFindingsReloadNonce((v) => v + 1); + router.refresh(); + }; - // Filter only failed findings and sort by severity - const failedFindings = findingsData - .filter( - (finding: ResourceFinding) => finding?.attributes?.status === "FAIL", - ) - .sort((a: ResourceFinding, b: ResourceFinding) => { - const severityA = (a?.attributes?.severity?.toLowerCase() || - "informational") as SeverityLevel; - const severityB = (b?.attributes?.severity?.toLowerCase() || - "informational") as SeverityLevel; - return ( - (SEVERITY_ORDER[severityA] ?? 999) - (SEVERITY_ORDER[severityB] ?? 999) - ); - }); + // Findings are already filtered (FAIL only) and sorted by the server + const failedFindings = findingsData; + + const selectableRowCount = failedFindings.filter( + (f) => !f.attributes.muted, + ).length; + + // Reset selection when page changes + useEffect(() => { + setRowSelection({}); + }, [currentPage, pageSize]); + + // Calculate total findings from metadata for tab title + const totalFindings = findingsMetadata?.pagination?.count || 0; + + const getRowCanSelect = (row: Row): boolean => + !row.original.attributes.muted; + + const selectedFindingIds = Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .map((idx) => failedFindings[parseInt(idx)]?.id) + .filter(Boolean); + + const columns = getResourceFindingsColumns( + rowSelection, + selectableRowCount, + navigateToFinding, + handleMuteComplete, + ); // Build Git URL for IaC resources const gitUrl = @@ -236,86 +369,134 @@ export const ResourceDetail = ({ ) : null; - if (selectedFindingId) { - const findingTitle = - findingDetails?.attributes?.check_metadata?.checktitle || - "Finding Detail"; + // Content when viewing a finding detail (breadcrumb navigation) + const findingTitle = + findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail"; - return ( -
- + const findingContent = ( +
+ - {findingDetails && } -
- ); - } + {findingDetailLoading ? ( +
+ +

+ Loading finding details... +

+
+ ) : ( + findingDetails && + )} +
+ ); - return ( -
- {/* Resource Details section */} - - -
- Resource Details - {providerData.provider === "iac" && gitUrl && ( - - + {/* Header */} +
+ {/* Provider logo */} +
+ {getProviderLogo(providerData.provider as ProviderType)} +
+ + {/* Details column */} +
+ {/* Title with copy link and optional IaC link */} +
+

+ {renderValue(attributes.name)} +

+ + +
+ + Copy resource link to clipboard + + {providerData.provider === "iac" && gitUrl && ( + + + + + View in Repository + + + + Go to Resource in the Repository + )}
- {getProviderLogo(providerData.provider as ProviderType)} - - + + {/* Last Updated */} +
+ + Last Updated: + + +
+
+
+ + {/* Tabs */} + + + Overview + + Findings {totalFindings > 0 && `(${totalFindings})`} + + + + {/* Overview Tab */} + - - - {renderValue(attributes.uid)} - - +
- - {renderValue(attributes.name)} - - - {renderValue(attributes.type)} - + {renderValue(attributes.name)} + {renderValue(attributes.type)}
+ + {attributes.groups && attributes.groups.length > 0 + ? attributes.groups.map(getGroupLabel).join(", ") + : "-"} + {renderValue(attributes.service)} +
+
{renderValue(attributes.region)} -
-
{renderValue(attributes.partition)} - - {renderValue(attributes.details)} -
+ + {renderValue(attributes.details)} +
@@ -331,16 +512,19 @@ export const ResourceDetail = ({ Object.entries(parsedMetadata).length > 0 ? (
- copyMetadata(parsedMetadata)} + className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors" + aria-label="Copy metadata to clipboard" > - {JSON.stringify(parsedMetadata, null, 2)} - -
+                    {metadataCopied ? (
+                      
+                    ) : (
+                      
+                    )}
+                  
+                  
                     {JSON.stringify(parsedMetadata, null, 2)}
                   
@@ -350,7 +534,7 @@ export const ResourceDetail = ({ {resourceTags && Object.entries(resourceTags).length > 0 ? (
-

+

Tags

@@ -362,79 +546,82 @@ export const ResourceDetail = ({
) : null} - - + - {/* Failed findings associated with this resource section */} - - - Failed findings associated with this resource - - - {findingsLoading ? ( + {/* Findings Tab */} + + {findingsLoading && !hasInitiallyLoaded ? (
- -

+ +

Loading findings...

- ) : failedFindings.length > 0 ? ( -
-

- Total failed findings: {failedFindings.length} -

- {failedFindings.map((finding: ResourceFinding, index: number) => { - const { attributes: findingAttrs, id } = finding; - - // Handle cases where finding might not have all attributes - if (!findingAttrs) { - return ( -
-

- Finding {id} - No attributes available -

-
- ); - } - - const { severity, check_metadata, status } = findingAttrs; - const checktitle = - check_metadata?.checktitle || "Unknown check"; - - return ( - - ); - })} -
) : ( -

- No failed findings found for this resource. -

+ <> + { + setSearchQuery(value); + setCurrentPage(1); // Reset to first page on search + }} + controlledPage={currentPage} + controlledPageSize={pageSize} + onPageChange={setCurrentPage} + onPageSizeChange={setPageSize} + isLoading={findingsLoading} + /> + {selectedFindingIds.length > 0 && ( + + )} + )} -
-
+ +
); + + // Determine which content to show + const content = selectedFindingId ? findingContent : resourceContent; + + // If no trigger, render content directly (inline mode) + if (!trigger) { + return content; + } + + // With trigger, wrap in Drawer + return ( + + {trigger} + + + Resource Details + View the resource details + + + + Close + + {content} + + + ); }; diff --git a/ui/components/resources/table/resource-findings-columns.tsx b/ui/components/resources/table/resource-findings-columns.tsx new file mode 100644 index 0000000000..bd0a42765a --- /dev/null +++ b/ui/components/resources/table/resource-findings-columns.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; + +import { DataTableRowActions } from "@/components/findings/table"; +import { + DeltaType, + NotificationIndicator, +} from "@/components/findings/table/notification-indicator"; +import { Checkbox } from "@/components/shadcn"; +import { DateWithTime } from "@/components/ui/entities"; +import { + DataTableColumnHeader, + Severity, + SeverityBadge, + StatusFindingBadge, +} from "@/components/ui/table"; + +export interface ResourceFinding { + type: "findings"; + id: string; + attributes: { + status: "PASS" | "FAIL" | "MANUAL"; + severity: Severity; + muted?: boolean; + muted_reason?: string; + delta?: DeltaType; + updated_at?: string; + check_metadata?: { + checktitle?: string; + }; + }; +} + +export const getResourceFindingsColumns = ( + rowSelection: RowSelectionState, + selectableRowCount: number, + onNavigate: (id: string) => void, + onMuteComplete?: (findingIds: string[]) => void, +): ColumnDef[] => { + const selectedCount = Object.values(rowSelection).filter(Boolean).length; + const isAllSelected = + selectedCount > 0 && selectedCount === selectableRowCount; + const isSomeSelected = + selectedCount > 0 && selectedCount < selectableRowCount; + + return [ + { + id: "notification", + header: () => null, + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + id: "select", + header: ({ table }) => ( +
+ + table.toggleAllPageRowsSelected(checked === true) + } + aria-label="Select all" + disabled={selectableRowCount === 0} + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(checked === true)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + accessorKey: "finding", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + accessorKey: "severity", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + accessorKey: "updated_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + id: "actions", + header: () =>
, + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + ]; +}; diff --git a/ui/components/resources/table/resources-table-with-selection.tsx b/ui/components/resources/table/resources-table-with-selection.tsx new file mode 100644 index 0000000000..1383d165e4 --- /dev/null +++ b/ui/components/resources/table/resources-table-with-selection.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { DataTable } from "@/components/ui/table"; +import { MetaDataProps, ResourceProps } from "@/types"; + +import { ColumnResources } from "./column-resources"; + +interface ResourcesTableWithSelectionProps { + data: ResourceProps[]; + metadata?: MetaDataProps; +} + +export function ResourcesTableWithSelection({ + data, + metadata, +}: ResourcesTableWithSelectionProps) { + // Ensure data is always an array for safe operations + const safeData = data ?? []; + + return ( + + ); +} diff --git a/ui/components/roles/table/column-roles.tsx b/ui/components/roles/table/column-roles.tsx index 7faf340aa9..d65be6f253 100644 --- a/ui/components/roles/table/column-roles.tsx +++ b/ui/components/roles/table/column-roles.tsx @@ -105,9 +105,7 @@ export const ColumnsRoles: ColumnDef[] = [ }, { accessorKey: "actions", - header: ({ column }) => ( - - ), + header: () => null, id: "actions", cell: ({ row }) => { return ; diff --git a/ui/components/roles/table/data-table-row-actions.tsx b/ui/components/roles/table/data-table-row-actions.tsx index 7e6f0e94ff..0eb7864a51 100644 --- a/ui/components/roles/table/data-table-row-actions.tsx +++ b/ui/components/roles/table/data-table-row-actions.tsx @@ -18,7 +18,7 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; +import { Modal } from "@/components/shadcn/modal"; import { DeleteRoleForm } from "../workflow/forms"; interface DataTableRowActionsProps { @@ -34,14 +34,14 @@ export function DataTableRowActions({ const roleId = (row.original as { id: string }).id; return ( <> - - +
void | Promise; } -export function AutoRefresh({ hasExecutingScan }: AutoRefreshProps) { +export function AutoRefresh({ hasExecutingScan, onRefresh }: AutoRefreshProps) { const router = useRouter(); const searchParams = useSearchParams(); @@ -19,11 +21,17 @@ export function AutoRefresh({ hasExecutingScan }: AutoRefreshProps) { if (scanId) return; const interval = setInterval(() => { - router.refresh(); + if (onRefresh) { + // Use custom refresh callback for client-side state management + onRefresh(); + } else { + // Default: trigger server-side refresh + router.refresh(); + } }, 5000); return () => clearInterval(interval); - }, [hasExecutingScan, router, searchParams]); + }, [hasExecutingScan, router, searchParams, onRefresh]); return null; } diff --git a/ui/components/scans/table/scans/data-table-row-actions.tsx b/ui/components/scans/table/scans/data-table-row-actions.tsx index 8e284df763..b9d16db9ab 100644 --- a/ui/components/scans/table/scans/data-table-row-actions.tsx +++ b/ui/components/scans/table/scans/data-table-row-actions.tsx @@ -17,8 +17,8 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { downloadScanZip } from "@/lib/helper"; import { EditScanForm } from "../../forms"; @@ -39,8 +39,8 @@ export function DataTableRowActions({ return ( <> - @@ -49,7 +49,7 @@ export function DataTableRowActions({ scanName={scanName} setIsOpen={setIsEditOpen} /> - +
{ }); } } catch (error) { - // eslint-disable-next-line no-console console.error("Error in fetchScanDetails:", error); } finally { setIsLoading(false); diff --git a/ui/components/shadcn/dropdown/action-dropdown.tsx b/ui/components/shadcn/dropdown/action-dropdown.tsx new file mode 100644 index 0000000000..d272872129 --- /dev/null +++ b/ui/components/shadcn/dropdown/action-dropdown.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { MoreHorizontal } from "lucide-react"; +import { ComponentProps, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./dropdown"; + +interface ActionDropdownProps { + /** The dropdown trigger element. Defaults to a vertical dots icon button */ + trigger?: ReactNode; + /** Label shown at the top of the dropdown */ + label?: string; + /** Alignment of the dropdown content */ + align?: "start" | "center" | "end"; + /** Additional className for the content */ + className?: string; + /** Accessible label for the trigger */ + ariaLabel?: string; + children: ReactNode; +} + +export function ActionDropdown({ + trigger, + label = "Actions", + align = "end", + className, + ariaLabel = "Open actions menu", + children, +}: ActionDropdownProps) { + return ( + + + {trigger ?? ( + + )} + + + {label && ( + <> + {label} + + + )} + {children} + + + ); +} + +interface ActionDropdownItemProps + extends Omit, "children"> { + /** Icon displayed before the label */ + icon?: ReactNode; + /** Main label text */ + label: ReactNode; + /** Optional description text below the label */ + description?: string; + /** Whether the item is destructive (danger styling) */ + destructive?: boolean; +} + +export function ActionDropdownItem({ + icon, + label, + description, + destructive = false, + className, + ...props +}: ActionDropdownItemProps) { + return ( + + {icon && ( + svg]:size-5", + destructive && "text-destructive", + )} + > + {icon} + + )} +
+ {label} + {description && ( + + {description} + + )} +
+
+ ); +} + +// Re-export commonly used components for convenience +export { + DropdownMenuLabel as ActionDropdownLabel, + DropdownMenuSeparator as ActionDropdownSeparator, +} from "./dropdown"; diff --git a/ui/components/shadcn/dropdown/index.ts b/ui/components/shadcn/dropdown/index.ts new file mode 100644 index 0000000000..d21a2ff982 --- /dev/null +++ b/ui/components/shadcn/dropdown/index.ts @@ -0,0 +1,23 @@ +export { + ActionDropdown, + ActionDropdownItem, + ActionDropdownLabel, + ActionDropdownSeparator, +} from "./action-dropdown"; +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./dropdown"; diff --git a/ui/components/shadcn/info-field/info-field.tsx b/ui/components/shadcn/info-field/info-field.tsx index 2f45957e99..d3bf0144ae 100644 --- a/ui/components/shadcn/info-field/info-field.tsx +++ b/ui/components/shadcn/info-field/info-field.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"; -const INFO_FIELD_VARIANTS = { +export const INFO_FIELD_VARIANTS = { default: "default", simple: "simple", transparent: "transparent", diff --git a/ui/components/shadcn/modal/index.ts b/ui/components/shadcn/modal/index.ts new file mode 100644 index 0000000000..18fc26b76e --- /dev/null +++ b/ui/components/shadcn/modal/index.ts @@ -0,0 +1 @@ +export { Modal } from "./modal"; diff --git a/ui/components/shadcn/modal/modal.tsx b/ui/components/shadcn/modal/modal.tsx new file mode 100644 index 0000000000..24e441ca23 --- /dev/null +++ b/ui/components/shadcn/modal/modal.tsx @@ -0,0 +1,67 @@ +import { ReactNode } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/shadcn/dialog"; +import { cn } from "@/lib/utils"; + +const SIZE_CLASSES = { + sm: "sm:max-w-sm", + md: "sm:max-w-md", + lg: "sm:max-w-lg", + xl: "sm:max-w-xl", + "2xl": "sm:max-w-2xl", + "3xl": "sm:max-w-3xl", + "4xl": "sm:max-w-4xl", + "5xl": "sm:max-w-5xl", +} as const; + +type ModalSize = keyof typeof SIZE_CLASSES; + +interface ModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + description?: string; + children: ReactNode; + size?: ModalSize; + className?: string; +} + +export const Modal = ({ + open, + onOpenChange, + title, + description, + children, + size = "xl", + className, +}: ModalProps) => { + return ( + + + {title && ( + + {title} + {description && ( + + {description} + + )} + + )} + {children} + + + ); +}; diff --git a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx index cbc46c6d25..02740bae7a 100644 --- a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx +++ b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx @@ -54,6 +54,7 @@ export function BreadcrumbNavigation({ "/manage-groups": "lucide:users-2", "/services": "lucide:server", "/workloads": "lucide:layers", + "/attack-paths": "lucide:git-branch", }; const pathSegments = pathname @@ -156,6 +157,7 @@ export function BreadcrumbNavigation({ > {breadcrumb.icon && typeof breadcrumb.icon === "string" ? (