diff --git a/.env b/.env index ac2af1631f..734b9df42a 100644 --- a/.env +++ b/.env @@ -54,7 +54,7 @@ NEO4J_PORT=7687 NEO4J_USER=neo4j NEO4J_PASSWORD=neo4j_password # Neo4j settings -NEO4J_DBMS_MAX__DATABASES=1000000 +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 @@ -66,7 +66,7 @@ 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 -NEO4J_INSERT_BATCH_SIZE=500 +ATTACK_PATHS_FINDINGS_BATCH_SIZE=1000 # Celery-Prowler task settings TASK_RETRY_DELAY_SECONDS=0.1 diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index ed1aa2cc58..e8a8efc879 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -61,9 +61,8 @@ jobs: - name: Safety if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check --ignore 79023,79027,82754 + run: poetry run safety check --ignore 79023,79027 # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - # TODO: 82754 filelock TOCTOU symlink vulnerability - transitive dep, awaiting upstream fix - name: Vulture if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index 565c378d9a..6dcc2f73af 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -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 ca4cbf871b..e7d1834f91 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -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/AGENTS.md b/AGENTS.md index d7bc86b5b1..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) | @@ -40,6 +41,7 @@ Use these skills for detailed patterns on-demand: | `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) | @@ -51,14 +53,19 @@ 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` | @@ -67,14 +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` | diff --git a/api/AGENTS.md b/api/AGENTS.md index b4a488a12a..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 @@ -13,10 +14,17 @@ 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` | diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 9262b2d02b..f0802ef42a 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,51 +2,79 @@ 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) -- `/api/v1/overviews/resource-groups` to retrieve an overview of the resource groups based on finding severities [(#9694)](https://github.com/prowler-cloud/prowler/pull/9694) -- Endpoints `GET /findings` and `GET /findings/metadata/latest` now support the `group` filter [(#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 scans for AWS providers with new Neo4j Docker service, new Celery task, new `AttackPathsScan` model and new endpoints under `/api/v1/attack-paths-scans` [(#9805)](https://github.com/prowler-cloud/prowler/pull/9805) +### πŸ”„ Changed -### Security -- `pyasn1` to v0.6.2 to address [CVE-2026-23490](https://nvd.nist.gov/vuln/detail/CVE-2026-23490) +- Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857) --- -## [1.17.2] (Prowler v5.16.2) +## [1.18.1] (Prowler v5.17.1) -### 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 + +- 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) @@ -54,19 +82,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) @@ -74,7 +105,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) @@ -82,7 +114,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) @@ -91,7 +124,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) @@ -110,11 +144,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) @@ -122,14 +158,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) @@ -138,7 +176,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) @@ -151,35 +190,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) @@ -188,101 +233,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) @@ -290,20 +354,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) @@ -311,7 +379,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) @@ -322,14 +391,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) @@ -340,7 +411,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) @@ -350,7 +421,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) @@ -362,14 +433,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) @@ -377,14 +450,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) @@ -393,19 +468,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 c6eed25700..fdec00aeb6 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" @@ -821,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"}, @@ -2676,13 +2676,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] @@ -2694,6 +2695,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)"] @@ -3188,21 +3190,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" @@ -3948,7 +3945,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"}, @@ -3988,7 +3985,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"}, @@ -4025,7 +4022,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"}, @@ -4303,6 +4300,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 = "jsonpickle" version = "4.1.1" @@ -5501,6 +5510,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" @@ -6304,33 +6339,34 @@ 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" @@ -6554,133 +6590,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] @@ -7301,6 +7326,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" @@ -7716,34 +7882,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)"] @@ -7752,20 +7918,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" @@ -7959,7 +8125,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"}, @@ -8041,7 +8207,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"}, @@ -8152,21 +8318,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" @@ -8878,4 +9029,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "87a29ee9d43486c2769aedae09b8f37b1f3709b62d39d602962307072e4d71ab" +content-hash = "15b849034c0969b944c45f341169a42160efe2525fe60a4cadd8a8909202e9e3" diff --git a/api/pyproject.toml b/api/pyproject.toml index 711b20092a..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", @@ -49,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" @@ -70,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 6c7eadb631..543c10ab88 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -1,5 +1,4 @@ import logging -import atexit import os import sys from pathlib import Path @@ -32,20 +31,47 @@ class ApiConfig(AppConfig): from api import schema_extensions # noqa: F401 from api import signals # noqa: F401 from api.attack_paths import database as graph_database - from api.compliance import load_prowler_compliance # 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() - if not getattr(settings, "TESTING", False): - graph_database.init_driver() - atexit.register(graph_database.close_driver) + # Commands that don't need Neo4j + SKIP_NEO4J_DJANGO_COMMANDS = [ + "makemigrations", + "migrate", + "pgpartition", + "check", + "help", + "showmigrations", + "check_and_fix_socialaccount_sites_migration", + ] - load_prowler_compliance() + # 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): """ diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index eaa9da3713..08b5552054 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -1,13 +1,12 @@ +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 @@ -42,10 +41,18 @@ def init_driver() -> neo4j.Driver: config = settings.DATABASES["neo4j"] _driver = neo4j.GraphDatabase.driver( - uri, auth=(config["USER"], config["PASSWORD"]) + 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 @@ -71,7 +78,6 @@ def get_session(database: str | None = None) -> Iterator[RetryableSession]: try: session_wrapper = RetryableSession( session_factory=lambda: get_driver().session(database=database), - close_driver=close_driver, # Just to avoid circular imports max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, ) yield session_wrapper @@ -119,6 +125,17 @@ def drop_subgraph(database: str, root_node_label: str, root_node_id: str) -> int 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}" diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py index 79bf383fff..2c70bc6a8e 100644 --- a/api/src/backend/api/attack_paths/retryable_session.py +++ b/api/src/backend/api/attack_paths/retryable_session.py @@ -17,11 +17,9 @@ class RetryableSession: def __init__( self, session_factory: Callable[[], neo4j.Session], - close_driver: Callable[[], None], # Just to avoid circular imports max_retries: int, ) -> None: self._session_factory = session_factory - self._close_driver = close_driver self._max_retries = max(0, max_retries) self._session = self._session_factory() @@ -58,7 +56,7 @@ class RetryableSession: def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any: attempt = 0 - last_exc: neo4j.exceptions.ServiceUnavailable | None = None + last_exc: Exception | None = None while attempt <= self._max_retries: try: @@ -66,7 +64,9 @@ class RetryableSession: return method(*args, **kwargs) except ( - neo4j.exceptions.ServiceUnavailable + BrokenPipeError, + ConnectionResetError, + neo4j.exceptions.ServiceUnavailable, ) as exc: # pragma: no cover - depends on infra last_exc = exc attempt += 1 @@ -75,7 +75,7 @@ class RetryableSession: raise logger.warning( - f"Neo4j session {method_name} failed with ServiceUnavailable ({attempt}/{self._max_retries} attempts). Retrying..." + f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..." ) self._refresh_session() @@ -83,7 +83,10 @@ class RetryableSession: def _refresh_session(self) -> None: if self._session is not None: - self._session.close() + try: + self._session.close() + except Exception: + # Best-effort close; failures just mean we open a new session below + pass - self._close_driver() self._session = self._session_factory() 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/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index af352b2ba1..45a3aae326 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. 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_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 a05345aaac..989c019198 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3867,6 +3867,7 @@ class TestAttackPathsScanViewSet: "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( @@ -3889,6 +3890,7 @@ class TestAttackPathsScanViewSet: 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"] @@ -4000,6 +4002,7 @@ class TestAttackPathsScanViewSet: "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( diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index e624a36aab..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") diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 003827dd74..d20558e139 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -92,6 +92,7 @@ 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 @@ -393,7 +394,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." ) @@ -2652,6 +2653,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet): 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"): @@ -4665,7 +4667,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 diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py index e244b6cca7..1bfe4eefac 100644 --- a/api/src/backend/tasks/jobs/attack_paths/aws.py +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -59,6 +59,7 @@ def start_aws_ingestion( ) # 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}, @@ -84,13 +85,22 @@ def start_aws_ingestion( ) 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, @@ -98,6 +108,9 @@ def start_aws_ingestion( ) 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, @@ -105,6 +118,7 @@ def start_aws_ingestion( ) 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", @@ -118,6 +132,7 @@ def start_aws_ingestion( # 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, @@ -125,6 +140,7 @@ def start_aws_ingestion( ) 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 ) diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py index 63451ef74d..92f79f6f36 100644 --- a/api/src/backend/tasks/jobs/attack_paths/db_utils.py +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -1,6 +1,7 @@ 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 @@ -153,9 +154,15 @@ def get_provider_graph_database_names(tenant_id: str, provider_id: str) -> list[ 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( - provider_id=provider_id, - is_graph_database_deleted=False, - ).values_list("graph_database", flat=True) + 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/prowler.py b/api/src/backend/tasks/jobs/attack_paths/prowler.py index 8678045811..7a92b9d760 100644 --- a/api/src/backend/tasks/jobs/attack_paths/prowler.py +++ b/api/src/backend/tasks/jobs/attack_paths/prowler.py @@ -1,18 +1,21 @@ -import neo4j +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 api.db_utils import rls_transaction -from api.models import Provider, ResourceFindingMapping from config.env import env -from prowler.config import config as ProwlerConfig 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("NEO4J_INSERT_BATCH_SIZE", 500) +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);", @@ -22,12 +25,18 @@ INDEX_STATEMENTS = [ ] INSERT_STATEMENT_TEMPLATE = """ + MATCH (account:__ROOT_NODE_LABEL__ {id: $provider_uid}) UNWIND $findings_data AS finding_data - MATCH (account:__ROOT_NODE_LABEL__ {id: $provider_uid}) - MATCH (account)-->(resource) - WHERE resource.__NODE_UID_FIELD__ = finding_data.resource_uid - OR resource.id = finding_data.resource_uid + 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 @@ -83,9 +92,8 @@ 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 node types.") + logger.info("Creating indexes for Prowler Findings node types") for statement in INDEX_STATEMENTS: - logger.debug("Executing statement: %s", statement) run_write_query(neo4j_session, statement) @@ -103,58 +111,131 @@ def analysis( def get_provider_last_scan_findings( prowler_api_provider: Provider, scan_id: str, -) -> list[dict[str, str]]: - with rls_transaction(prowler_api_provider.tenant_id): - resource_finding_qs = ResourceFindingMapping.objects.filter( - finding__scan_id=scan_id, - ).values( - "resource__uid", - "finding__id", - "finding__uid", - "finding__inserted_at", - "finding__updated_at", - "finding__first_seen_at", - "finding__scan_id", - "finding__delta", - "finding__status", - "finding__status_extended", - "finding__severity", - "finding__check_id", - "finding__check_metadata__checktitle", - "finding__muted", - "finding__muted_reason", - ) +) -> Generator[list[dict[str, str]], None, None]: + """ + Generator that yields batches of finding-resource pairs. - findings = [] - for resource_finding in resource_finding_qs: - findings.append( + 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_finding["resource__uid"]), - "id": str(resource_finding["finding__id"]), - "uid": resource_finding["finding__uid"], - "inserted_at": resource_finding["finding__inserted_at"], - "updated_at": resource_finding["finding__updated_at"], - "first_seen_at": resource_finding["finding__first_seen_at"], - "scan_id": str(resource_finding["finding__scan_id"]), - "delta": resource_finding["finding__delta"], - "status": resource_finding["finding__status"], - "status_extended": resource_finding["finding__status_extended"], - "severity": resource_finding["finding__severity"], - "check_id": str(resource_finding["finding__check_id"]), - "check_title": resource_finding[ - "finding__check_metadata__checktitle" - ], - "muted": resource_finding["finding__muted"], - "muted_reason": resource_finding["finding__muted_reason"], + "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 findings + return results def load_findings( neo4j_session: neo4j.Session, - findings_data: list[dict[str, str]], + findings_batches: Generator[list[dict[str, str]], None, None], prowler_api_provider: Provider, config: CartographyConfig, ) -> None: @@ -172,16 +253,20 @@ def load_findings( "prowler_version": ProwlerConfig.prowler_version, } - total_length = len(findings_data) - for i in range(0, total_length, BATCH_SIZE): - parameters["findings_data"] = findings_data[i : i + BATCH_SIZE] + batch_num = 0 + total_records = 0 + for batch in findings_batches: + batch_num += 1 + batch_size = len(batch) + total_records += batch_size - logger.info( - f"Loading findings batch {i // BATCH_SIZE + 1} / {(total_length + BATCH_SIZE - 1) // 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, diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py index d2cf275842..9db4d6bf6e 100644 --- a/api/src/backend/tasks/jobs/attack_paths/scan.py +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -117,17 +117,31 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: ) # 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}" diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index f900126db6..0dd3b0905b 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -1,25 +1,13 @@ import os - from datetime import datetime, timedelta, timezone from pathlib import Path from shutil import rmtree from celery import chain, group, shared_task from celery.utils.log import get_task_logger -from django_celery_beat.models import PeriodicTask - -from api.compliance import get_compliance_frameworks -from api.db_router import READ_REPLICA_ALIAS -from api.db_utils import rls_transaction -from api.decorators import handle_provider_deletion, set_tenant -from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices -from api.utils import initialize_prowler_provider -from api.v1.serializers import ScanTaskSerializer from config.celery import RLSTask from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.generic.generic import GenericCompliance -from prowler.lib.outputs.finding import Finding as FindingOutput +from django_celery_beat.models import PeriodicTask from tasks.jobs.attack_paths import ( attack_paths_scan, can_provider_run_attack_paths_scan, @@ -64,7 +52,22 @@ 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 +from api.db_utils import rls_transaction +from api.decorators import handle_provider_deletion, set_tenant +from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices +from api.utils import initialize_prowler_provider +from api.v1.serializers import ScanTaskSerializer +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.generic.generic import GenericCompliance +from prowler.lib.outputs.finding import Finding as FindingOutput logger = get_task_logger(__name__) @@ -275,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( @@ -321,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() @@ -343,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) @@ -368,8 +359,12 @@ def perform_scan_summary_task(tenant_id: str, scan_id: str): return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id) -# TODO: This task must be queued at the `attack-paths` queue, don't forget to add it to the `docker-entrypoint.sh` file -@shared_task(base=RLSTask, bind=True, name="attack-paths-scan-perform", queue="scans") +@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. diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index c94e8c83b6..0309e7162a 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -3,6 +3,8 @@ 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, @@ -15,8 +17,6 @@ from api.models import ( StatusChoices, ) from prowler.lib.check.models import Severity -from tasks.jobs.attack_paths import prowler as prowler_module -from tasks.jobs.attack_paths.scan import run as attack_paths_run @pytest.mark.django_db @@ -68,6 +68,7 @@ class TestAttackPathsRun: "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, @@ -276,15 +277,15 @@ class TestAttackPathsProwlerHelpers: provider.provider = Provider.ProviderChoices.AWS provider.save() - findings = [ - {"id": "1", "resource_uid": "r-1"}, - {"id": "2", "resource_uid": "r-2"}, - ] + # 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.object(prowler_module, "BATCH_SIZE", 1), patch( "tasks.jobs.attack_paths.prowler.get_root_node_label", return_value="AWSAccount", @@ -294,7 +295,9 @@ class TestAttackPathsProwlerHelpers: return_value="arn", ), ): - prowler_module.load_findings(mock_session, findings, provider, config) + 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: @@ -402,11 +405,18 @@ class TestAttackPathsProwlerHelpers: with patch( "tasks.jobs.attack_paths.prowler.rls_transaction", new=lambda *args, **kwargs: nullcontext(), + ), patch( + "tasks.jobs.attack_paths.prowler.READ_REPLICA_ALIAS", + "default", ): - findings_data = prowler_module.get_provider_last_scan_findings( + # 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] @@ -414,3 +424,285 @@ class TestAttackPathsProwlerHelpers: 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_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_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 3d44cd95bb..3a58118c62 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -1,21 +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 api.models import ( - Integration, - LighthouseProviderConfiguration, - LighthouseProviderModels, - Scan, - StateChoices, -) +from django_celery_results.models import TaskResult from tasks.jobs.lighthouse_providers import ( _create_bedrock_client, _extract_bedrock_credentials, @@ -27,11 +19,21 @@ from tasks.tasks import ( 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, ) +from api.models import ( + Integration, + LighthouseProviderConfiguration, + LighthouseProviderModels, + Scan, + StateChoices, + Task, +) + @pytest.mark.django_db class TestExtractBedrockCredentials: @@ -2137,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/docker-compose-dev.yml b/docker-compose-dev.yml index 90ab1b6390..b5de1af83f 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -98,7 +98,7 @@ services: # Auth - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD} # Memory limits - - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000000} + - 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} diff --git a/docker-compose.yml b/docker-compose.yml index 0cf60c66d3..992a753ac0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,7 @@ services: # Auth - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD} # Memory limits - - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000000} + - 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} 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/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/mcp_server/AGENTS.md b/mcp_server/AGENTS.md index 24621c2755..c8f77bd4b1 100644 --- a/mcp_server/AGENTS.md +++ b/mcp_server/AGENTS.md @@ -9,7 +9,9 @@ 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` | diff --git a/poetry.lock b/poetry.lock index 1da996720d..d4a7a1db18 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "about-time" @@ -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"}, @@ -1546,8 +1546,8 @@ files = [ 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] @@ -2378,20 +2378,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 +2715,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 +2755,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 +2792,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 +2973,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 +3948,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 +4536,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 +4585,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 +4629,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"}, @@ -5675,34 +5692,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)"] @@ -5711,20 +5729,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" @@ -5828,7 +5846,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"}, @@ -5894,6 +5912,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" @@ -5971,6 +6005,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" @@ -6558,4 +6614,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.9.1,<3.13" -content-hash = "3a52abdf28e0895fb05281d4febc871302cb1a9c121acec1139b1135d11756d4" +content-hash = "adfc2da2c6e3e803f7a151b9697dbc3f461366a03e4504eb97498cbc72b2e48c" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 6ae207697a..3fef12fc90 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,11 +2,34 @@ 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) + +### 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) + +--- + +## [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,16 +39,19 @@ 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) -- `TLS/SSL`, `records` and `email` checks for `zone` service [(#9424)](https://github.com/prowler-cloud/prowler/pull/9424) - `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) ### 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) @@ -54,20 +80,25 @@ All notable changes to the **Prowler SDK** are documented in this file. - 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) - Update AWS EC2 service metadata to new format [(#9549)](https://github.com/prowler-cloud/prowler/pull/9549) - ---- - -## [5.16.2] (Prowler v5.16.2) (UNRELEASED) +- 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) --- @@ -75,10 +106,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) @@ -91,6 +124,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) @@ -101,6 +135,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) --- @@ -108,6 +143,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) @@ -121,6 +157,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) @@ -130,6 +167,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) @@ -139,6 +177,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) @@ -147,6 +186,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) @@ -155,6 +195,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) @@ -180,6 +221,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) @@ -213,9 +255,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) @@ -233,6 +276,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) @@ -241,14 +285,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) @@ -291,6 +331,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) @@ -307,6 +348,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) @@ -316,6 +358,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) @@ -339,9 +382,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) @@ -352,6 +397,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) @@ -366,10 +412,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) @@ -383,6 +431,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) @@ -397,6 +446,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) --- @@ -404,6 +454,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) @@ -416,11 +467,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) @@ -444,6 +497,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) --- @@ -451,6 +505,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) @@ -463,9 +518,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) @@ -485,6 +542,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) @@ -537,6 +595,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) @@ -547,9 +606,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) --- @@ -557,6 +618,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) @@ -575,6 +637,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) @@ -583,6 +646,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) @@ -595,6 +659,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) @@ -613,6 +678,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) @@ -623,6 +689,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) @@ -672,6 +739,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) @@ -692,6 +760,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/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/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/__init__.py b/prowler/providers/aws/services/codebuild/codebuild_project_webhook_filters_use_anchored_patterns/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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/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/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/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/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 a1e576f9f9..0965142766 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -80,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) @@ -88,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( @@ -191,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" + ), ) ) @@ -690,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): @@ -729,6 +742,7 @@ class Firewall(BaseModel): class Project(BaseModel): id: str enable_oslogin: bool + enable_oslogin_2fa: bool = False class LoadBalancer(BaseModel): 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/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 f6de85dfca..01c19efcf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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-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-test-api/SKILL.md b/skills/prowler-test-api/SKILL.md index f0cf7bade8..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,115 +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 | --- -## 7. Fake Secrets in Tests (TruffleHog) - -CI runs TruffleHog to detect leaked secrets. Use obviously fake values: +## 2. JSON:API Requests +### POST (Create) ```python -# BAD - TruffleHog will flag these patterns: -api_key = "sk-test1234567890T3BlbkFJtest1234567890" # OpenAI pattern -api_key = "AKIA..." # AWS pattern - -# GOOD - clearly fake values: -api_key = "sk-fake-test-key-for-unit-testing-only" -api_key = "fake-aws-key-for-testing" +response = client.post( + reverse("provider-list"), + data={"data": {"type": "providers", "attributes": {...}}}, + format="vnd.api+json", # NOT content_type! +) ``` -**Patterns to avoid:** -- `sk-*T3BlbkFJ*` (OpenAI) -- `AKIA[A-Z0-9]{16}` (AWS Access Key) -- `ghp_*` or `gho_*` (GitHub tokens) +### 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 | --- @@ -134,11 +155,13 @@ api_key = "fake-aws-key-for-testing" ```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/skill-sync/assets/sync.sh b/skills/skill-sync/assets/sync.sh index 15b53a5d7c..941f7bae56 100755 --- a/skills/skill-sync/assets/sync.sh +++ b/skills/skill-sync/assets/sync.sh @@ -137,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/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/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/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index 01e9f033c0..99eb88d25b 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -771,6 +771,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): "automaticRestart": False, "preemptible": False, "provisioningModel": "STANDARD", + "onHostMaintenance": "MIGRATE", }, }, { @@ -802,6 +803,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): "automaticRestart": False, "preemptible": False, "provisioningModel": "STANDARD", + "onHostMaintenance": "TERMINATE", }, }, ] 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/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/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/AGENTS.md b/ui/AGENTS.md index 94c3fab23e..edb3f53447 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -21,8 +21,10 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | 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` | diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index a257bb3b94..68586b4cd4 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,42 +2,46 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.17.0] (Prowler UNRELEASED) +## [1.18.0] (Prowler v5.18.0) + +### πŸ”„ Changed + +- Launch Scan page now displays all providers without pagination limit [(#9700)](https://github.com/prowler-cloud/prowler/pull/9700) + +--- + +## [1.17.0] (Prowler v5.17.0) ### πŸš€ Added -- Resource group label filter to Resources page [(#9820)](https://github.com/prowler-cloud/prowler/pull/9820) -- Add search bar when adding a provider [(#9634)](https://github.com/prowler-cloud/prowler/pull/9634) +- 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) -- Add ThreatScore pillar breakdown to Compliance Summary page and detail view [(#9773)](https://github.com/prowler-cloud/prowler/pull/9773) -- Add Provider and Group filters to Resources page [(#9492)](https://github.com/prowler-cloud/prowler/pull/9492) +- 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) +- 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) -- Add showCenterLabel prop to RadialChart for optional center text display [(#9773)](https://github.com/prowler-cloud/prowler/pull/9773) - -### πŸ” Security - -- Upgrade 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) - ---- - -## [1.16.2] (Prowler v5.16.2) (UNRELEASED) ### 🐞 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) 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)/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 index 67ed4c42c7..10842492e8 100644 --- 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 @@ -3,6 +3,7 @@ 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, @@ -20,7 +21,8 @@ import { getNodeColor, getPathEdges, GRAPH_ALERT_BORDER_COLOR, - GRAPH_EDGE_COLOR, + GRAPH_EDGE_COLOR_DARK, + GRAPH_EDGE_COLOR_LIGHT, GRAPH_EDGE_HIGHLIGHT_COLOR, } from "../../_lib"; @@ -62,6 +64,7 @@ const AttackPathGraphComponent = forwardRef< >(({ data, onNodeClick, selectedNodeId, isFilteredView = false }, ref) => { const svgRef = useRef(null); const [zoomLevel, setZoomLevel] = useState(1); + const { resolvedTheme } = useTheme(); const zoomBehaviorRef = useRef | null>( null, ); @@ -88,6 +91,10 @@ const AttackPathGraphComponent = forwardRef< }> >([]); + // 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; @@ -160,17 +167,14 @@ const AttackPathGraphComponent = forwardRef< const edgeId = `${edgeData.sourceId}-${edgeData.targetId}`; const isInPath = pathEdges.has(edgeId); select(this) - .attr( - "stroke", - isInPath ? GRAPH_EDGE_HIGHLIGHT_COLOR : GRAPH_EDGE_COLOR, - ) + .attr("stroke", isInPath ? GRAPH_EDGE_HIGHLIGHT_COLOR : edgeColor) .attr( "marker-end", isInPath ? "url(#arrowhead-highlight)" : "url(#arrowhead)", ); }); } - }, [selectedNodeId]); + }, [selectedNodeId, edgeColor]); useImperativeHandle(ref, () => ({ zoomIn: () => { @@ -406,7 +410,7 @@ const AttackPathGraphComponent = forwardRef< .attr("flood-color", GRAPH_EDGE_HIGHLIGHT_COLOR) .attr("flood-opacity", "0.8"); - // Arrow marker (default white) - refX=10 places the arrow tip exactly at the line endpoint + // Arrow marker (theme-aware) - refX=10 places the arrow tip exactly at the line endpoint defs .append("marker") .attr("id", "arrowhead") @@ -418,7 +422,7 @@ const AttackPathGraphComponent = forwardRef< .attr("orient", "auto") .append("path") .attr("d", "M 0 0 L 10 5 L 0 10 z") - .attr("fill", GRAPH_EDGE_COLOR); + .attr("fill", edgeColor); // Arrow marker (highlighted orange) for hover state defs @@ -541,7 +545,7 @@ const AttackPathGraphComponent = forwardRef< "y2", (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).y2, ) - .attr("stroke", GRAPH_EDGE_COLOR) + .attr("stroke", edgeColor) .attr("stroke-width", 3) .attr("stroke-linecap", "round") .attr("stroke-dasharray", (d) => { @@ -640,7 +644,7 @@ const AttackPathGraphComponent = forwardRef< .attr("marker-end", "url(#arrowhead-highlight)"); } else { select(this) - .attr("stroke", GRAPH_EDGE_COLOR) + .attr("stroke", edgeColor) .attr("marker-end", "url(#arrowhead)"); } }); @@ -1152,8 +1156,9 @@ const AttackPathGraphComponent = forwardRef< // 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]); + }, [data, isFilteredView, edgeColor]); return ( ( +const EdgeLine = ({ + dashed, + edgeColor, +}: { + dashed: boolean; + edgeColor: string; +}) => ( ( y1="10" x2="44" y2="10" - stroke={GRAPH_EDGE_COLOR} + stroke={edgeColor} strokeWidth={3} strokeLinecap="round" strokeDasharray={dashed ? "8,6" : undefined} /> {/* Arrow head */} - + ); @@ -393,8 +402,13 @@ interface GraphLegendProps { * 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"), @@ -458,7 +472,7 @@ export const GraphLegend = ({ data }: GraphLegendProps) => { role="img" aria-label="Solid line: Resource connection" > - + Resource Connection @@ -477,7 +491,7 @@ export const GraphLegend = ({ data }: GraphLegendProps) => { role="img" aria-label="Dashed line: Finding connection" > - + Finding Connection 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 index eb207535ad..b49b058850 100644 --- 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 @@ -51,7 +51,9 @@ export const GRAPH_NODE_BORDER_COLORS = { default: "#22d3ee", } as const; -export const GRAPH_EDGE_COLOR = "#ffffff"; // White (default) +// 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"; 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 index bd7581751f..abecf7a3c2 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts @@ -8,7 +8,8 @@ export { getNodeBorderColor, getNodeColor, GRAPH_ALERT_BORDER_COLOR, - GRAPH_EDGE_COLOR, + GRAPH_EDGE_COLOR_DARK, + GRAPH_EDGE_COLOR_LIGHT, GRAPH_EDGE_HIGHLIGHT_COLOR, GRAPH_NODE_BORDER_COLORS, GRAPH_NODE_COLORS, diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index ba7a78d146..c3f1489714 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,6 +1,6 @@ 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"; @@ -32,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 diff --git a/ui/components/ui/sidebar/menu-item.tsx b/ui/components/ui/sidebar/menu-item.tsx index 2c94527643..45337b6bf3 100644 --- a/ui/components/ui/sidebar/menu-item.tsx +++ b/ui/components/ui/sidebar/menu-item.tsx @@ -46,19 +46,12 @@ export const MenuItem = ({ variant={isActive ? "menu-active" : "menu-inactive"} className={cn( isOpen ? "w-full justify-start" : "w-14 justify-center", - highlight && - "relative overflow-hidden before:absolute before:inset-0 before:rounded-lg before:bg-gradient-to-r before:from-emerald-500/20 before:via-teal-400/20 before:to-emerald-300/20 before:opacity-70", )} asChild > -
- +
+ {isOpen && (