diff --git a/.env b/.env index 7f739e01e5..c5c1f34342 100644 --- a/.env +++ b/.env @@ -10,13 +10,23 @@ NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL} NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST=true UI_PORT=3000 -# Temp URL for feeds need to use actual -RSS_FEED_URL=https://prowler.com/blog/rss # openssl rand -base64 32 AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" # Google Tag Manager ID NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" +#### MCP Server #### +PROWLER_MCP_VERSION=stable +# For UI and MCP running on docker: +PROWLER_MCP_SERVER_URL=http://mcp-server:8000/mcp +# For UI running on host, MCP in docker: +# PROWLER_MCP_SERVER_URL=http://localhost:8000/mcp + +#### Code Review Configuration #### +# Enable Claude Code standards validation on pre-push hook +# Set to 'true' to validate changes against AGENTS.md standards via Claude Code +# Set to 'false' to skip validation +CODE_REVIEW_ENABLED=true #### Prowler API Configuration #### PROWLER_API_VERSION="stable" @@ -35,6 +45,28 @@ POSTGRES_DB=prowler_db # POSTGRES_REPLICA_USER=prowler # POSTGRES_REPLICA_PASSWORD=postgres # POSTGRES_REPLICA_DB=prowler_db +# POSTGRES_REPLICA_MAX_ATTEMPTS=3 +# POSTGRES_REPLICA_RETRY_BASE_DELAY=0.5 + +# Neo4j auth +NEO4J_HOST=neo4j +NEO4J_PORT=7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=neo4j_password +# Neo4j settings +NEO4J_DBMS_MAX__DATABASES=1000 +NEO4J_SERVER_MEMORY_PAGECACHE_SIZE=1G +NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE=1G +NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE=1G +NEO4J_POC_EXPORT_FILE_ENABLED=true +NEO4J_APOC_IMPORT_FILE_ENABLED=true +NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true +NEO4J_PLUGINS=["apoc"] +NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST=apoc.* +NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=apoc.* +NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687 +# Neo4j Prowler settings +ATTACK_PATHS_BATCH_SIZE=1000 # Celery-Prowler task settings TASK_RETRY_DELAY_SECONDS=0.1 @@ -103,9 +135,10 @@ DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute # Sentry settings SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local +NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.12.2 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.16.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" @@ -124,3 +157,12 @@ LANGSMITH_TRACING=false LANGSMITH_ENDPOINT="https://api.smith.langchain.com" LANGSMITH_API_KEY="" LANGCHAIN_PROJECT="" + +# RSS Feed Configuration +# Multiple feed sources can be configured as a JSON array (must be valid JSON, no trailing commas) +# Each source requires: id, name, type (github_releases|blog|custom), url, and enabled flag +# IMPORTANT: Must be a single line with valid JSON (no newlines, no trailing commas) +# Example with one source: +RSS_FEED_SOURCES='[{"id":"prowler-releases","name":"Prowler Releases","type":"github_releases","url":"https://github.com/prowler-cloud/prowler/releases.atom","enabled":true}]' +# Example with multiple sources (no trailing comma after last item): +# RSS_FEED_SOURCES='[{"id":"prowler-releases","name":"Prowler Releases","type":"github_releases","url":"https://github.com/prowler-cloud/prowler/releases.atom","enabled":true},{"id":"prowler-blog","name":"Prowler Blog","type":"blog","url":"https://prowler.com/blog/rss","enabled":false}]' diff --git a/.github/actions/setup-python-poetry/action.yml b/.github/actions/setup-python-poetry/action.yml index 790f3ef0e6..cb17c050a2 100644 --- a/.github/actions/setup-python-poetry/action.yml +++ b/.github/actions/setup-python-poetry/action.yml @@ -29,7 +29,7 @@ runs: run: | BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" echo "Using branch: $BRANCH_NAME" - sed -i "s|@master|@$BRANCH_NAME|g" pyproject.toml + sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml - name: Install poetry shell: bash diff --git a/.github/actions/slack-notification/README.md b/.github/actions/slack-notification/README.md new file mode 100644 index 0000000000..ce8843df2a --- /dev/null +++ b/.github/actions/slack-notification/README.md @@ -0,0 +1,198 @@ +# Slack Notification Action + +A generic and flexible GitHub composite action for sending Slack notifications using JSON template files. Supports both standalone messages and message updates, with automatic status detection. + +## Features + +- **Template-based**: All messages use JSON template files for consistency +- **Automatic status detection**: Pass `step-outcome` to auto-calculate success/failure +- **Message updates**: Supports updating existing messages (using `chat.update`) +- **Simple API**: Clean and minimal interface +- **Reusable**: Use across all workflows and scenarios +- **Maintainable**: Centralized message templates + +## Use Cases + +1. **Container releases**: Track push start and completion with automatic status +2. **Deployments**: Track deployment progress with rich Block Kit formatting +3. **Custom notifications**: Any scenario where you need to notify Slack + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `slack-bot-token` | Slack bot token for authentication | Yes | - | +| `payload-file-path` | Path to JSON file with the Slack message payload | Yes | - | +| `update-ts` | Message timestamp to update (leave empty for new messages) | No | `''` | +| `step-outcome` | Step outcome for automatic status detection (sets STATUS_EMOJI and STATUS_TEXT env vars) | No | `''` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `ts` | Timestamp of the Slack message (use for updates) | + +## Usage Examples + +### Example 1: Container Release with Automatic Status Detection + +Using JSON template files with automatic status detection: + +```yaml +# Send start notification +- name: Notify container push started + if: github.event_name == 'release' + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + COMPONENT: API + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-started.json" + +# Do the work +- name: Build and push container + if: github.event_name == 'release' + id: container-push + uses: docker/build-push-action@... + with: + push: true + tags: ... + +# Send completion notification with automatic status detection +- name: Notify container push completed + if: github.event_name == 'release' && always() + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + COMPONENT: API + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" + step-outcome: ${{ steps.container-push.outcome }} +``` + +**Benefits:** +- No status calculation needed in workflow +- Reusable template files +- Clean and concise +- Automatic `STATUS_EMOJI` and `STATUS_TEXT` env vars set by action +- Consistent message format across all workflows + +### Example 2: Deployment with Message Update Pattern + +```yaml +# Send initial deployment message +- name: Notify deployment started + id: slack-start + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + COMPONENT: API + ENVIRONMENT: PRODUCTION + COMMIT_HASH: ${{ github.sha }} + VERSION_DEPLOYED: latest + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_WORKFLOW: ${{ github.workflow }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/deployment-started.json" + +# Run deployment +- name: Deploy + id: deploy + run: terraform apply -auto-approve + +# Determine additional status variables +- name: Determine deployment status + if: always() + id: deploy-status + run: | + if [[ "${{ steps.deploy.outcome }}" == "success" ]]; then + echo "STATUS_COLOR=28a745" >> $GITHUB_ENV + echo "STATUS=Completed" >> $GITHUB_ENV + else + echo "STATUS_COLOR=fc3434" >> $GITHUB_ENV + echo "STATUS=Failed" >> $GITHUB_ENV + fi + +# Update the same message with final status +- name: Update deployment notification + if: always() + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + MESSAGE_TS: ${{ steps.slack-start.outputs.ts }} + COMPONENT: API + ENVIRONMENT: PRODUCTION + COMMIT_HASH: ${{ github.sha }} + VERSION_DEPLOYED: latest + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_WORKFLOW: ${{ github.workflow }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + STATUS: ${{ env.STATUS }} + STATUS_COLOR: ${{ env.STATUS_COLOR }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + update-ts: ${{ steps.slack-start.outputs.ts }} + payload-file-path: "./.github/scripts/slack-messages/deployment-completed.json" + step-outcome: ${{ steps.deploy.outcome }} +``` + +## Automatic Status Detection + +When you provide `step-outcome` input, the action automatically sets these environment variables: + +| Outcome | STATUS_EMOJI | STATUS_TEXT | +|---------|--------------|-------------| +| success | `[✓]` | `completed successfully!` | +| failure | `[✗]` | `failed` | + +These variables are then available in your payload template files. + +## Template File Format + +All template files must be valid JSON and support environment variable substitution. Example: + +```json +{ + "channel": "$SLACK_CHANNEL_ID", + "text": "$STATUS_EMOJI $COMPONENT container release $RELEASE_TAG push $STATUS_TEXT <$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID|View run>" +} +``` + +See available templates in [`.github/scripts/slack-messages/`](../../scripts/slack-messages/). + +## Requirements + +- Slack Bot Token with scopes: `chat:write`, `chat:write.public` +- Slack Channel ID where messages will be posted +- JSON template files for your messages + +## Benefits + +- **Consistency**: All notifications use standardized templates +- **Automatic status handling**: No need to calculate success/failure in workflows +- **Clean workflows**: Minimal boilerplate code +- **Reusable templates**: One template for all components +- **Easy to maintain**: Change template once, applies everywhere +- **Version controlled**: All message formats in git + +## Related Resources + +- [Slack Block Kit Builder](https://app.slack.com/block-kit-builder) +- [Slack API Method Documentation](https://docs.slack.dev/tools/slack-github-action/sending-techniques/sending-data-slack-api-method/) +- [Message templates documentation](../../scripts/slack-messages/README.md) diff --git a/.github/actions/slack-notification/action.yml b/.github/actions/slack-notification/action.yml new file mode 100644 index 0000000000..1427fed3e0 --- /dev/null +++ b/.github/actions/slack-notification/action.yml @@ -0,0 +1,74 @@ +name: 'Slack Notification' +description: 'Generic action to send Slack notifications with optional message updates and automatic status detection' +inputs: + slack-bot-token: + description: 'Slack bot token for authentication' + required: true + payload-file-path: + description: 'Path to JSON file with the Slack message payload' + required: true + update-ts: + description: 'Message timestamp to update (only for updates, leave empty for new messages)' + required: false + default: '' + step-outcome: + description: 'Outcome of a step to determine status (success/failure) - automatically sets STATUS_TEXT and STATUS_COLOR env vars' + required: false + default: '' +outputs: + ts: + description: 'Timestamp of the Slack message' + value: ${{ steps.slack-notification.outputs.ts }} +runs: + using: 'composite' + steps: + - name: Determine status + id: status + shell: bash + run: | + if [[ "${{ inputs.step-outcome }}" == "success" ]]; then + echo "STATUS_TEXT=Completed" >> $GITHUB_ENV + echo "STATUS_COLOR=#6aa84f" >> $GITHUB_ENV + elif [[ "${{ inputs.step-outcome }}" == "failure" ]]; then + echo "STATUS_TEXT=Failed" >> $GITHUB_ENV + echo "STATUS_COLOR=#fc3434" >> $GITHUB_ENV + else + # No outcome provided - pending/in progress state + echo "STATUS_COLOR=#dbab09" >> $GITHUB_ENV + fi + + - name: Send Slack notification (new message) + if: inputs.update-ts == '' + id: slack-notification-post + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + env: + SLACK_PAYLOAD_FILE_PATH: ${{ inputs.payload-file-path }} + with: + method: chat.postMessage + token: ${{ inputs.slack-bot-token }} + payload-file-path: ${{ inputs.payload-file-path }} + payload-templated: true + errors: true + + - name: Update Slack notification + if: inputs.update-ts != '' + id: slack-notification-update + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + env: + SLACK_PAYLOAD_FILE_PATH: ${{ inputs.payload-file-path }} + with: + method: chat.update + token: ${{ inputs.slack-bot-token }} + payload-file-path: ${{ inputs.payload-file-path }} + payload-templated: true + errors: true + + - name: Set output + id: slack-notification + shell: bash + run: | + if [[ "${{ inputs.update-ts }}" == "" ]]; then + echo "ts=${{ steps.slack-notification-post.outputs.ts }}" >> $GITHUB_OUTPUT + else + echo "ts=${{ inputs.update-ts }}" >> $GITHUB_OUTPUT + fi diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index e3c2404748..5eca1266b0 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -87,7 +87,7 @@ runs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: - name: trivy-scan-report-${{ inputs.image-name }} + name: trivy-scan-report-${{ inputs.image-name }}-${{ inputs.image-tag }} path: trivy-report.json retention-days: ${{ inputs.artifact-retention-days }} diff --git a/.github/labeler.yml b/.github/labeler.yml index 9a56691628..a26abc983e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -47,6 +47,21 @@ provider/oci: - any-glob-to-any-file: "prowler/providers/oraclecloud/**" - any-glob-to-any-file: "tests/providers/oraclecloud/**" +provider/alibabacloud: + - changed-files: + - any-glob-to-any-file: "prowler/providers/alibabacloud/**" + - any-glob-to-any-file: "tests/providers/alibabacloud/**" + +provider/cloudflare: + - changed-files: + - any-glob-to-any-file: "prowler/providers/cloudflare/**" + - any-glob-to-any-file: "tests/providers/cloudflare/**" + +provider/openstack: + - changed-files: + - any-glob-to-any-file: "prowler/providers/openstack/**" + - any-glob-to-any-file: "tests/providers/openstack/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" @@ -62,13 +77,23 @@ mutelist: - any-glob-to-any-file: "prowler/providers/azure/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/gcp/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/kubernetes/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/m365/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/mongodbatlas/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/oraclecloud/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/openstack/lib/mutelist/**" - any-glob-to-any-file: "tests/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/gcp/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/kubernetes/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/m365/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/mongodbatlas/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/oraclecloud/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**" integration/s3: - changed-files: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 94122d8349..28365d4acb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,15 +14,39 @@ Please add a detailed description of how to review this PR. ### Checklist -- Are there new checks included in this PR? Yes / No - - If so, do we need to update permissions for the provider? Please review this carefully. +
+ +Community Checklist + +- [ ] This feature/issue is listed in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com +- [ ] Is it assigned to me, if not, request it via the issue/feature in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](goto.prowler.com/slack) + +
+ + - [ ] Review if the code is being covered by tests. - [ ] Review if code is being documented following this specification https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings - [ ] Review if backport is needed. - [ ] Review if is needed to change the [Readme.md](https://github.com/prowler-cloud/prowler/blob/master/README.md) - [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/prowler/CHANGELOG.md), if applicable. +#### SDK/CLI +- Are there new checks included in this PR? Yes / No + - If so, do we need to update permissions for the provider? Please review this carefully. + +#### UI +- [ ] All issue/task requirements work as expected on the UI +- [ ] Screenshots/Video of the functionality flow (if applicable) - Mobile (X < 640px) +- [ ] Screenshots/Video of the functionality flow (if applicable) - Table (640px > X < 1024px) +- [ ] Screenshots/Video of the functionality flow (if applicable) - Desktop (X > 1024px) +- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/ui/CHANGELOG.md), if applicable. + #### API +- [ ] All issue/task requirements work as expected on the API +- [ ] Endpoint response output (if applicable) +- [ ] EXPLAIN ANALYZE output for new/modified queries or indexes (if applicable) +- [ ] Performance test results (if applicable) +- [ ] Any other relevant evidence of the implementation (if applicable) - [ ] Verify if API specs need to be regenerated. - [ ] Check if version updates are required (e.g., specs, Poetry, etc.). - [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/api/CHANGELOG.md), if applicable. diff --git a/.github/scripts/slack-messages/README.md b/.github/scripts/slack-messages/README.md new file mode 100644 index 0000000000..e7fcf00fdd --- /dev/null +++ b/.github/scripts/slack-messages/README.md @@ -0,0 +1,462 @@ +# Slack Message Templates + +This directory contains reusable message templates for Slack notifications sent from GitHub Actions workflows. + +## Usage + +These JSON templates are used with the `slackapi/slack-github-action` using the Slack API method (`chat.postMessage` and `chat.update`). All templates support rich Block Kit formatting and message updates. + +### Available Templates + +**Container Releases** +- `container-release-started.json`: Simple one-line notification when container push starts +- `container-release-completed.json`: Simple one-line notification when container release completes + +**Deployments** +- `deployment-started.json`: Deployment start notification with Block Kit formatting +- `deployment-completed.json`: Deployment completion notification (updates the start message) + +All templates use the Slack API method and require a Slack Bot Token. + +## Setup Requirements + +1. Create a Slack App (or use existing) +2. Add Bot Token Scopes: `chat:write`, `chat:write.public` +3. Install the app to your workspace +4. Get the Bot Token from OAuth & Permissions page +5. Add secrets: + - `SLACK_BOT_TOKEN`: Your bot token + - `SLACK_CHANNEL_ID`: The channel ID where messages will be posted + +Reference: [Sending data using a Slack API method](https://docs.slack.dev/tools/slack-github-action/sending-techniques/sending-data-slack-api-method/) + +## Environment Variables + +### Required Secrets (GitHub Secrets) +- `SLACK_BOT_TOKEN`: Passed as `token` parameter to the action (not as env variable) +- `SLACK_CHANNEL_ID`: Used in payload as env variable + +### Container Release Variables (configured as env) +- `COMPONENT`: Component name (e.g., "API", "SDK", "UI", "MCP") +- `RELEASE_TAG` / `PROWLER_VERSION`: The release tag or version being deployed +- `GITHUB_SERVER_URL`: Provided by GitHub context +- `GITHUB_REPOSITORY`: Provided by GitHub context +- `GITHUB_RUN_ID`: Provided by GitHub context +- `STATUS_EMOJI`: Status symbol (calculated: `[✓]` for success, `[✗]` for failure) +- `STATUS_TEXT`: Status text (calculated: "completed successfully!" or "failed") + +### Deployment Variables (configured as env) +- `COMPONENT`: Component name (e.g., "API", "SDK", "UI", "MCP") +- `ENVIRONMENT`: Environment name (e.g., "DEVELOPMENT", "PRODUCTION") +- `COMMIT_HASH`: Commit hash being deployed +- `VERSION_DEPLOYED`: Version being deployed +- `GITHUB_ACTOR`: User who triggered the workflow +- `GITHUB_WORKFLOW`: Workflow name +- `GITHUB_SERVER_URL`: Provided by GitHub context +- `GITHUB_REPOSITORY`: Provided by GitHub context +- `GITHUB_RUN_ID`: Provided by GitHub context + +All other variables (MESSAGE_TS, STATUS, STATUS_COLOR, STATUS_EMOJI, etc.) are calculated internally within the workflow and should NOT be configured as environment variables. + +## Example Workflow Usage + +### Using the Generic Slack Notification Action (Recommended) + +**Recommended approach**: Use the generic reusable action `.github/actions/slack-notification` which provides maximum flexibility: + +#### Example 1: Container Release (Start + Completion) + +```yaml +# Send start notification +- name: Notify container push started + if: github.event_name == 'release' + uses: ./.github/actions/slack-notification + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + { + "channel": "${{ secrets.SLACK_CHANNEL_ID }}", + "text": "API container release ${{ env.RELEASE_TAG }} push started... <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" + } + +# Build and push container +- name: Build and push container + if: github.event_name == 'release' + id: container-push + uses: docker/build-push-action@... + with: + push: true + tags: ... + +# Calculate status +- name: Determine push status + if: github.event_name == 'release' && always() + id: push-status + run: | + if [[ "${{ steps.container-push.outcome }}" == "success" ]]; then + echo "emoji=[✓]" >> $GITHUB_OUTPUT + echo "text=completed successfully!" >> $GITHUB_OUTPUT + else + echo "emoji=[✗]" >> $GITHUB_OUTPUT + echo "text=failed" >> $GITHUB_OUTPUT + fi + +# Send completion notification +- name: Notify container push completed + if: github.event_name == 'release' && always() + uses: ./.github/actions/slack-notification + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + { + "channel": "${{ secrets.SLACK_CHANNEL_ID }}", + "text": "${{ steps.push-status.outputs.emoji }} API container release ${{ env.RELEASE_TAG }} push ${{ steps.push-status.outputs.text }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" + } +``` + +#### Example 2: Simple One-Time Message + +```yaml +- name: Send notification + uses: ./.github/actions/slack-notification + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + { + "channel": "${{ secrets.SLACK_CHANNEL_ID }}", + "text": "Deployment completed successfully!" + } +``` + +#### Example 3: Deployment with Message Update Pattern + +```yaml +# Send initial deployment message +- name: Notify deployment started + id: slack-start + uses: ./.github/actions/slack-notification + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + { + "channel": "${{ secrets.SLACK_CHANNEL_ID }}", + "text": "API deployment to PRODUCTION started", + "attachments": [ + { + "color": "dbab09", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "API | Deployment to PRODUCTION" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status:*\nIn Progress" + } + ] + } + ] + } + ] + } + +# Run deployment +- name: Deploy + id: deploy + run: terraform apply -auto-approve + +# Calculate status +- name: Determine status + if: always() + id: status + run: | + if [[ "${{ steps.deploy.outcome }}" == "success" ]]; then + echo "color=28a745" >> $GITHUB_OUTPUT + echo "emoji=[✓]" >> $GITHUB_OUTPUT + echo "status=Completed" >> $GITHUB_OUTPUT + else + echo "color=fc3434" >> $GITHUB_OUTPUT + echo "emoji=[✗]" >> $GITHUB_OUTPUT + echo "status=Failed" >> $GITHUB_OUTPUT + fi + +# Update the same message with final status +- name: Update deployment notification + if: always() + uses: ./.github/actions/slack-notification + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + update-ts: ${{ steps.slack-start.outputs.ts }} + payload: | + { + "channel": "${{ secrets.SLACK_CHANNEL_ID }}", + "ts": "${{ steps.slack-start.outputs.ts }}", + "text": "${{ steps.status.outputs.emoji }} API deployment to PRODUCTION ${{ steps.status.outputs.status }}", + "attachments": [ + { + "color": "${{ steps.status.outputs.color }}", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "API | Deployment to PRODUCTION" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status:*\n${{ steps.status.outputs.emoji }} ${{ steps.status.outputs.status }}" + } + ] + } + ] + } + ] + } +``` + +**Benefits of using the generic action:** +- Maximum flexibility: Build any payload you need directly in the workflow +- No template files needed: Everything inline +- Supports all scenarios: one-time messages, start/update patterns, rich Block Kit +- Easy to customize per use case +- Generic: Works for containers, deployments, or any notification type + +For more details, see [Slack Notification Action](../../actions/slack-notification/README.md). + +### Using Message Templates (Alternative Approach) + +Simple one-line notifications for container releases: + +```yaml +# Step 1: Notify when push starts +- name: Notify container push started + if: github.event_name == 'release' + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + COMPONENT: API + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-started.json" + +# Step 2: Build and push container +- name: Build and push container + id: container-push + uses: docker/build-push-action@... + with: + push: true + tags: ... + +# Step 3: Determine push status +- name: Determine push status + if: github.event_name == 'release' && always() + id: push-status + run: | + if [[ "${{ steps.container-push.outcome }}" == "success" ]]; then + echo "status-emoji=[✓]" >> $GITHUB_OUTPUT + echo "status-text=completed successfully!" >> $GITHUB_OUTPUT + else + echo "status-emoji=[✗]" >> $GITHUB_OUTPUT + echo "status-text=failed" >> $GITHUB_OUTPUT + fi + +# Step 4: Notify when push completes (success or failure) +- name: Notify container push completed + if: github.event_name == 'release' && always() + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + COMPONENT: API + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + STATUS_EMOJI: ${{ steps.push-status.outputs.status-emoji }} + STATUS_TEXT: ${{ steps.push-status.outputs.status-text }} + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" +``` + +### Deployment with Update Pattern + +For deployments that start with one message and update it with the final status: + +```yaml +# Step 1: Send deployment start notification +- name: Notify Deployment Start + id: slack-notification-start + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + COMPONENT: API + ENVIRONMENT: PRODUCTION + COMMIT_HASH: ${{ github.sha }} + VERSION_DEPLOYED: latest + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_WORKFLOW: ${{ github.workflow }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/deployment-started.json" + +# Step 2: Run your deployment steps +- name: Terraform Plan + id: terraform-plan + run: terraform plan + +- name: Terraform Apply + id: terraform-apply + run: terraform apply -auto-approve + +# Step 3: Determine status (calculated internally, not configured) +- name: Determine Status + if: always() + id: determine-status + run: | + if [[ "${{ steps.terraform-apply.outcome }}" == "success" ]]; then + echo "status=Completed" >> $GITHUB_OUTPUT + echo "status-color=28a745" >> $GITHUB_OUTPUT + echo "status-emoji=[✓]" >> $GITHUB_OUTPUT + echo "plan-emoji=[✓]" >> $GITHUB_OUTPUT + echo "apply-emoji=[✓]" >> $GITHUB_OUTPUT + elif [[ "${{ steps.terraform-plan.outcome }}" == "failure" || "${{ steps.terraform-apply.outcome }}" == "failure" ]]; then + echo "status=Failed" >> $GITHUB_OUTPUT + echo "status-color=fc3434" >> $GITHUB_OUTPUT + echo "status-emoji=[✗]" >> $GITHUB_OUTPUT + if [[ "${{ steps.terraform-plan.outcome }}" == "failure" ]]; then + echo "plan-emoji=[✗]" >> $GITHUB_OUTPUT + else + echo "plan-emoji=[✓]" >> $GITHUB_OUTPUT + fi + if [[ "${{ steps.terraform-apply.outcome }}" == "failure" ]]; then + echo "apply-emoji=[✗]" >> $GITHUB_OUTPUT + else + echo "apply-emoji=[✓]" >> $GITHUB_OUTPUT + fi + else + echo "status=Failed" >> $GITHUB_OUTPUT + echo "status-color=fc3434" >> $GITHUB_OUTPUT + echo "status-emoji=[✗]" >> $GITHUB_OUTPUT + echo "plan-emoji=[?]" >> $GITHUB_OUTPUT + echo "apply-emoji=[?]" >> $GITHUB_OUTPUT + fi + +# Step 4: Update the same Slack message (using calculated values) +- name: Notify Deployment Result + if: always() + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + MESSAGE_TS: ${{ steps.slack-notification-start.outputs.ts }} + COMPONENT: API + ENVIRONMENT: PRODUCTION + COMMIT_HASH: ${{ github.sha }} + VERSION_DEPLOYED: latest + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_WORKFLOW: ${{ github.workflow }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + STATUS: ${{ steps.determine-status.outputs.status }} + STATUS_COLOR: ${{ steps.determine-status.outputs.status-color }} + STATUS_EMOJI: ${{ steps.determine-status.outputs.status-emoji }} + PLAN_EMOJI: ${{ steps.determine-status.outputs.plan-emoji }} + APPLY_EMOJI: ${{ steps.determine-status.outputs.apply-emoji }} + TERRAFORM_PLAN_OUTCOME: ${{ steps.terraform-plan.outcome }} + TERRAFORM_APPLY_OUTCOME: ${{ steps.terraform-apply.outcome }} + with: + method: chat.update + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/deployment-completed.json" +``` + +**Note**: Variables like `STATUS`, `STATUS_COLOR`, `STATUS_EMOJI`, `PLAN_EMOJI`, `APPLY_EMOJI` are calculated by the `determine-status` step based on the outcomes of previous steps. They should NOT be manually configured. + +## Key Features + +### Benefits of Using Slack API Method + +- **Rich Block Kit Formatting**: Full support for Slack's Block Kit including headers, sections, fields, colors, and attachments +- **Message Updates**: Update the same message instead of posting multiple messages (using `chat.update` with `ts`) +- **Consistent Experience**: Same look and feel as Prowler Cloud notifications +- **Flexible**: Easy to customize message appearance by editing JSON templates + +### Differences from Webhook Method + +| Feature | webhook-trigger | Slack API (chat.postMessage) | +|---------|-----------------|------------------------------| +| Setup | Workflow Builder webhook | Slack Bot Token + Channel ID | +| Formatting | Plain text/simple | Full Block Kit support | +| Message Update | No | Yes (with chat.update) | +| Authentication | Webhook URL | Bot Token | +| Scopes Required | None | chat:write, chat:write.public | + +## Message Appearance + +### Container Release (Simple One-Line) + +**Start message:** +``` +API container release 4.5.0 push started... View run +``` + +**Completion message (success):** +``` +[✓] API container release 4.5.0 push completed successfully! View run +``` + +**Completion message (failure):** +``` +[✗] API container release 4.5.0 push failed View run +``` + +All messages are simple one-liners with a clickable "View run" link. The completion message adapts to show success `[✓]` or failure `[✗]` based on the outcome of the container push. + +### Deployment Start +- Header: Component and environment +- Yellow bar (color: `dbab09`) +- Status: In Progress +- Details: Commit, version, actor, workflow +- Link: Direct link to deployment run + +### Deployment Completion +- Header: Component and environment +- Green bar for success (color: `28a745`) / Red bar for failure (color: `fc3434`) +- Status: [✓] Completed or [✗] Failed +- Details: All deployment info plus terraform outcomes +- Link: Direct link to deployment run + +## Adding New Templates + +1. Create a new JSON file with Block Kit structure +2. Use environment variable placeholders (e.g., `$VAR_NAME`) +3. Include `channel` and `text` fields (required) +4. Add `blocks` or `attachments` for rich formatting +5. For update templates, include `ts` field as `$MESSAGE_TS` +6. Document the template in this README +7. Reference it in your workflow using `payload-file-path` + +## Reference + +- [Slack Block Kit Builder](https://app.slack.com/block-kit-builder) +- [Slack API Method Documentation](https://docs.slack.dev/tools/slack-github-action/sending-techniques/sending-data-slack-api-method/) diff --git a/.github/scripts/slack-messages/container-release-completed.json b/.github/scripts/slack-messages/container-release-completed.json new file mode 100644 index 0000000000..f2b9682022 --- /dev/null +++ b/.github/scripts/slack-messages/container-release-completed.json @@ -0,0 +1,18 @@ +{ + "channel": "${{ env.SLACK_CHANNEL_ID }}", + "ts": "${{ env.MESSAGE_TS }}", + "attachments": [ + { + "color": "${{ env.STATUS_COLOR }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Status:*\n${{ env.STATUS_TEXT }}\n\n${{ env.COMPONENT }} container release ${{ env.RELEASE_TAG }} push ${{ env.STATUS_TEXT }}\n\n<${{ env.GITHUB_SERVER_URL }}/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}|View run>" + } + } + ] + } + ] +} diff --git a/.github/scripts/slack-messages/container-release-started.json b/.github/scripts/slack-messages/container-release-started.json new file mode 100644 index 0000000000..f67129e3ba --- /dev/null +++ b/.github/scripts/slack-messages/container-release-started.json @@ -0,0 +1,17 @@ +{ + "channel": "${{ env.SLACK_CHANNEL_ID }}", + "attachments": [ + { + "color": "${{ env.STATUS_COLOR }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Status:*\nStarted\n\n${{ env.COMPONENT }} container release ${{ env.RELEASE_TAG }} push started...\n\n<${{ env.GITHUB_SERVER_URL }}/${{ env.GITHUB_REPOSITORY }}/actions/runs/${{ env.GITHUB_RUN_ID }}|View run>" + } + } + ] + } + ] +} diff --git a/.github/scripts/test-impact.py b/.github/scripts/test-impact.py new file mode 100755 index 0000000000..f97848f6b5 --- /dev/null +++ b/.github/scripts/test-impact.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +Test Impact Analysis Script + +Analyzes changed files and determines which tests need to run. +Outputs GitHub Actions compatible outputs. + +Usage: + python test-impact.py + python test-impact.py --from-stdin # Read files from stdin (one per line) + +Outputs (for GitHub Actions): + - run-all: "true" if critical paths changed + - sdk-tests: Space-separated list of SDK test paths + - api-tests: Space-separated list of API test paths + - ui-e2e: Space-separated list of UI E2E test paths + - modules: Comma-separated list of affected module names +""" + +import fnmatch +import os +import sys +from pathlib import Path + +import yaml + + +def load_config() -> dict: + """Load test-impact.yml configuration.""" + config_path = Path(__file__).parent.parent / "test-impact.yml" + with open(config_path) as f: + return yaml.safe_load(f) + + +def matches_pattern(file_path: str, pattern: str) -> bool: + """Check if file path matches a glob pattern.""" + # Normalize paths + file_path = file_path.strip("/") + pattern = pattern.strip("/") + + # Handle ** patterns + if "**" in pattern: + # Convert glob pattern to work with fnmatch + # e.g., "prowler/lib/**" matches "prowler/lib/check/foo.py" + base = pattern.replace("/**", "") + if file_path.startswith(base): + return True + # Also try standard fnmatch + return fnmatch.fnmatch(file_path, pattern) + + return fnmatch.fnmatch(file_path, pattern) + + +def filter_ignored_files( + changed_files: list[str], ignored_paths: list[str] +) -> list[str]: + """Filter out files that match ignored patterns.""" + filtered = [] + for file_path in changed_files: + is_ignored = False + for pattern in ignored_paths: + if matches_pattern(file_path, pattern): + print(f" [IGNORED] {file_path} matches {pattern}", file=sys.stderr) + is_ignored = True + break + if not is_ignored: + filtered.append(file_path) + return filtered + + +def check_critical_paths(changed_files: list[str], critical_paths: list[str]) -> bool: + """Check if any changed file matches critical paths.""" + for file_path in changed_files: + for pattern in critical_paths: + if matches_pattern(file_path, pattern): + print(f" [CRITICAL] {file_path} matches {pattern}", file=sys.stderr) + return True + return False + + +def find_affected_modules( + changed_files: list[str], modules: list[dict] +) -> dict[str, dict]: + """Find which modules are affected by changed files.""" + affected = {} + + for file_path in changed_files: + for module in modules: + module_name = module["name"] + match_patterns = module.get("match", []) + + for pattern in match_patterns: + if matches_pattern(file_path, pattern): + if module_name not in affected: + affected[module_name] = { + "tests": set(), + "e2e": set(), + "matched_files": [], + } + affected[module_name]["matched_files"].append(file_path) + + # Add test patterns + for test_pattern in module.get("tests", []): + affected[module_name]["tests"].add(test_pattern) + + # Add E2E patterns + for e2e_pattern in module.get("e2e", []): + affected[module_name]["e2e"].add(e2e_pattern) + + break # File matched this module, move to next file + + return affected + + +def categorize_tests( + affected_modules: dict[str, dict], +) -> tuple[set[str], set[str], set[str]]: + """Categorize tests into SDK, API, and UI E2E.""" + sdk_tests = set() + api_tests = set() + ui_e2e = set() + + for module_name, data in affected_modules.items(): + for test_path in data["tests"]: + if test_path.startswith("tests/"): + sdk_tests.add(test_path) + elif test_path.startswith("api/"): + api_tests.add(test_path) + + for e2e_path in data["e2e"]: + ui_e2e.add(e2e_path) + + return sdk_tests, api_tests, ui_e2e + + +def set_github_output(name: str, value: str): + """Set GitHub Actions output.""" + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as f: + # Handle multiline values + if "\n" in value: + import uuid + + delimiter = uuid.uuid4().hex + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + else: + f.write(f"{name}={value}\n") + # Print for debugging (without deprecated format) + print(f" {name}={value}", file=sys.stderr) + + +def main(): + # Parse arguments + if "--from-stdin" in sys.argv: + changed_files = [line.strip() for line in sys.stdin if line.strip()] + else: + changed_files = [f for f in sys.argv[1:] if f and not f.startswith("-")] + + if not changed_files: + print("No changed files provided", file=sys.stderr) + set_github_output("run-all", "false") + set_github_output("sdk-tests", "") + set_github_output("api-tests", "") + set_github_output("ui-e2e", "") + set_github_output("modules", "") + set_github_output("has-tests", "false") + return + + print(f"Analyzing {len(changed_files)} changed files...", file=sys.stderr) + for f in changed_files[:10]: # Show first 10 + print(f" - {f}", file=sys.stderr) + if len(changed_files) > 10: + print(f" ... and {len(changed_files) - 10} more", file=sys.stderr) + + # Load configuration + config = load_config() + + # Filter out ignored files (docs, configs, etc.) + ignored_paths = config.get("ignored", {}).get("paths", []) + changed_files = filter_ignored_files(changed_files, ignored_paths) + + if not changed_files: + print("\nAll changed files are ignored (docs, configs, etc.)", file=sys.stderr) + print("No tests needed.", file=sys.stderr) + set_github_output("run-all", "false") + set_github_output("sdk-tests", "") + set_github_output("api-tests", "") + set_github_output("ui-e2e", "") + set_github_output("modules", "none-ignored") + set_github_output("has-tests", "false") + return + + print( + f"\n{len(changed_files)} files remain after filtering ignored paths", + file=sys.stderr, + ) + + # Check critical paths + critical_paths = config.get("critical", {}).get("paths", []) + if check_critical_paths(changed_files, critical_paths): + print("\nCritical path changed - running ALL tests", file=sys.stderr) + set_github_output("run-all", "true") + set_github_output("sdk-tests", "tests/") + set_github_output("api-tests", "api/src/backend/") + set_github_output("ui-e2e", "ui/tests/") + set_github_output("modules", "all") + set_github_output("has-tests", "true") + return + + # Find affected modules + modules = config.get("modules", []) + affected = find_affected_modules(changed_files, modules) + + if not affected: + print("\nNo test-mapped modules affected", file=sys.stderr) + set_github_output("run-all", "false") + set_github_output("sdk-tests", "") + set_github_output("api-tests", "") + set_github_output("ui-e2e", "") + set_github_output("modules", "") + set_github_output("has-tests", "false") + return + + # Report affected modules + print(f"\nAffected modules: {len(affected)}", file=sys.stderr) + for module_name, data in affected.items(): + print(f" [{module_name}]", file=sys.stderr) + for f in data["matched_files"][:3]: + print(f" - {f}", file=sys.stderr) + if len(data["matched_files"]) > 3: + print( + f" ... and {len(data['matched_files']) - 3} more files", + file=sys.stderr, + ) + + # Categorize tests + sdk_tests, api_tests, ui_e2e = categorize_tests(affected) + + # Output results + print("\nTest paths to run:", file=sys.stderr) + print(f" SDK: {sdk_tests or 'none'}", file=sys.stderr) + print(f" API: {api_tests or 'none'}", file=sys.stderr) + print(f" E2E: {ui_e2e or 'none'}", file=sys.stderr) + + set_github_output("run-all", "false") + set_github_output("sdk-tests", " ".join(sorted(sdk_tests))) + set_github_output("api-tests", " ".join(sorted(api_tests))) + set_github_output("ui-e2e", " ".join(sorted(ui_e2e))) + set_github_output("modules", ",".join(sorted(affected.keys()))) + set_github_output( + "has-tests", "true" if (sdk_tests or api_tests or ui_e2e) else "false" + ) + + +if __name__ == "__main__": + main() diff --git a/.github/test-impact.yml b/.github/test-impact.yml new file mode 100644 index 0000000000..0626f33707 --- /dev/null +++ b/.github/test-impact.yml @@ -0,0 +1,402 @@ +# Test Impact Analysis Configuration +# Defines which tests to run based on changed files +# +# Usage: Changes to paths in 'critical' always run all tests. +# Changes to paths in 'modules' run only the mapped tests. +# Changes to paths in 'ignored' don't trigger any tests. + +# Ignored paths - changes here don't trigger any tests +# Documentation, configs, and other non-code files +ignored: + paths: + # Documentation + - docs/** + - "*.md" + - "**/*.md" + - mkdocs.yml + + # Config files that don't affect runtime + - .gitignore + - .gitattributes + - .editorconfig + - .pre-commit-config.yaml + - .backportrc.json + - CODEOWNERS + - LICENSE + + # IDE/Editor configs + - .vscode/** + - .idea/** + + # Examples and contrib (not production code) + - examples/** + - contrib/** + + # Skills (AI agent configs, not runtime) + - skills/** + + # E2E setup helpers (not runnable tests) + - ui/tests/setups/** + + # Permissions docs + - permissions/** + +# Critical paths - changes here run ALL tests +# These are foundational/shared code that can affect anything +critical: + paths: + # SDK Core + - prowler/lib/** + - prowler/config/** + - prowler/exceptions/** + - prowler/providers/common/** + + # API Core + - api/src/backend/api/models.py + - api/src/backend/config/** + - api/src/backend/conftest.py + + # UI Core + - ui/lib/** + - ui/types/** + - ui/config/** + - ui/middleware.ts + + # CI/CD changes + - .github/workflows/** + - .github/test-impact.yml + +# Module mappings - path patterns to test patterns +modules: + # ============================================ + # SDK - Providers (each provider is isolated) + # ============================================ + - name: sdk-aws + match: + - prowler/providers/aws/** + - prowler/compliance/aws/** + tests: + - tests/providers/aws/** + e2e: [] + + - name: sdk-azure + match: + - prowler/providers/azure/** + - prowler/compliance/azure/** + tests: + - tests/providers/azure/** + e2e: [] + + - name: sdk-gcp + match: + - prowler/providers/gcp/** + - prowler/compliance/gcp/** + tests: + - tests/providers/gcp/** + e2e: [] + + - name: sdk-kubernetes + match: + - prowler/providers/kubernetes/** + - prowler/compliance/kubernetes/** + tests: + - tests/providers/kubernetes/** + e2e: [] + + - name: sdk-github + match: + - prowler/providers/github/** + - prowler/compliance/github/** + tests: + - tests/providers/github/** + e2e: [] + + - name: sdk-m365 + match: + - prowler/providers/m365/** + - prowler/compliance/m365/** + tests: + - tests/providers/m365/** + e2e: [] + + - name: sdk-alibabacloud + match: + - prowler/providers/alibabacloud/** + - prowler/compliance/alibabacloud/** + tests: + - tests/providers/alibabacloud/** + e2e: [] + + - name: sdk-cloudflare + match: + - prowler/providers/cloudflare/** + - prowler/compliance/cloudflare/** + tests: + - tests/providers/cloudflare/** + e2e: [] + + - name: sdk-oraclecloud + match: + - prowler/providers/oraclecloud/** + - prowler/compliance/oraclecloud/** + tests: + - tests/providers/oraclecloud/** + e2e: [] + + - name: sdk-mongodbatlas + match: + - prowler/providers/mongodbatlas/** + - prowler/compliance/mongodbatlas/** + tests: + - tests/providers/mongodbatlas/** + e2e: [] + + - name: sdk-nhn + match: + - prowler/providers/nhn/** + - prowler/compliance/nhn/** + tests: + - tests/providers/nhn/** + e2e: [] + + - name: sdk-iac + match: + - prowler/providers/iac/** + - prowler/compliance/iac/** + tests: + - tests/providers/iac/** + e2e: [] + + - name: sdk-llm + match: + - prowler/providers/llm/** + - prowler/compliance/llm/** + tests: + - tests/providers/llm/** + e2e: [] + + # ============================================ + # SDK - Lib modules + # ============================================ + - name: sdk-lib-check + match: + - prowler/lib/check/** + tests: + - tests/lib/check/** + e2e: [] + + - name: sdk-lib-outputs + match: + - prowler/lib/outputs/** + tests: + - tests/lib/outputs/** + e2e: [] + + - name: sdk-lib-scan + match: + - prowler/lib/scan/** + tests: + - tests/lib/scan/** + e2e: [] + + - name: sdk-lib-cli + match: + - prowler/lib/cli/** + tests: + - tests/lib/cli/** + e2e: [] + + - name: sdk-lib-mutelist + match: + - prowler/lib/mutelist/** + tests: + - tests/lib/mutelist/** + e2e: [] + + # ============================================ + # API - Views, Serializers, Tasks + # ============================================ + - name: api-views + match: + - api/src/backend/api/v1/views.py + tests: + - api/src/backend/api/tests/test_views.py + e2e: + # API view changes can break UI + - ui/tests/** + + - name: api-serializers + match: + - api/src/backend/api/v1/serializers.py + - api/src/backend/api/v1/serializer_utils/** + tests: + - api/src/backend/api/tests/** + e2e: + # Serializer changes affect API responses → UI + - ui/tests/** + + - name: api-filters + match: + - api/src/backend/api/filters.py + tests: + - api/src/backend/api/tests/** + e2e: [] + + - name: api-rbac + match: + - api/src/backend/api/rbac/** + tests: + - api/src/backend/api/tests/** + e2e: + - ui/tests/roles/** + + - name: api-tasks + match: + - api/src/backend/tasks/** + tests: + - api/src/backend/tasks/tests/** + e2e: [] + + - name: api-attack-paths + match: + - api/src/backend/api/attack_paths/** + tests: + - api/src/backend/api/tests/test_attack_paths.py + e2e: [] + + # ============================================ + # UI - Components and Features + # ============================================ + - name: ui-providers + match: + - ui/components/providers/** + - ui/actions/providers/** + - ui/app/**/providers/** + tests: [] + e2e: + - ui/tests/providers/** + + - name: ui-findings + match: + - ui/components/findings/** + - ui/actions/findings/** + - ui/app/**/findings/** + tests: [] + e2e: + - ui/tests/findings/** + + - name: ui-scans + match: + - ui/components/scans/** + - ui/actions/scans/** + - ui/app/**/scans/** + tests: [] + e2e: + - ui/tests/scans/** + + - name: ui-compliance + match: + - ui/components/compliance/** + - ui/actions/compliances/** + - ui/app/**/compliance/** + tests: [] + e2e: + - ui/tests/compliance/** + + - name: ui-auth + match: + - ui/components/auth/** + - ui/actions/auth/** + - ui/app/(auth)/** + tests: [] + e2e: + - ui/tests/sign-in/** + - ui/tests/sign-up/** + + - name: ui-invitations + match: + - ui/components/invitations/** + - ui/actions/invitations/** + - ui/app/**/invitations/** + tests: [] + e2e: + - ui/tests/invitations/** + + - name: ui-roles + match: + - ui/components/roles/** + - ui/actions/roles/** + - ui/app/**/roles/** + tests: [] + e2e: + - ui/tests/roles/** + + - name: ui-users + match: + - ui/components/users/** + - ui/actions/users/** + - ui/app/**/users/** + tests: [] + e2e: + - ui/tests/users/** + + - name: ui-integrations + match: + - ui/components/integrations/** + - ui/actions/integrations/** + - ui/app/**/integrations/** + tests: [] + e2e: + - ui/tests/integrations/** + + - name: ui-resources + match: + - ui/components/resources/** + - ui/actions/resources/** + - ui/app/**/resources/** + tests: [] + e2e: + - ui/tests/resources/** + + - name: ui-profile + match: + - ui/app/**/profile/** + tests: [] + e2e: + - ui/tests/profile/** + + - name: ui-lighthouse + match: + - ui/components/lighthouse/** + - ui/actions/lighthouse/** + - ui/app/**/lighthouse/** + - ui/lib/lighthouse/** + tests: [] + e2e: + - ui/tests/lighthouse/** + + - name: ui-overview + match: + - ui/components/overview/** + - ui/actions/overview/** + tests: [] + e2e: + - ui/tests/home/** + + - name: ui-shadcn + match: + - ui/components/shadcn/** + - ui/components/ui/** + tests: [] + e2e: + # Shared components can affect any E2E + - ui/tests/** + + - name: ui-attack-paths + match: + - ui/components/attack-paths/** + - ui/actions/attack-paths/** + - ui/app/**/attack-paths/** + tests: [] + e2e: + - ui/tests/attack-paths/** diff --git a/.github/workflows/api-bump-version.yml b/.github/workflows/api-bump-version.yml new file mode 100644 index 0000000000..97cdd546ff --- /dev/null +++ b/.github/workflows/api-bump-version.yml @@ -0,0 +1,254 @@ +name: 'API: Bump Version' + +on: + release: + types: + - 'published' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + PROWLER_VERSION: ${{ github.event.release.tag_name }} + BASE_BRANCH: master + +jobs: + detect-release-type: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + is_minor: ${{ steps.detect.outputs.is_minor }} + is_patch: ${{ steps.detect.outputs.is_patch }} + major_version: ${{ steps.detect.outputs.major_version }} + minor_version: ${{ steps.detect.outputs.minor_version }} + patch_version: ${{ steps.detect.outputs.patch_version }} + current_api_version: ${{ steps.get_api_version.outputs.current_api_version }} + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Get current API version + id: get_api_version + run: | + CURRENT_API_VERSION=$(grep -oP '^version = "\K[^"]+' api/pyproject.toml) + echo "current_api_version=${CURRENT_API_VERSION}" >> "${GITHUB_OUTPUT}" + echo "Current API version: $CURRENT_API_VERSION" + + - name: Detect release type and parse version + id: detect + run: | + if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + MAJOR_VERSION=${BASH_REMATCH[1]} + MINOR_VERSION=${BASH_REMATCH[2]} + PATCH_VERSION=${BASH_REMATCH[3]} + + echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}" + echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}" + echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}" + + if (( MAJOR_VERSION != 5 )); then + echo "::error::Releasing another Prowler major version, aborting..." + exit 1 + fi + + if (( PATCH_VERSION == 0 )); then + echo "is_minor=true" >> "${GITHUB_OUTPUT}" + echo "is_patch=false" >> "${GITHUB_OUTPUT}" + echo "✓ Minor release detected: $PROWLER_VERSION" + else + echo "is_minor=false" >> "${GITHUB_OUTPUT}" + echo "is_patch=true" >> "${GITHUB_OUTPUT}" + echo "✓ Patch release detected: $PROWLER_VERSION" + fi + else + echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)" + exit 1 + fi + + bump-minor-version: + needs: detect-release-type + if: needs.detect-release-type.outputs.is_minor == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Calculate next API minor version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + CURRENT_API_VERSION="${{ needs.detect-release-type.outputs.current_api_version }}" + + # API version follows Prowler minor + 1 + # For Prowler 5.17.0 -> API 1.18.0 + # For next master (Prowler 5.18.0) -> API 1.19.0 + NEXT_API_VERSION=1.$((MINOR_VERSION + 2)).0 + + echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" + echo "NEXT_API_VERSION=${NEXT_API_VERSION}" >> "${GITHUB_ENV}" + + echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.0" + echo "Current API version: $CURRENT_API_VERSION" + echo "Next API minor version (for master): $NEXT_API_VERSION" + + - name: Bump API versions in files for master + run: | + set -e + + sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml + sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${NEXT_API_VERSION}\"|" api/src/backend/api/v1/views.py + sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for next API minor version to master + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: master + commit-message: 'chore(api): Bump version to v${{ env.NEXT_API_VERSION }}' + branch: api-version-bump-to-v${{ env.NEXT_API_VERSION }} + title: 'chore(api): Bump version to v${{ env.NEXT_API_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler API version to v${{ env.NEXT_API_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + - name: Checkout version branch + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} + + - name: Calculate first API patch version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + CURRENT_API_VERSION="${{ needs.detect-release-type.outputs.current_api_version }}" + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + # API version follows Prowler minor + 1 + # For Prowler 5.17.0 release -> version branch v5.17 should have API 1.18.1 + FIRST_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).1 + + echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" + echo "FIRST_API_PATCH_VERSION=${FIRST_API_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.0" + echo "First API patch version (for ${VERSION_BRANCH}): $FIRST_API_PATCH_VERSION" + echo "Version branch: $VERSION_BRANCH" + + - name: Bump API versions in files for version branch + run: | + set -e + + sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml + sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${FIRST_API_PATCH_VERSION}\"|" api/src/backend/api/v1/views.py + sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for first API patch version to version branch + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: ${{ env.VERSION_BRANCH }} + commit-message: 'chore(api): Bump version to v${{ env.FIRST_API_PATCH_VERSION }}' + branch: api-version-bump-to-v${{ env.FIRST_API_PATCH_VERSION }} + title: 'chore(api): Bump version to v${{ env.FIRST_API_PATCH_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler API version to v${{ env.FIRST_API_PATCH_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + bump-patch-version: + needs: detect-release-type + if: needs.detect-release-type.outputs.is_patch == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Calculate next API patch version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + PATCH_VERSION=${{ needs.detect-release-type.outputs.patch_version }} + CURRENT_API_VERSION="${{ needs.detect-release-type.outputs.current_api_version }}" + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + # Extract current API patch to increment it + if [[ $CURRENT_API_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + API_PATCH=${BASH_REMATCH[3]} + + # API version follows Prowler minor + 1 + # Keep same API minor (based on Prowler minor), increment patch + NEXT_API_PATCH_VERSION=1.$((MINOR_VERSION + 1)).$((API_PATCH + 1)) + + echo "CURRENT_API_VERSION=${CURRENT_API_VERSION}" >> "${GITHUB_ENV}" + echo "NEXT_API_PATCH_VERSION=${NEXT_API_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "Prowler release version: ${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION}" + echo "Current API version: $CURRENT_API_VERSION" + echo "Next API patch version: $NEXT_API_PATCH_VERSION" + echo "Target branch: $VERSION_BRANCH" + else + echo "::error::Invalid API version format: $CURRENT_API_VERSION" + exit 1 + fi + + - name: Bump API versions in files for version branch + run: | + set -e + + sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml + sed -i "s|spectacular_settings.VERSION = \"${CURRENT_API_VERSION}\"|spectacular_settings.VERSION = \"${NEXT_API_PATCH_VERSION}\"|" api/src/backend/api/v1/views.py + sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for next API patch version to version branch + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: ${{ env.VERSION_BRANCH }} + commit-message: 'chore(api): Bump version to v${{ env.NEXT_API_PATCH_VERSION }}' + branch: api-version-bump-to-v${{ env.NEXT_API_PATCH_VERSION }} + title: 'chore(api): Bump version to v${{ env.NEXT_API_PATCH_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler API version to v${{ env.NEXT_API_PATCH_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. diff --git a/.github/workflows/api-code-quality.yml b/.github/workflows/api-code-quality.yml index 2c52e532ba..c5cc02a298 100644 --- a/.github/workflows/api-code-quality.yml +++ b/.github/workflows/api-code-quality.yml @@ -5,16 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-code-quality.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-code-quality.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -39,16 +33,20 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: | + api/** + .github/workflows/api-code-quality.yml files_ignore: | api/docs/** api/README.md api/CHANGELOG.md + api/AGENTS.md - name: Setup Python with Poetry if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/api-codeql.yml b/.github/workflows/api-codeql.yml index e9af830156..45cc911050 100644 --- a/.github/workflows/api-codeql.yml +++ b/.github/workflows/api-codeql.yml @@ -25,7 +25,7 @@ concurrency: cancel-in-progress: true jobs: - analyze: + api-analyze: name: CodeQL Security Analysis runs-on: ubuntu-latest timeout-minutes: 30 @@ -42,15 +42,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/api-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/api-container-build-push.yml b/.github/workflows/api-container-build-push.yml index 679f551972..17693785f7 100644 --- a/.github/workflows/api-container-build-push.yml +++ b/.github/workflows/api-container-build-push.yml @@ -7,10 +7,16 @@ on: paths: - 'api/**' - 'prowler/**' - - '.github/workflows/api-build-lint-push-containers.yml' + - '.github/workflows/api-container-build-push.yml' release: types: - 'published' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (e.g., 5.14.0)' + required: true + type: string permissions: contents: read @@ -22,7 +28,7 @@ concurrency: env: # Tags LATEST_TAG: latest - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} STABLE_TAG: stable WORKING_DIRECTORY: ./api @@ -42,9 +48,44 @@ jobs: id: set-short-sha run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - container-build-push: + notify-release-started: + if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') needs: setup runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + message-ts: ${{ steps.slack-notification.outputs.ts }} + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Notify container push started + id: slack-notification + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + COMPONENT: API + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-started.json" + + container-build-push: + needs: [setup, notify-release-started] + if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped') + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 timeout-minutes: 30 permissions: contents: read @@ -52,7 +93,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Login to DockerHub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 @@ -61,35 +102,104 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build and push API container (latest) + - name: Build and push API container for ${{ matrix.arch }} + id: container-push + if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: ${{ env.WORKING_DIRECTORY }} + push: true + platforms: ${{ matrix.platform }} + tags: | + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + # Create and push multi-architecture manifest + create-manifest: + needs: [setup, container-build-push] + if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' + runs-on: ubuntu-latest + + steps: + - name: Login to DockerHub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Create and push manifests for push event if: github.event_name == 'push' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: ${{ env.WORKING_DIRECTORY }} - push: true - tags: | - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + run: | + docker buildx imagetools create \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64 - - name: Build and push API container (release) - if: github.event_name == 'release' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + - name: Create and push manifests for release event + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + run: | + docker buildx imagetools create \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.RELEASE_TAG }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64 + + - name: Install regctl + if: always() + uses: regclient/actions/regctl-installer@f61d18f46c86af724a9c804cb9ff2a6fec741c7c # main + + - name: Cleanup intermediate architecture tags + if: always() + run: | + echo "Cleaning up intermediate tags..." + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64" || true + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64" || true + echo "Cleanup completed" + + notify-release-completed: + if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + needs: [setup, notify-release-started, container-build-push, create-manifest] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Determine overall outcome + id: outcome + run: | + if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then + echo "outcome=success" >> $GITHUB_OUTPUT + else + echo "outcome=failure" >> $GITHUB_OUTPUT + fi + + - name: Notify container push completed + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }} + COMPONENT: API + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} with: - context: ${{ env.WORKING_DIRECTORY }} - push: true - tags: | - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.RELEASE_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} - cache-from: type=gha - cache-to: type=gha,mode=max + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" + step-outcome: ${{ steps.outcome.outputs.outcome }} + update-ts: ${{ needs.notify-release-started.outputs.message-ts }} trigger-deployment: - if: github.event_name == 'push' needs: [setup, container-build-push] + if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -97,7 +207,7 @@ jobs: steps: - name: Trigger API deployment - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} repository: ${{ secrets.CLOUD_DISPATCH }} diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 0b9f5a28bc..e1bca8091c 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -5,16 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-container-checks.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -26,6 +20,7 @@ env: jobs: api-dockerfile-lint: + if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -33,11 +28,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: api/Dockerfile @@ -49,7 +44,17 @@ jobs: ignore: DL3013 api-container-build-and-scan: - runs-on: ubuntu-latest + if: github.repository == 'prowler-cloud/prowler' + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 timeout-minutes: 30 permissions: contents: read @@ -58,37 +63,40 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: api/** files_ignore: | api/docs/** api/README.md api/CHANGELOG.md + api/AGENTS.md - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build container + - name: Build container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.API_WORKING_DIR }} push: false load: true - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} - - name: Scan container with Trivy - if: github.repository == 'prowler-cloud/prowler' && steps.check-changes.outputs.any_changed == 'true' + - name: Scan container with Trivy for ${{ matrix.arch }} + if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }} + image-tag: ${{ github.sha }}-${{ matrix.arch }} fail-on-critical: 'false' severity: 'CRITICAL' diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index 126b957ed0..e8a8efc879 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -5,16 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-security.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-security.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -39,16 +33,20 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: | + api/** + .github/workflows/api-security.yml files_ignore: | api/docs/** api/README.md api/CHANGELOG.md + api/AGENTS.md - name: Setup Python with Poetry if: steps.check-changes.outputs.any_changed == 'true' @@ -63,9 +61,8 @@ jobs: - name: Safety if: steps.check-changes.outputs.any_changed == 'true' - # 76352, 76353, 77323 come from SDK, but they cannot upgrade it yet. It does not affect API - # TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X - run: poetry run safety check --ignore 70612,66963,74429,76352,76353,77323,77744,77745 + run: poetry run safety check --ignore 79023,79027 + # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - name: Vulture if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 3f827d8283..ec235fd33b 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -5,16 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-tests.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-tests.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -79,16 +73,20 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: | + api/** + .github/workflows/api-tests.yml files_ignore: | api/docs/** api/README.md api/CHANGELOG.md + api/AGENTS.md - name: Setup Python with Poetry if: steps.check-changes.outputs.any_changed == 'true' @@ -103,7 +101,7 @@ jobs: - name: Upload coverage reports to Codecov if: steps.check-changes.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index f8df9a9abc..974d919fc6 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -38,7 +38,7 @@ jobs: - name: Backport PR if: steps.label_check.outputs.label_check == 'success' - uses: sorenlouv/backport-github-action@ad888e978060bc1b2798690dd9d03c4036560947 # v9.5.1 + uses: sorenlouv/backport-github-action@516854e7c9f962b9939085c9a92ea28411d1ae90 # v10.2.0 with: github_token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} auto_backport_label_prefix: ${{ env.BACKPORT_LABEL_PREFIX }} diff --git a/.github/workflows/comment-label-update.yml b/.github/workflows/comment-label-update.yml new file mode 100644 index 0000000000..9bddd6a9dd --- /dev/null +++ b/.github/workflows/comment-label-update.yml @@ -0,0 +1,39 @@ +name: 'Tools: Comment Label Update' + +on: + issue_comment: + types: + - 'created' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + update-labels: + if: contains(github.event.issue.labels.*.name, 'status/awaiting-response') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + pull-requests: write + + steps: + - name: Remove 'status/awaiting-response' label + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + echo "Removing 'status/awaiting-response' label from #$ISSUE_NUMBER" + gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response \ + -X DELETE + + - name: Add 'status/waiting-for-revision' label + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + echo "Adding 'status/waiting-for-revision' label to #$ISSUE_NUMBER" + gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels \ + -X POST \ + -f labels[]='status/waiting-for-revision' diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index c5ba446edf..58e1653b74 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -26,6 +26,6 @@ jobs: steps: - name: Check PR title format - uses: agenthunt/conventional-commit-checker-action@9e552d650d0e205553ec7792d447929fc78e012b # v2.0.0 + uses: agenthunt/conventional-commit-checker-action@f1823f632e95a64547566dcd2c7da920e67117ad # v2.0.1 with: pr-title-regex: '^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([^)]+\))?!?: .+' diff --git a/.github/workflows/docs-bump-version.yml b/.github/workflows/docs-bump-version.yml new file mode 100644 index 0000000000..dde21b9c1e --- /dev/null +++ b/.github/workflows/docs-bump-version.yml @@ -0,0 +1,247 @@ +name: 'Docs: Bump Version' + +on: + release: + types: + - 'published' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + PROWLER_VERSION: ${{ github.event.release.tag_name }} + BASE_BRANCH: master + +jobs: + detect-release-type: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + is_minor: ${{ steps.detect.outputs.is_minor }} + is_patch: ${{ steps.detect.outputs.is_patch }} + major_version: ${{ steps.detect.outputs.major_version }} + minor_version: ${{ steps.detect.outputs.minor_version }} + patch_version: ${{ steps.detect.outputs.patch_version }} + current_docs_version: ${{ steps.get_docs_version.outputs.current_docs_version }} + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Get current documentation version + id: get_docs_version + run: | + CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' docs/getting-started/installation/prowler-app.mdx) + echo "current_docs_version=${CURRENT_DOCS_VERSION}" >> "${GITHUB_OUTPUT}" + echo "Current documentation version: $CURRENT_DOCS_VERSION" + + - name: Detect release type and parse version + id: detect + run: | + if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + MAJOR_VERSION=${BASH_REMATCH[1]} + MINOR_VERSION=${BASH_REMATCH[2]} + PATCH_VERSION=${BASH_REMATCH[3]} + + echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}" + echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}" + echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}" + + if (( MAJOR_VERSION != 5 )); then + echo "::error::Releasing another Prowler major version, aborting..." + exit 1 + fi + + if (( PATCH_VERSION == 0 )); then + echo "is_minor=true" >> "${GITHUB_OUTPUT}" + echo "is_patch=false" >> "${GITHUB_OUTPUT}" + echo "✓ Minor release detected: $PROWLER_VERSION" + else + echo "is_minor=false" >> "${GITHUB_OUTPUT}" + echo "is_patch=true" >> "${GITHUB_OUTPUT}" + echo "✓ Patch release detected: $PROWLER_VERSION" + fi + else + echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)" + exit 1 + fi + + bump-minor-version: + needs: detect-release-type + if: needs.detect-release-type.outputs.is_minor == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Calculate next minor version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + CURRENT_DOCS_VERSION="${{ needs.detect-release-type.outputs.current_docs_version }}" + + NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0 + echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}" + echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}" + + echo "Current documentation version: $CURRENT_DOCS_VERSION" + echo "Current release version: $PROWLER_VERSION" + echo "Next minor version: $NEXT_MINOR_VERSION" + + - name: Bump versions in documentation for master + run: | + set -e + + # Update prowler-app.mdx with current release version + sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx + sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for documentation update to master + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: master + commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}' + branch: docs-version-update-to-v${{ env.PROWLER_VERSION }} + title: 'docs: Update version to v${{ env.PROWLER_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### Files Updated + - `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION` + - All `*.mdx` files with `` components + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + - name: Checkout version branch + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} + + - name: Calculate first patch version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + CURRENT_DOCS_VERSION="${{ needs.detect-release-type.outputs.current_docs_version }}" + + FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1 + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}" + echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "First patch version: $FIRST_PATCH_VERSION" + echo "Version branch: $VERSION_BRANCH" + + - name: Bump versions in documentation for version branch + run: | + set -e + + # Update prowler-app.mdx with current release version + sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx + sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for documentation update to version branch + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: ${{ env.VERSION_BRANCH }} + commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}' + branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}-branch + title: 'docs: Update version to v${{ env.PROWLER_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### Files Updated + - `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION` + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + bump-patch-version: + needs: detect-release-type + if: needs.detect-release-type.outputs.is_patch == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Calculate next patch version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + PATCH_VERSION=${{ needs.detect-release-type.outputs.patch_version }} + CURRENT_DOCS_VERSION="${{ needs.detect-release-type.outputs.current_docs_version }}" + + NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1)) + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}" + echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "Current documentation version: $CURRENT_DOCS_VERSION" + echo "Current release version: $PROWLER_VERSION" + echo "Next patch version: $NEXT_PATCH_VERSION" + echo "Target branch: $VERSION_BRANCH" + + - name: Bump versions in documentation for patch version + run: | + set -e + + # Update prowler-app.mdx with current release version + sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx + sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for documentation update to version branch + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: ${{ env.VERSION_BRANCH }} + commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}' + branch: docs-version-update-to-v${{ env.PROWLER_VERSION }} + title: 'docs: Update version to v${{ env.PROWLER_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### Files Updated + - `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION` + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. diff --git a/.github/workflows/find-secrets.yml b/.github/workflows/find-secrets.yml index 6428cf8f08..c525d8faa4 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -23,11 +23,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Scan for secrets with TruffleHog - uses: trufflesecurity/trufflehog@ad6fc8fb446b8fafbf7ea8193d2d6bfd42f45690 # v3.90.11 + uses: trufflesecurity/trufflehog@ef6e76c3c4023279497fab4721ffa071a722fd05 # v3.92.4 with: extra_args: '--results=verified,unknown' diff --git a/.github/workflows/labeler-community.yml b/.github/workflows/labeler-community.yml deleted file mode 100644 index 1949ca886e..0000000000 --- a/.github/workflows/labeler-community.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Community PR labelling - -on: - # We need "write" permissions on the PR to be able to add a label. - pull_request_target: # We need this to have labelling permissions. There are no user inputs here, so we should be fine. - types: - - opened - -permissions: {} - -jobs: - label-if-community: - name: Add 'community' label if the PR is from a community contributor - if: github.repository == 'prowler-cloud/prowler' && github.event.pull_request.author_association != 'MEMBER' && github.event.pull_request.author_association != 'OWNER' - runs-on: ubuntu-latest - permissions: - pull-requests: write # to write the label - - steps: - - name: Add the 'community' label - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - GH_TOKEN: ${{ github.token }} - run: | - echo "Adding 'community' label to the PR" - gh pr edit "$PR_NUMBER" --add-label community diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index fad94177f7..1f44080229 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -27,3 +27,64 @@ jobs: uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: sync-labels: true + + label-community: + name: Add 'community' label if the PR is from a community contributor + needs: labeler + if: github.repository == 'prowler-cloud/prowler' && github.event.action == 'opened' + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Check if author is org member + id: check_membership + env: + AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + # Hardcoded list of prowler-cloud organization members + # This list includes members who have set their organization membership as private + ORG_MEMBERS=( + "AdriiiPRodri" + "Alan-TheGentleman" + "alejandrobailo" + "amitsharm" + "andoniaf" + "cesararroba" + "danibarranqueroo" + "HugoPBrito" + "jfagoagas" + "josema-xyz" + "lydiavilchez" + "mmuller88" + # "MrCloudSec" + "pedrooot" + "prowler-bot" + "puchy22" + "RosaRivasProwler" + "StylusFrost" + "toniblyx" + "vicferpoy" + ) + + echo "Checking if $AUTHOR is a member of prowler-cloud organization" + + # Check if author is in the org members list + if printf '%s\n' "${ORG_MEMBERS[@]}" | grep -q "^${AUTHOR}$"; then + echo "is_member=true" >> $GITHUB_OUTPUT + echo "$AUTHOR is an organization member" + else + echo "is_member=false" >> $GITHUB_OUTPUT + echo "$AUTHOR is not an organization member" + fi + + - name: Add community label + if: steps.check_membership.outputs.is_member == 'false' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + run: | + echo "Adding 'community' label to PR #$PR_NUMBER" + gh api /repos/${{ github.repository }}/issues/${{ github.event.number }}/labels \ + -X POST \ + -f labels[]='community' diff --git a/.github/workflows/mcp-container-build-push.yml b/.github/workflows/mcp-container-build-push.yml index fefb7555b7..130ef90129 100644 --- a/.github/workflows/mcp-container-build-push.yml +++ b/.github/workflows/mcp-container-build-push.yml @@ -10,6 +10,12 @@ on: release: types: - 'published' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (e.g., 5.14.0)' + required: true + type: string permissions: contents: read @@ -21,7 +27,7 @@ concurrency: env: # Tags LATEST_TAG: latest - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} STABLE_TAG: stable WORKING_DIRECTORY: ./mcp_server @@ -41,16 +47,51 @@ jobs: id: set-short-sha run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - container-build-push: + notify-release-started: + if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') needs: setup runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + message-ts: ${{ steps.slack-notification.outputs.ts }} + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Notify container push started + id: slack-notification + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + COMPONENT: MCP + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-started.json" + + container-build-push: + needs: [setup, notify-release-started] + if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped') + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 timeout-minutes: 30 permissions: contents: read packages: write steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Login to DockerHub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 @@ -59,50 +100,112 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build and push MCP container (latest) + - name: Build and push MCP container for ${{ matrix.arch }} + id: container-push + if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: ${{ env.WORKING_DIRECTORY }} + push: true + platforms: ${{ matrix.platform }} + tags: | + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }} + labels: | + org.opencontainers.image.title=Prowler MCP Server + org.opencontainers.image.description=Model Context Protocol server for Prowler + org.opencontainers.image.vendor=ProwlerPro, Inc. + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }} + ${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + # Create and push multi-architecture manifest + create-manifest: + needs: [setup, container-build-push] + if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' + runs-on: ubuntu-latest + + steps: + - name: Login to DockerHub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Create and push manifests for push event if: github.event_name == 'push' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: ${{ env.WORKING_DIRECTORY }} - push: true - tags: | - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }} - labels: | - org.opencontainers.image.title=Prowler MCP Server - org.opencontainers.image.description=Model Context Protocol server for Prowler - org.opencontainers.image.vendor=ProwlerPro, Inc. - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} - cache-from: type=gha - cache-to: type=gha,mode=max + run: | + docker buildx imagetools create \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64 - - name: Build and push MCP container (release) - if: github.event_name == 'release' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + - name: Create and push manifests for release event + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + run: | + docker buildx imagetools create \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.RELEASE_TAG }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64 + + - name: Install regctl + if: always() + uses: regclient/actions/regctl-installer@main + + - name: Cleanup intermediate architecture tags + if: always() + run: | + echo "Cleaning up intermediate tags..." + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64" || true + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64" || true + echo "Cleanup completed" + + notify-release-completed: + if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + needs: [setup, notify-release-started, container-build-push, create-manifest] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Determine overall outcome + id: outcome + run: | + if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then + echo "outcome=success" >> $GITHUB_OUTPUT + else + echo "outcome=failure" >> $GITHUB_OUTPUT + fi + + - name: Notify container push completed + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }} + COMPONENT: MCP + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} with: - context: ${{ env.WORKING_DIRECTORY }} - push: true - tags: | - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.RELEASE_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} - labels: | - org.opencontainers.image.title=Prowler MCP Server - org.opencontainers.image.description=Model Context Protocol server for Prowler - org.opencontainers.image.vendor=ProwlerPro, Inc. - org.opencontainers.image.version=${{ env.RELEASE_TAG }} - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.created=${{ github.event.release.published_at }} - cache-from: type=gha - cache-to: type=gha,mode=max + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" + step-outcome: ${{ steps.outcome.outputs.outcome }} + update-ts: ${{ needs.notify-release-started.outputs.message-ts }} trigger-deployment: - if: github.event_name == 'push' needs: [setup, container-build-push] + if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -110,7 +213,7 @@ jobs: steps: - name: Trigger MCP deployment - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} repository: ${{ secrets.CLOUD_DISPATCH }} diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index f93ed9023c..5ea0b6c465 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -5,16 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/**' - - '.github/workflows/mcp-container-checks.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/**' - - '.github/workflows/mcp-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -26,6 +20,7 @@ env: jobs: mcp-dockerfile-lint: + if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -33,11 +28,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: mcp_server/Dockerfile @@ -48,7 +43,17 @@ jobs: dockerfile: mcp_server/Dockerfile mcp-container-build-and-scan: - runs-on: ubuntu-latest + if: github.repository == 'prowler-cloud/prowler' + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 timeout-minutes: 30 permissions: contents: read @@ -57,36 +62,38 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for MCP changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: mcp_server/** files_ignore: | mcp_server/README.md mcp_server/CHANGELOG.md - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build MCP container + - name: Build MCP container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.MCP_WORKING_DIR }} push: false load: true - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} - - name: Scan MCP container with Trivy - if: github.repository == 'prowler-cloud/prowler' && steps.check-changes.outputs.any_changed == 'true' + - name: Scan MCP container with Trivy for ${{ matrix.arch }} + if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }} + image-tag: ${{ github.sha }}-${{ matrix.arch }} fail-on-critical: 'false' severity: 'CRITICAL' diff --git a/.github/workflows/mcp-pypi-release.yml b/.github/workflows/mcp-pypi-release.yml new file mode 100644 index 0000000000..7a3e5f5daa --- /dev/null +++ b/.github/workflows/mcp-pypi-release.yml @@ -0,0 +1,81 @@ +name: "MCP: PyPI Release" + +on: + release: + types: + - "published" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + PYTHON_VERSION: "3.12" + WORKING_DIRECTORY: ./mcp_server + +jobs: + validate-release: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + prowler_version: ${{ steps.parse-version.outputs.version }} + major_version: ${{ steps.parse-version.outputs.major }} + + steps: + - name: Parse and validate version + id: parse-version + run: | + PROWLER_VERSION="${{ env.RELEASE_TAG }}" + echo "version=${PROWLER_VERSION}" >> "${GITHUB_OUTPUT}" + + # Extract major version + MAJOR_VERSION="${PROWLER_VERSION%%.*}" + echo "major=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}" + + # Validate major version (only Prowler 3, 4, 5 supported) + case ${MAJOR_VERSION} in + 3|4|5) + echo "✓ Releasing Prowler MCP for tag ${PROWLER_VERSION}" + ;; + *) + echo "::error::Unsupported Prowler major version: ${MAJOR_VERSION}" + exit 1 + ;; + esac + + publish-prowler-mcp: + needs: validate-release + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + environment: + name: pypi-prowler-mcp + url: https://pypi.org/project/prowler-mcp/ + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Build prowler-mcp package + working-directory: ${{ env.WORKING_DIRECTORY }} + run: uv build + + - name: Publish prowler-mcp package to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/ + print-hash: true diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml index f8de212e2a..cc22767552 100644 --- a/.github/workflows/pr-check-changelog.yml +++ b/.github/workflows/pr-check-changelog.yml @@ -29,27 +29,29 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Get changed files id: changed-files - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | api/** ui/** prowler/** mcp_server/** + poetry.lock + pyproject.toml - name: Check for folder changes and changelog presence id: check-folders run: | missing_changelogs="" - # Check api folder if [[ "${{ steps.changed-files.outputs.any_changed }}" == "true" ]]; then + # Check monitored folders for folder in $MONITORED_FOLDERS; do # Get files changed in this folder changed_in_folder=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep "^${folder}/" || true) @@ -64,6 +66,22 @@ jobs: fi fi done + + # Check root-level dependency files (poetry.lock, pyproject.toml) + # These are associated with the prowler folder changelog + root_deps_changed=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep -E "^(poetry\.lock|pyproject\.toml)$" || true) + if [ -n "$root_deps_changed" ]; then + echo "Detected changes in root dependency files: $root_deps_changed" + # Check if prowler/CHANGELOG.md was already updated (might have been caught above) + prowler_changelog_updated=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' | grep "^prowler/CHANGELOG.md$" || true) + if [ -z "$prowler_changelog_updated" ]; then + # Only add if prowler wasn't already flagged + if ! echo "$missing_changelogs" | grep -q "prowler"; then + echo "No changelog update found for root dependency changes" + missing_changelogs="${missing_changelogs}- \`prowler\` (root dependency files changed)"$'\n' + fi + fi + fi fi { @@ -83,7 +101,7 @@ jobs: - name: Update PR comment with changelog status if: github.event.pull_request.head.repo.full_name == github.repository - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find-comment.outputs.comment-id }} diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml index 3761d252a3..c812f4ef19 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -25,14 +25,14 @@ jobs: steps: - name: Checkout PR head - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Get changed files id: changed-files - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: '**' @@ -97,7 +97,7 @@ jobs: body-includes: '' - name: Create or update comment - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/pr-merged.yml b/.github/workflows/pr-merged.yml index d8255026e6..61970827f2 100644 --- a/.github/workflows/pr-merged.yml +++ b/.github/workflows/pr-merged.yml @@ -13,7 +13,10 @@ concurrency: jobs: trigger-cloud-pull-request: - if: github.event.pull_request.merged == true && github.repository == 'prowler-cloud/prowler' + if: | + github.event.pull_request.merged == true && + github.repository == 'prowler-cloud/prowler' && + !contains(github.event.pull_request.labels.*.name, 'skip-sync') runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -26,7 +29,7 @@ jobs: echo "SHORT_SHA=${SHORT_SHA::7}" >> $GITHUB_ENV - name: Trigger Cloud repository pull request - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} repository: ${{ secrets.CLOUD_DISPATCH }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 55b7d2e441..815ade1dec 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -27,13 +27,13 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.12' @@ -47,7 +47,7 @@ jobs: git config --global user.name 'prowler-bot' git config --global user.email '179230569+prowler-bot@users.noreply.github.com' - - name: Parse version and read changelogs + - name: Parse version and determine branch run: | # Validate version format (reusing pattern from sdk-bump-version.yml) if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then @@ -64,66 +64,80 @@ jobs: BRANCH_NAME="v${MAJOR_VERSION}.${MINOR_VERSION}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_ENV}" - # Function to extract the latest version from changelog - extract_latest_version() { - local changelog_file="$1" - if [ -f "$changelog_file" ]; then - # Extract the first version entry (most recent) from changelog - # Format: ## [version] (1.2.3) or ## [vversion] (v1.2.3) - local version=$(grep -m 1 '^## \[' "$changelog_file" | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]') - echo "$version" - else - echo "" - fi - } - - # Read actual versions from changelogs (source of truth) - UI_VERSION=$(extract_latest_version "ui/CHANGELOG.md") - API_VERSION=$(extract_latest_version "api/CHANGELOG.md") - SDK_VERSION=$(extract_latest_version "prowler/CHANGELOG.md") - MCP_VERSION=$(extract_latest_version "mcp_server/CHANGELOG.md") - - echo "UI_VERSION=${UI_VERSION}" >> "${GITHUB_ENV}" - echo "API_VERSION=${API_VERSION}" >> "${GITHUB_ENV}" - echo "SDK_VERSION=${SDK_VERSION}" >> "${GITHUB_ENV}" - echo "MCP_VERSION=${MCP_VERSION}" >> "${GITHUB_ENV}" - - if [ -n "$UI_VERSION" ]; then - echo "Read UI version from changelog: $UI_VERSION" - else - echo "Warning: No UI version found in ui/CHANGELOG.md" - fi - - if [ -n "$API_VERSION" ]; then - echo "Read API version from changelog: $API_VERSION" - else - echo "Warning: No API version found in api/CHANGELOG.md" - fi - - if [ -n "$SDK_VERSION" ]; then - echo "Read SDK version from changelog: $SDK_VERSION" - else - echo "Warning: No SDK version found in prowler/CHANGELOG.md" - fi - - if [ -n "$MCP_VERSION" ]; then - echo "Read MCP version from changelog: $MCP_VERSION" - else - echo "Warning: No MCP version found in mcp_server/CHANGELOG.md" - fi - echo "Prowler version: $PROWLER_VERSION" echo "Branch name: $BRANCH_NAME" - echo "UI version: $UI_VERSION" - echo "API version: $API_VERSION" - echo "SDK version: $SDK_VERSION" - echo "MCP version: $MCP_VERSION" echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')" else echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2 exit 1 fi + - name: Checkout release branch + run: | + echo "Checking out branch $BRANCH_NAME for release $PROWLER_VERSION..." + if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then + echo "Branch $BRANCH_NAME exists locally, checking out..." + git checkout "$BRANCH_NAME" + elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then + echo "Branch $BRANCH_NAME exists remotely, checking out..." + git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME" + else + echo "ERROR: Branch $BRANCH_NAME does not exist. For minor releases (X.Y.0), create it manually first. For patch releases (X.Y.Z), the branch should already exist." + exit 1 + fi + + - name: Read changelog versions from release branch + run: | + # Function to extract the version for a specific Prowler release from changelog + # This looks for entries with "(Prowler X.Y.Z)" to find the released version + extract_version_for_release() { + local changelog_file="$1" + local prowler_version="$2" + if [ -f "$changelog_file" ]; then + # Extract version that matches this Prowler release + # Format: ## [version] (Prowler X.Y.Z) or ## [vversion] (Prowler vX.Y.Z) + local version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]') + echo "$version" + else + echo "" + fi + } + + # Read versions from changelogs for this specific Prowler release + SDK_VERSION=$(extract_version_for_release "prowler/CHANGELOG.md" "$PROWLER_VERSION") + API_VERSION=$(extract_version_for_release "api/CHANGELOG.md" "$PROWLER_VERSION") + UI_VERSION=$(extract_version_for_release "ui/CHANGELOG.md" "$PROWLER_VERSION") + MCP_VERSION=$(extract_version_for_release "mcp_server/CHANGELOG.md" "$PROWLER_VERSION") + + echo "SDK_VERSION=${SDK_VERSION}" >> "${GITHUB_ENV}" + echo "API_VERSION=${API_VERSION}" >> "${GITHUB_ENV}" + echo "UI_VERSION=${UI_VERSION}" >> "${GITHUB_ENV}" + echo "MCP_VERSION=${MCP_VERSION}" >> "${GITHUB_ENV}" + + if [ -n "$SDK_VERSION" ]; then + echo "✓ SDK version for Prowler $PROWLER_VERSION: $SDK_VERSION" + else + echo "ℹ No SDK version found for Prowler $PROWLER_VERSION in prowler/CHANGELOG.md" + fi + + if [ -n "$API_VERSION" ]; then + echo "✓ API version for Prowler $PROWLER_VERSION: $API_VERSION" + else + echo "ℹ No API version found for Prowler $PROWLER_VERSION in api/CHANGELOG.md" + fi + + if [ -n "$UI_VERSION" ]; then + echo "✓ UI version for Prowler $PROWLER_VERSION: $UI_VERSION" + else + echo "ℹ No UI version found for Prowler $PROWLER_VERSION in ui/CHANGELOG.md" + fi + + if [ -n "$MCP_VERSION" ]; then + echo "✓ MCP version for Prowler $PROWLER_VERSION: $MCP_VERSION" + else + echo "ℹ No MCP version found for Prowler $PROWLER_VERSION in mcp_server/CHANGELOG.md" + fi + - name: Extract and combine changelog entries run: | set -e @@ -149,70 +163,54 @@ jobs: # Remove --- separators sed -i '/^---$/d' "$output_file" - - # Remove only trailing empty lines (not all empty lines) - sed -i -e :a -e '/^\s*$/d;N;ba' "$output_file" } - # Calculate expected versions for this release - if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - EXPECTED_UI_VERSION="1.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" - EXPECTED_API_VERSION="1.$((${BASH_REMATCH[2]} + 1)).${BASH_REMATCH[3]}" - - echo "Expected UI version for this release: $EXPECTED_UI_VERSION" - echo "Expected API version for this release: $EXPECTED_API_VERSION" - fi - # Determine if components have changes for this specific release - # UI has changes if its current version matches what we expect for this release - if [ -n "$UI_VERSION" ] && [ "$UI_VERSION" = "$EXPECTED_UI_VERSION" ]; then - echo "HAS_UI_CHANGES=true" >> $GITHUB_ENV - echo "✓ UI changes detected - version matches expected: $UI_VERSION" - extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md" - else - echo "HAS_UI_CHANGES=false" >> $GITHUB_ENV - echo "ℹ No UI changes for this release (current: $UI_VERSION, expected: $EXPECTED_UI_VERSION)" - touch "ui_changelog.md" - fi - - # API has changes if its current version matches what we expect for this release - if [ -n "$API_VERSION" ] && [ "$API_VERSION" = "$EXPECTED_API_VERSION" ]; then - echo "HAS_API_CHANGES=true" >> $GITHUB_ENV - echo "✓ API changes detected - version matches expected: $API_VERSION" - extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md" - else - echo "HAS_API_CHANGES=false" >> $GITHUB_ENV - echo "ℹ No API changes for this release (current: $API_VERSION, expected: $EXPECTED_API_VERSION)" - touch "api_changelog.md" - fi - - # SDK has changes if its current version matches the input version - if [ -n "$SDK_VERSION" ] && [ "$SDK_VERSION" = "$PROWLER_VERSION" ]; then + if [ -n "$SDK_VERSION" ]; then echo "HAS_SDK_CHANGES=true" >> $GITHUB_ENV - echo "✓ SDK changes detected - version matches input: $SDK_VERSION" - extract_changelog "prowler/CHANGELOG.md" "$PROWLER_VERSION" "prowler_changelog.md" + HAS_SDK_CHANGES="true" + echo "✓ SDK changes detected - version: $SDK_VERSION" + extract_changelog "prowler/CHANGELOG.md" "$SDK_VERSION" "prowler_changelog.md" else echo "HAS_SDK_CHANGES=false" >> $GITHUB_ENV - echo "ℹ No SDK changes for this release (current: $SDK_VERSION, input: $PROWLER_VERSION)" + HAS_SDK_CHANGES="false" + echo "ℹ No SDK changes for this release" touch "prowler_changelog.md" fi - # MCP has changes if the changelog references this Prowler version - # Check if the changelog contains "(Prowler X.Y.Z)" or "(Prowler UNRELEASED)" - if [ -f "mcp_server/CHANGELOG.md" ]; then - MCP_PROWLER_REF=$(grep -m 1 "^## \[.*\] (Prowler" mcp_server/CHANGELOG.md | sed -E 's/.*\(Prowler ([^)]+)\).*/\1/' | tr -d '[:space:]') - if [ "$MCP_PROWLER_REF" = "$PROWLER_VERSION" ] || [ "$MCP_PROWLER_REF" = "UNRELEASED" ]; then - echo "HAS_MCP_CHANGES=true" >> $GITHUB_ENV - echo "✓ MCP changes detected - Prowler reference: $MCP_PROWLER_REF (version: $MCP_VERSION)" - extract_changelog "mcp_server/CHANGELOG.md" "$MCP_VERSION" "mcp_changelog.md" - else - echo "HAS_MCP_CHANGES=false" >> $GITHUB_ENV - echo "ℹ No MCP changes for this release (Prowler reference: $MCP_PROWLER_REF, input: $PROWLER_VERSION)" - touch "mcp_changelog.md" - fi + if [ -n "$API_VERSION" ]; then + echo "HAS_API_CHANGES=true" >> $GITHUB_ENV + HAS_API_CHANGES="true" + echo "✓ API changes detected - version: $API_VERSION" + extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md" + else + echo "HAS_API_CHANGES=false" >> $GITHUB_ENV + HAS_API_CHANGES="false" + echo "ℹ No API changes for this release" + touch "api_changelog.md" + fi + + if [ -n "$UI_VERSION" ]; then + echo "HAS_UI_CHANGES=true" >> $GITHUB_ENV + HAS_UI_CHANGES="true" + echo "✓ UI changes detected - version: $UI_VERSION" + extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md" + else + echo "HAS_UI_CHANGES=false" >> $GITHUB_ENV + HAS_UI_CHANGES="false" + echo "ℹ No UI changes for this release" + touch "ui_changelog.md" + fi + + if [ -n "$MCP_VERSION" ]; then + echo "HAS_MCP_CHANGES=true" >> $GITHUB_ENV + HAS_MCP_CHANGES="true" + echo "✓ MCP changes detected - version: $MCP_VERSION" + extract_changelog "mcp_server/CHANGELOG.md" "$MCP_VERSION" "mcp_changelog.md" else echo "HAS_MCP_CHANGES=false" >> $GITHUB_ENV - echo "ℹ No MCP changelog found" + HAS_MCP_CHANGES="false" + echo "ℹ No MCP changes for this release" touch "mcp_changelog.md" fi @@ -255,21 +253,6 @@ jobs: echo "Combined changelog preview:" cat combined_changelog.md - - name: Checkout release branch for patch release - if: ${{ env.PATCH_VERSION != '0' }} - run: | - echo "Patch release detected, checking out existing branch $BRANCH_NAME..." - if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then - echo "Branch $BRANCH_NAME exists locally, checking out..." - git checkout "$BRANCH_NAME" - elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then - echo "Branch $BRANCH_NAME exists remotely, checking out..." - git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME" - else - echo "ERROR: Branch $BRANCH_NAME should exist for patch release $PROWLER_VERSION" - exit 1 - fi - - name: Verify SDK version in pyproject.toml run: | CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' | tr -d '[:space:]') @@ -323,17 +306,16 @@ jobs: fi echo "✓ api/src/backend/api/v1/views.py version: $CURRENT_API_VERSION" - - name: Checkout release branch for minor release - if: ${{ env.PATCH_VERSION == '0' }} + - name: Verify API version in api/src/backend/api/specs/v1.yaml + if: ${{ env.HAS_API_CHANGES == 'true' }} run: | - echo "Minor release detected (patch = 0), checking out existing branch $BRANCH_NAME..." - if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then - echo "Branch $BRANCH_NAME exists remotely, checking out..." - git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME" - else - echo "ERROR: Branch $BRANCH_NAME should exist for minor release $PROWLER_VERSION. Please create it manually first." + CURRENT_API_VERSION=$(grep '^ version: ' api/src/backend/api/specs/v1.yaml | sed -E 's/ version: ([0-9]+\.[0-9]+\.[0-9]+)/\1/' | tr -d '[:space:]') + API_VERSION_TRIMMED=$(echo "$API_VERSION" | tr -d '[:space:]') + if [ "$CURRENT_API_VERSION" != "$API_VERSION_TRIMMED" ]; then + echo "ERROR: API version mismatch in api/src/backend/api/specs/v1.yaml (expected: '$API_VERSION_TRIMMED', found: '$CURRENT_API_VERSION')" exit 1 fi + echo "✓ api/src/backend/api/specs/v1.yaml version: $CURRENT_API_VERSION" - name: Update API prowler dependency for minor release if: ${{ env.PATCH_VERSION == '0' }} @@ -362,7 +344,7 @@ jobs: - name: Create PR for API dependency update if: ${{ env.PATCH_VERSION == '0' }} - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} commit-message: 'chore(api): update prowler dependency to ${{ env.BRANCH_NAME }} for release ${{ env.PROWLER_VERSION }}' @@ -392,7 +374,7 @@ jobs: no-changelog - name: Create draft release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: tag_name: ${{ env.PROWLER_VERSION }} name: Prowler ${{ env.PROWLER_VERSION }} diff --git a/.github/workflows/sdk-bump-version.yml b/.github/workflows/sdk-bump-version.yml index 0291502ab6..9f25065689 100644 --- a/.github/workflows/sdk-bump-version.yml +++ b/.github/workflows/sdk-bump-version.yml @@ -67,7 +67,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Calculate next minor version run: | @@ -86,13 +86,12 @@ jobs: sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_MINOR_VERSION}\"|" pyproject.toml sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_MINOR_VERSION}\"|" prowler/config/config.py - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env echo "Files modified:" git --no-pager diff - name: Create PR for next minor version to master - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -100,7 +99,7 @@ jobs: commit-message: 'chore(release): Bump version to v${{ env.NEXT_MINOR_VERSION }}' branch: version-bump-to-v${{ env.NEXT_MINOR_VERSION }} title: 'chore(release): Bump version to v${{ env.NEXT_MINOR_VERSION }}' - labels: no-changelog + labels: no-changelog,skip-sync body: | ### Description @@ -111,7 +110,7 @@ jobs: By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. - name: Checkout version branch - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} @@ -135,13 +134,12 @@ jobs: sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${FIRST_PATCH_VERSION}\"|" pyproject.toml sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${FIRST_PATCH_VERSION}\"|" prowler/config/config.py - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env echo "Files modified:" git --no-pager diff - name: Create PR for first patch version to version branch - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -149,7 +147,7 @@ jobs: commit-message: 'chore(release): Bump version to v${{ env.FIRST_PATCH_VERSION }}' branch: version-bump-to-v${{ env.FIRST_PATCH_VERSION }} title: 'chore(release): Bump version to v${{ env.FIRST_PATCH_VERSION }}' - labels: no-changelog + labels: no-changelog,skip-sync body: | ### Description @@ -169,7 +167,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Calculate next patch version run: | @@ -193,13 +191,12 @@ jobs: sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${NEXT_PATCH_VERSION}\"|" pyproject.toml sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${NEXT_PATCH_VERSION}\"|" prowler/config/config.py - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env echo "Files modified:" git --no-pager diff - name: Create PR for next patch version to version branch - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -207,7 +204,7 @@ jobs: commit-message: 'chore(release): Bump version to v${{ env.NEXT_PATCH_VERSION }}' branch: version-bump-to-v${{ env.NEXT_PATCH_VERSION }} title: 'chore(release): Bump version to v${{ env.NEXT_PATCH_VERSION }}' - labels: no-changelog + labels: no-changelog,skip-sync body: | ### Description diff --git a/.github/workflows/sdk-check-duplicate-test-names.yml b/.github/workflows/sdk-check-duplicate-test-names.yml new file mode 100644 index 0000000000..45bd01bc41 --- /dev/null +++ b/.github/workflows/sdk-check-duplicate-test-names.yml @@ -0,0 +1,91 @@ +name: 'SDK: Check Duplicate Test Names' + +on: + pull_request: + branches: + - 'master' + - 'v5.*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-duplicate-test-names: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Check for duplicate test names across providers + run: | + python3 << 'EOF' + import sys + from collections import defaultdict + from pathlib import Path + + def find_duplicate_test_names(): + """Find test files with the same name across different providers.""" + tests_dir = Path("tests/providers") + + if not tests_dir.exists(): + print("tests/providers directory not found") + sys.exit(0) + + # Dictionary: filename -> list of (provider, full_path) + test_files = defaultdict(list) + + # Find all *_test.py files + for test_file in tests_dir.rglob("*_test.py"): + relative_path = test_file.relative_to(tests_dir) + provider = relative_path.parts[0] + filename = test_file.name + test_files[filename].append((provider, str(test_file))) + + # Find duplicates (files appearing in multiple providers) + duplicates = { + filename: locations + for filename, locations in test_files.items() + if len(set(loc[0] for loc in locations)) > 1 + } + + if not duplicates: + print("No duplicate test file names found across providers.") + print("All test names are unique within the repository.") + sys.exit(0) + + # Report duplicates + print("::error::Duplicate test file names found across providers!") + print() + print("=" * 70) + print("DUPLICATE TEST NAMES DETECTED") + print("=" * 70) + print() + print("The following test files have the same name in multiple providers.") + print("Please rename YOUR new test file by adding the provider prefix.") + print() + print("Example: 'kms_service_test.py' -> 'oraclecloud_kms_service_test.py'") + print() + + for filename, locations in sorted(duplicates.items()): + print(f"### {filename}") + print(f" Found in {len(locations)} providers:") + for provider, path in sorted(locations): + print(f" - {provider}: {path}") + print() + print(f" Suggested fix: Rename your new file to '_{filename}'") + print() + + print("=" * 70) + print() + print("See: tests/providers/TESTING.md for naming conventions.") + sys.exit(1) + + if __name__ == "__main__": + find_duplicate_test_names() + EOF diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml index fb7c13609b..004ca0b7eb 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -5,22 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'poetry.lock' - - '.github/workflows/sdk-code-quality.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'poetry.lock' - - '.github/workflows/sdk-code-quality.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -28,6 +16,7 @@ concurrency: jobs: sdk-code-quality: + if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -42,13 +31,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: ./** files_ignore: | + .github/** prowler/CHANGELOG.md docs/** permissions/** @@ -56,6 +47,7 @@ jobs: ui/** dashboard/** mcp_server/** + skills/** README.md mkdocs.yml .backportrc.json @@ -64,6 +56,7 @@ jobs: examples/** .gitignore contrib/** + **/AGENTS.md - name: Install Poetry if: steps.check-changes.outputs.any_changed == 'true' @@ -71,7 +64,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} cache: 'poetry' @@ -88,11 +81,11 @@ jobs: - name: Lint with flake8 if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude contrib,ui,api + run: poetry run flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude contrib,ui,api,skills - name: Check format with black if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run black --exclude api ui --check . + run: poetry run black --exclude "api|ui|skills" --check . - name: Lint with pylint if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/sdk-codeql.yml b/.github/workflows/sdk-codeql.yml index ee9ba9c396..a45f633567 100644 --- a/.github/workflows/sdk-codeql.yml +++ b/.github/workflows/sdk-codeql.yml @@ -31,7 +31,8 @@ concurrency: cancel-in-progress: true jobs: - analyze: + sdk-analyze: + if: github.repository == 'prowler-cloud/prowler' name: CodeQL Security Analysis runs-on: ubuntu-latest timeout-minutes: 30 @@ -48,15 +49,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/sdk-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/sdk-container-build-push.yml b/.github/workflows/sdk-container-build-push.yml index d66c9efaa2..d510ec8568 100644 --- a/.github/workflows/sdk-container-build-push.yml +++ b/.github/workflows/sdk-container-build-push.yml @@ -16,6 +16,12 @@ on: release: types: - 'published' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (e.g., 5.14.0)' + required: true + type: string permissions: contents: read @@ -44,25 +50,21 @@ env: AWS_REGION: us-east-1 jobs: - container-build-push: + setup: if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - packages: write + timeout-minutes: 5 outputs: prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }} prowler_version_major: ${{ steps.get-prowler-version.outputs.prowler_version_major }} - env: - POETRY_VIRTUALENVS_CREATE: 'false' - + latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }} + stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }} steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.PYTHON_VERSION }} @@ -76,28 +78,26 @@ jobs: run: | PROWLER_VERSION="$(poetry version -s 2>/dev/null)" echo "prowler_version=${PROWLER_VERSION}" >> "${GITHUB_OUTPUT}" - echo "PROWLER_VERSION=${PROWLER_VERSION}" >> "${GITHUB_ENV}" # Extract major version PROWLER_VERSION_MAJOR="${PROWLER_VERSION%%.*}" echo "prowler_version_major=${PROWLER_VERSION_MAJOR}" >> "${GITHUB_OUTPUT}" - echo "PROWLER_VERSION_MAJOR=${PROWLER_VERSION_MAJOR}" >> "${GITHUB_ENV}" # Set version-specific tags case ${PROWLER_VERSION_MAJOR} in 3) - echo "LATEST_TAG=v3-latest" >> "${GITHUB_ENV}" - echo "STABLE_TAG=v3-stable" >> "${GITHUB_ENV}" + echo "latest_tag=v3-latest" >> "${GITHUB_OUTPUT}" + echo "stable_tag=v3-stable" >> "${GITHUB_OUTPUT}" echo "✓ Prowler v3 detected - tags: v3-latest, v3-stable" ;; 4) - echo "LATEST_TAG=v4-latest" >> "${GITHUB_ENV}" - echo "STABLE_TAG=v4-stable" >> "${GITHUB_ENV}" + echo "latest_tag=v4-latest" >> "${GITHUB_OUTPUT}" + echo "stable_tag=v4-stable" >> "${GITHUB_OUTPUT}" echo "✓ Prowler v4 detected - tags: v4-latest, v4-stable" ;; 5) - echo "LATEST_TAG=latest" >> "${GITHUB_ENV}" - echo "STABLE_TAG=stable" >> "${GITHUB_ENV}" + echo "latest_tag=latest" >> "${GITHUB_OUTPUT}" + echo "stable_tag=stable" >> "${GITHUB_OUTPUT}" echo "✓ Prowler v5 detected - tags: latest, stable" ;; *) @@ -106,6 +106,53 @@ jobs: ;; esac + notify-release-started: + if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + message-ts: ${{ steps.slack-notification.outputs.ts }} + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Notify container push started + id: slack-notification + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + COMPONENT: SDK + RELEASE_TAG: ${{ needs.setup.outputs.prowler_version }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-started.json" + + container-build-push: + needs: [setup, notify-release-started] + if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped') + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + timeout-minutes: 45 + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Login to DockerHub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: @@ -122,42 +169,119 @@ jobs: AWS_REGION: ${{ env.AWS_REGION }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build and push SDK container (latest) + - name: Build and push SDK container for ${{ matrix.arch }} + id: container-push + if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + platforms: ${{ matrix.platform }} + tags: | + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + # Create and push multi-architecture manifest + create-manifest: + needs: [setup, container-build-push] + if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' + runs-on: ubuntu-latest + + steps: + - name: Login to DockerHub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to Public ECR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: public.ecr.aws + username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} + password: ${{ secrets.PUBLIC_ECR_AWS_SECRET_ACCESS_KEY }} + env: + AWS_REGION: ${{ env.AWS_REGION }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Create and push manifests for push event if: github.event_name == 'push' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: . - file: ${{ env.DOCKERFILE_PATH }} - push: true - tags: | - ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }} - ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} - cache-from: type=gha - cache-to: type=gha,mode=max + run: | + docker buildx imagetools create \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }} \ + -t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }} \ + -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-arm64 - - name: Build and push SDK container (release) - if: github.event_name == 'release' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + - name: Create and push manifests for release event + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + run: | + docker buildx imagetools create \ + -t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.prowler_version }} \ + -t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.stable_tag }} \ + -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.prowler_version }} \ + -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.stable_tag }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.prowler_version }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.stable_tag }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-arm64 + + - name: Install regctl + if: always() + uses: regclient/actions/regctl-installer@main + + - name: Cleanup intermediate architecture tags + if: always() + run: | + echo "Cleaning up intermediate tags..." + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-amd64" || true + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-arm64" || true + echo "Cleanup completed" + + notify-release-completed: + if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + needs: [setup, notify-release-started, container-build-push, create-manifest] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Determine overall outcome + id: outcome + run: | + if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then + echo "outcome=success" >> $GITHUB_OUTPUT + else + echo "outcome=failure" >> $GITHUB_OUTPUT + fi + + - name: Notify container push completed + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }} + COMPONENT: SDK + RELEASE_TAG: ${{ needs.setup.outputs.prowler_version }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} with: - context: . - file: ${{ env.DOCKERFILE_PATH }} - push: true - tags: | - ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.PROWLER_VERSION }} - ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.STABLE_TAG }} - ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.PROWLER_VERSION }} - ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ env.STABLE_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.PROWLER_VERSION }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} - cache-from: type=gha - cache-to: type=gha,mode=max + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" + step-outcome: ${{ steps.outcome.outputs.outcome }} + update-ts: ${{ needs.notify-release-started.outputs.message-ts }} dispatch-v3-deployment: - if: needs.container-build-push.outputs.prowler_version_major == '3' - needs: container-build-push + needs: [setup, container-build-push] + if: always() && needs.setup.outputs.prowler_version_major == '3' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -170,7 +294,7 @@ jobs: - name: Dispatch v3 deployment (latest) if: github.event_name == 'push' - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} repository: ${{ secrets.DISPATCH_OWNER }}/${{ secrets.DISPATCH_REPO }} @@ -179,9 +303,9 @@ jobs: - name: Dispatch v3 deployment (release) if: github.event_name == 'release' - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} repository: ${{ secrets.DISPATCH_OWNER }}/${{ secrets.DISPATCH_REPO }} event-type: dispatch - client-payload: '{"version":"release","tag":"${{ needs.container-build-push.outputs.prowler_version }}"}' + client-payload: '{"version":"release","tag":"${{ needs.setup.outputs.prowler_version }}"}' diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 49ea98fc33..7a0323c216 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -5,20 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'Dockerfile' - - '.github/workflows/sdk-container-checks.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'Dockerfile' - - '.github/workflows/sdk-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -29,6 +19,7 @@ env: jobs: sdk-dockerfile-lint: + if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -36,11 +27,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: Dockerfile @@ -52,7 +43,17 @@ jobs: ignore: DL3013 sdk-container-build-and-scan: - runs-on: ubuntu-latest + if: github.repository == 'prowler-cloud/prowler' + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 timeout-minutes: 30 permissions: contents: read @@ -61,13 +62,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: ./** files_ignore: | + .github/** prowler/CHANGELOG.md docs/** permissions/** @@ -75,6 +78,7 @@ jobs: ui/** dashboard/** mcp_server/** + skills/** README.md mkdocs.yml .backportrc.json @@ -83,27 +87,29 @@ jobs: examples/** .gitignore contrib/** + **/AGENTS.md - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build SDK container + - name: Build SDK container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . push: false load: true - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} - - name: Scan SDK container with Trivy - if: github.repository == 'prowler-cloud/prowler' && steps.check-changes.outputs.any_changed == 'true' + - name: Scan SDK container with Trivy for ${{ matrix.arch }} + if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }} + image-tag: ${{ github.sha }}-${{ matrix.arch }} fail-on-critical: 'false' severity: 'CRITICAL' diff --git a/.github/workflows/sdk-pypi-release.yml b/.github/workflows/sdk-pypi-release.yml index 0f74ba054c..68a986d37a 100644 --- a/.github/workflows/sdk-pypi-release.yml +++ b/.github/workflows/sdk-pypi-release.yml @@ -59,13 +59,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install Poetry run: pipx install poetry==2.1.1 - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.PYTHON_VERSION }} cache: 'poetry' @@ -91,13 +91,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install Poetry run: pipx install poetry==2.1.1 - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.PYTHON_VERSION }} cache: 'poetry' diff --git a/.github/workflows/sdk-refresh-aws-services-regions.yml b/.github/workflows/sdk-refresh-aws-services-regions.yml index 4df220fc49..25839f2631 100644 --- a/.github/workflows/sdk-refresh-aws-services-regions.yml +++ b/.github/workflows/sdk-refresh-aws-services-regions.yml @@ -25,12 +25,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: 'master' - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' @@ -39,7 +39,7 @@ jobs: run: pip install boto3 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 with: aws-region: ${{ env.AWS_REGION }} role-to-assume: ${{ secrets.DEV_IAM_ROLE_ARN }} @@ -50,7 +50,7 @@ jobs: - name: Create pull request id: create-pr - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>' diff --git a/.github/workflows/sdk-refresh-oci-regions.yml b/.github/workflows/sdk-refresh-oci-regions.yml new file mode 100644 index 0000000000..337e3e2cda --- /dev/null +++ b/.github/workflows/sdk-refresh-oci-regions.yml @@ -0,0 +1,95 @@ +name: 'SDK: Refresh OCI Regions' + +on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 09:00 UTC + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + PYTHON_VERSION: '3.12' + +jobs: + refresh-oci-regions: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + pull-requests: write + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: 'master' + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install dependencies + run: pip install oci + + - name: Update OCI regions + env: + OCI_CLI_USER: ${{ secrets.E2E_OCI_USER_ID }} + OCI_CLI_FINGERPRINT: ${{ secrets.E2E_OCI_FINGERPRINT }} + OCI_CLI_TENANCY: ${{ secrets.E2E_OCI_TENANCY_ID }} + OCI_CLI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }} + OCI_CLI_REGION: ${{ secrets.E2E_OCI_REGION }} + run: python util/update_oci_regions.py + + - name: Create pull request + id: create-pr + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>' + committer: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' + commit-message: 'feat(oraclecloud): update commercial regions' + branch: 'oci-regions-update-${{ github.run_number }}' + title: 'feat(oraclecloud): Update commercial regions' + labels: | + status/waiting-for-revision + severity/low + provider/oraclecloud + no-changelog + body: | + ### Description + + Automated update of OCI commercial regions from the official Oracle Cloud Infrastructure Identity service. + + **Trigger:** ${{ github.event_name == 'schedule' && 'Scheduled (weekly)' || github.event_name == 'workflow_dispatch' && 'Manual' || 'Workflow update' }} + **Run:** [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + ### Changes + + This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`). + + - Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged + - Region display names are mapped from Oracle's official documentation + + ### Checklist + + - [x] This is an automated update from OCI official sources + - [x] Government regions (us-langley-1, us-luke-1) preserved + - [x] No manual review of region data required + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + - name: PR creation result + run: | + if [[ "${{ steps.create-pr.outputs.pull-request-number }}" ]]; then + echo "✓ Pull request #${{ steps.create-pr.outputs.pull-request-number }} created successfully" + echo "URL: ${{ steps.create-pr.outputs.pull-request-url }}" + else + echo "✓ No changes detected - OCI regions are up to date" + fi diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index e5e8980f3f..01f94d842a 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -5,22 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'poetry.lock' - - '.github/workflows/sdk-security.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'poetry.lock' - - '.github/workflows/sdk-security.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -28,6 +16,7 @@ concurrency: jobs: sdk-security-scans: + if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -35,13 +24,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: + ./** + .github/workflows/sdk-security.yml files_ignore: | + .github/** prowler/CHANGELOG.md docs/** permissions/** @@ -49,6 +42,7 @@ jobs: ui/** dashboard/** mcp_server/** + skills/** README.md mkdocs.yml .backportrc.json @@ -57,6 +51,7 @@ jobs: examples/** .gitignore contrib/** + **/AGENTS.md - name: Install Poetry if: steps.check-changes.outputs.any_changed == 'true' @@ -64,7 +59,7 @@ jobs: - name: Set up Python 3.12 if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.12' cache: 'poetry' @@ -79,7 +74,7 @@ jobs: - name: Security scan with Safety if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check --ignore 70612 -r pyproject.toml + run: poetry run safety check -r pyproject.toml - name: Dead code detection with Vulture if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index c8252bec02..20e4ebd061 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -5,22 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'poetry.lock' - - '.github/workflows/sdk-tests.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'poetry.lock' - - '.github/workflows/sdk-tests.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -28,6 +16,7 @@ concurrency: jobs: sdk-tests: + if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 120 permissions: @@ -42,13 +31,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: ./** files_ignore: | + .github/** prowler/CHANGELOG.md docs/** permissions/** @@ -56,6 +47,7 @@ jobs: ui/** dashboard/** mcp_server/** + skills/** README.md mkdocs.yml .backportrc.json @@ -64,6 +56,7 @@ jobs: examples/** .gitignore contrib/** + **/AGENTS.md - name: Install Poetry if: steps.check-changes.outputs.any_changed == 'true' @@ -71,7 +64,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} cache: 'poetry' @@ -84,20 +77,121 @@ jobs: - name: Check if AWS files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-aws - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/aws/** ./tests/**/aws/** ./poetry.lock + - name: Resolve AWS services under test + if: steps.changed-aws.outputs.any_changed == 'true' + id: aws-services + shell: bash + run: | + python3 <<'PY' + import os + from pathlib import Path + + dependents = { + "acm": ["elb"], + "autoscaling": ["dynamodb"], + "awslambda": ["ec2", "inspector2"], + "backup": ["dynamodb", "ec2", "rds"], + "cloudfront": ["shield"], + "cloudtrail": ["awslambda", "cloudwatch"], + "cloudwatch": ["bedrock"], + "ec2": ["dlm", "dms", "elbv2", "emr", "inspector2", "rds", "redshift", "route53", "shield", "ssm"], + "ecr": ["inspector2"], + "elb": ["shield"], + "elbv2": ["shield"], + "globalaccelerator": ["shield"], + "iam": ["bedrock", "cloudtrail", "cloudwatch", "codebuild"], + "kafka": ["firehose"], + "kinesis": ["firehose"], + "kms": ["kafka"], + "organizations": ["iam", "servicecatalog"], + "route53": ["shield"], + "s3": ["bedrock", "cloudfront", "cloudtrail", "macie"], + "ssm": ["ec2"], + "vpc": ["awslambda", "ec2", "efs", "elasticache", "neptune", "networkfirewall", "rds", "redshift", "workspaces"], + "waf": ["elbv2"], + "wafv2": ["cognito", "elbv2"], + } + + changed_raw = """${{ steps.changed-aws.outputs.all_changed_files }}""" + # all_changed_files is space-separated, not newline-separated + # Strip leading "./" if present for consistent path handling + changed_files = [Path(f.lstrip("./")) for f in changed_raw.split() if f] + + services = set() + run_all = False + + for path in changed_files: + path_str = path.as_posix() + parts = path.parts + if path_str.startswith("prowler/providers/aws/services/"): + if len(parts) > 4 and "." not in parts[4]: + services.add(parts[4]) + else: + run_all = True + elif path_str.startswith("tests/providers/aws/services/"): + if len(parts) > 4 and "." not in parts[4]: + services.add(parts[4]) + else: + run_all = True + elif path_str.startswith("prowler/providers/aws/") or path_str.startswith("tests/providers/aws/"): + run_all = True + + # Expand with direct dependent services (one level only) + # We only test services that directly depend on the changed services, + # not transitive dependencies (services that depend on dependents) + original_services = set(services) + for svc in original_services: + for dep in dependents.get(svc, []): + services.add(dep) + + if run_all or not services: + run_all = True + services = set() + + service_paths = " ".join(sorted(f"tests/providers/aws/services/{svc}" for svc in services)) + + output_lines = [ + f"run_all={'true' if run_all else 'false'}", + f"services={' '.join(sorted(services))}", + f"service_paths={service_paths}", + ] + + with open(os.environ["GITHUB_OUTPUT"], "a") as gh_out: + for line in output_lines: + gh_out.write(line + "\n") + + print(f"AWS changed files (filtered): {changed_raw or 'none'}") + print(f"Run all AWS tests: {run_all}") + if services: + print(f"AWS service test paths: {service_paths}") + else: + print("AWS service test paths: none detected") + PY + - name: Run AWS tests if: steps.changed-aws.outputs.any_changed == 'true' - run: poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws + run: | + echo "AWS run_all=${{ steps.aws-services.outputs.run_all }}" + echo "AWS service_paths='${{ steps.aws-services.outputs.service_paths }}'" + + if [ "${{ steps.aws-services.outputs.run_all }}" = "true" ]; then + poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws + elif [ -z "${{ steps.aws-services.outputs.service_paths }}" ]; then + echo "No AWS service paths detected; skipping AWS tests." + else + poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${{ steps.aws-services.outputs.service_paths }} + fi - name: Upload AWS coverage to Codecov if: steps.changed-aws.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -108,7 +202,7 @@ jobs: - name: Check if Azure files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-azure - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/azure/** @@ -121,7 +215,7 @@ jobs: - name: Upload Azure coverage to Codecov if: steps.changed-azure.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -132,7 +226,7 @@ jobs: - name: Check if GCP files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-gcp - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/gcp/** @@ -145,7 +239,7 @@ jobs: - name: Upload GCP coverage to Codecov if: steps.changed-gcp.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -156,7 +250,7 @@ jobs: - name: Check if Kubernetes files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-kubernetes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/kubernetes/** @@ -169,7 +263,7 @@ jobs: - name: Upload Kubernetes coverage to Codecov if: steps.changed-kubernetes.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -180,7 +274,7 @@ jobs: - name: Check if GitHub files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-github - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/github/** @@ -193,7 +287,7 @@ jobs: - name: Upload GitHub coverage to Codecov if: steps.changed-github.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -204,7 +298,7 @@ jobs: - name: Check if NHN files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-nhn - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/nhn/** @@ -217,7 +311,7 @@ jobs: - name: Upload NHN coverage to Codecov if: steps.changed-nhn.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -228,7 +322,7 @@ jobs: - name: Check if M365 files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-m365 - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/m365/** @@ -241,7 +335,7 @@ jobs: - name: Upload M365 coverage to Codecov if: steps.changed-m365.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -252,7 +346,7 @@ jobs: - name: Check if IaC files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-iac - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/iac/** @@ -265,7 +359,7 @@ jobs: - name: Upload IaC coverage to Codecov if: steps.changed-iac.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -276,7 +370,7 @@ jobs: - name: Check if MongoDB Atlas files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-mongodbatlas - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/mongodbatlas/** @@ -289,7 +383,7 @@ jobs: - name: Upload MongoDB Atlas coverage to Codecov if: steps.changed-mongodbatlas.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -300,7 +394,7 @@ jobs: - name: Check if OCI files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-oraclecloud - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/**/oraclecloud/** @@ -313,18 +407,42 @@ jobs: - name: Upload OCI coverage to Codecov if: steps.changed-oraclecloud.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: flags: prowler-py${{ matrix.python-version }}-oraclecloud files: ./oraclecloud_coverage.xml + # OpenStack Provider + - name: Check if OpenStack files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-openstack + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + with: + files: | + ./prowler/**/openstack/** + ./tests/**/openstack/** + ./poetry.lock + + - name: Run OpenStack tests + if: steps.changed-openstack.outputs.any_changed == 'true' + run: poetry run pytest -n auto --cov=./prowler/providers/openstack --cov-report=xml:openstack_coverage.xml tests/providers/openstack + + - name: Upload OpenStack coverage to Codecov + if: steps.changed-openstack.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-openstack + files: ./openstack_coverage.xml + # Lib - name: Check if Lib files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-lib - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/lib/** @@ -337,7 +455,7 @@ jobs: - name: Upload Lib coverage to Codecov if: steps.changed-lib.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -348,7 +466,7 @@ jobs: - name: Check if Config files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-config - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: | ./prowler/config/** @@ -361,7 +479,7 @@ jobs: - name: Upload Config coverage to Codecov if: steps.changed-config.outputs.any_changed == 'true' - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: diff --git a/.github/workflows/test-impact-analysis.yml b/.github/workflows/test-impact-analysis.yml new file mode 100644 index 0000000000..7f750ede2b --- /dev/null +++ b/.github/workflows/test-impact-analysis.yml @@ -0,0 +1,112 @@ +name: Test Impact Analysis + +on: + workflow_call: + outputs: + run-all: + description: "Whether to run all tests (critical path changed)" + value: ${{ jobs.analyze.outputs.run-all }} + sdk-tests: + description: "SDK test paths to run" + value: ${{ jobs.analyze.outputs.sdk-tests }} + api-tests: + description: "API test paths to run" + value: ${{ jobs.analyze.outputs.api-tests }} + ui-e2e: + description: "UI E2E test paths to run" + value: ${{ jobs.analyze.outputs.ui-e2e }} + modules: + description: "Comma-separated list of affected modules" + value: ${{ jobs.analyze.outputs.modules }} + has-tests: + description: "Whether there are any tests to run" + value: ${{ jobs.analyze.outputs.has-tests }} + has-sdk-tests: + description: "Whether there are SDK tests to run" + value: ${{ jobs.analyze.outputs.has-sdk-tests }} + has-api-tests: + description: "Whether there are API tests to run" + value: ${{ jobs.analyze.outputs.has-api-tests }} + has-ui-e2e: + description: "Whether there are UI E2E tests to run" + value: ${{ jobs.analyze.outputs.has-ui-e2e }} + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run-all: ${{ steps.impact.outputs.run-all }} + sdk-tests: ${{ steps.impact.outputs.sdk-tests }} + api-tests: ${{ steps.impact.outputs.api-tests }} + ui-e2e: ${{ steps.impact.outputs.ui-e2e }} + modules: ${{ steps.impact.outputs.modules }} + has-tests: ${{ steps.impact.outputs.has-tests }} + has-sdk-tests: ${{ steps.set-flags.outputs.has-sdk-tests }} + has-api-tests: ${{ steps.set-flags.outputs.has-api-tests }} + has-ui-e2e: ${{ steps.set-flags.outputs.has-ui-e2e }} + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + + - name: Setup Python + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: '3.12' + + - name: Install PyYAML + run: pip install pyyaml + + - name: Analyze test impact + id: impact + run: | + echo "Changed files:" + echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' + echo "" + python .github/scripts/test-impact.py ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Set convenience flags + id: set-flags + run: | + if [[ -n "${{ steps.impact.outputs.sdk-tests }}" ]]; then + echo "has-sdk-tests=true" >> $GITHUB_OUTPUT + else + echo "has-sdk-tests=false" >> $GITHUB_OUTPUT + fi + + if [[ -n "${{ steps.impact.outputs.api-tests }}" ]]; then + echo "has-api-tests=true" >> $GITHUB_OUTPUT + else + echo "has-api-tests=false" >> $GITHUB_OUTPUT + fi + + if [[ -n "${{ steps.impact.outputs.ui-e2e }}" ]]; then + echo "has-ui-e2e=true" >> $GITHUB_OUTPUT + else + echo "has-ui-e2e=false" >> $GITHUB_OUTPUT + fi + + - name: Summary + run: | + echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ steps.impact.outputs.run-all }}" == "true" ]]; then + echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY + else + echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY + echo "\`${{ steps.impact.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY + echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| SDK Tests | \`${{ steps.impact.outputs.sdk-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| API Tests | \`${{ steps.impact.outputs.api-tests || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| UI E2E | \`${{ steps.impact.outputs.ui-e2e || 'none' }}\` |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/ui-bump-version.yml b/.github/workflows/ui-bump-version.yml new file mode 100644 index 0000000000..7c576eed55 --- /dev/null +++ b/.github/workflows/ui-bump-version.yml @@ -0,0 +1,221 @@ +name: 'UI: Bump Version' + +on: + release: + types: + - 'published' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.release.tag_name }} + cancel-in-progress: false + +env: + PROWLER_VERSION: ${{ github.event.release.tag_name }} + BASE_BRANCH: master + +jobs: + detect-release-type: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + is_minor: ${{ steps.detect.outputs.is_minor }} + is_patch: ${{ steps.detect.outputs.is_patch }} + major_version: ${{ steps.detect.outputs.major_version }} + minor_version: ${{ steps.detect.outputs.minor_version }} + patch_version: ${{ steps.detect.outputs.patch_version }} + steps: + - name: Detect release type and parse version + id: detect + run: | + if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + MAJOR_VERSION=${BASH_REMATCH[1]} + MINOR_VERSION=${BASH_REMATCH[2]} + PATCH_VERSION=${BASH_REMATCH[3]} + + echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}" + echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}" + echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}" + + if (( MAJOR_VERSION != 5 )); then + echo "::error::Releasing another Prowler major version, aborting..." + exit 1 + fi + + if (( PATCH_VERSION == 0 )); then + echo "is_minor=true" >> "${GITHUB_OUTPUT}" + echo "is_patch=false" >> "${GITHUB_OUTPUT}" + echo "✓ Minor release detected: $PROWLER_VERSION" + else + echo "is_minor=false" >> "${GITHUB_OUTPUT}" + echo "is_patch=true" >> "${GITHUB_OUTPUT}" + echo "✓ Patch release detected: $PROWLER_VERSION" + fi + else + echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)" + exit 1 + fi + + bump-minor-version: + needs: detect-release-type + if: needs.detect-release-type.outputs.is_minor == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Calculate next minor version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + + NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0 + echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}" + + echo "Current version: $PROWLER_VERSION" + echo "Next minor version: $NEXT_MINOR_VERSION" + + - name: Bump UI version in .env for master + run: | + set -e + + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for next minor version to master + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: master + commit-message: 'chore(ui): Bump version to v${{ env.NEXT_MINOR_VERSION }}' + branch: ui-version-bump-to-v${{ env.NEXT_MINOR_VERSION }} + title: 'chore(ui): Bump version to v${{ env.NEXT_MINOR_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler UI version to v${{ env.NEXT_MINOR_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### Files Updated + - `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_VERSION` + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + - name: Checkout version branch + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} + + - name: Calculate first patch version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + + FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1 + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "First patch version: $FIRST_PATCH_VERSION" + echo "Version branch: $VERSION_BRANCH" + + - name: Bump UI version in .env for version branch + run: | + set -e + + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for first patch version to version branch + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: ${{ env.VERSION_BRANCH }} + commit-message: 'chore(ui): Bump version to v${{ env.FIRST_PATCH_VERSION }}' + branch: ui-version-bump-to-v${{ env.FIRST_PATCH_VERSION }} + title: 'chore(ui): Bump version to v${{ env.FIRST_PATCH_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler UI version to v${{ env.FIRST_PATCH_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### Files Updated + - `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_VERSION` + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. + + bump-patch-version: + needs: detect-release-type + if: needs.detect-release-type.outputs.is_patch == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Calculate next patch version + run: | + MAJOR_VERSION=${{ needs.detect-release-type.outputs.major_version }} + MINOR_VERSION=${{ needs.detect-release-type.outputs.minor_version }} + PATCH_VERSION=${{ needs.detect-release-type.outputs.patch_version }} + + NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1)) + VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION} + + echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}" + echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}" + + echo "Current version: $PROWLER_VERSION" + echo "Next patch version: $NEXT_PATCH_VERSION" + echo "Target branch: $VERSION_BRANCH" + + - name: Bump UI version in .env for version branch + run: | + set -e + + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env + + echo "Files modified:" + git --no-pager diff + + - name: Create PR for next patch version to version branch + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + with: + author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> + token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + base: ${{ env.VERSION_BRANCH }} + commit-message: 'chore(ui): Bump version to v${{ env.NEXT_PATCH_VERSION }}' + branch: ui-version-bump-to-v${{ env.NEXT_PATCH_VERSION }} + title: 'chore(ui): Bump version to v${{ env.NEXT_PATCH_VERSION }}' + labels: no-changelog,skip-sync + body: | + ### Description + + Bump Prowler UI version to v${{ env.NEXT_PATCH_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}. + + ### Files Updated + - `.env`: `NEXT_PUBLIC_PROWLER_RELEASE_VERSION` + + ### License + + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. diff --git a/.github/workflows/ui-codeql.yml b/.github/workflows/ui-codeql.yml index d51b904959..581e798189 100644 --- a/.github/workflows/ui-codeql.yml +++ b/.github/workflows/ui-codeql.yml @@ -27,7 +27,8 @@ concurrency: cancel-in-progress: true jobs: - analyze: + ui-analyze: + if: github.repository == 'prowler-cloud/prowler' name: CodeQL Security Analysis runs-on: ubuntu-latest timeout-minutes: 30 @@ -44,15 +45,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/ui-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index 3f4da11606..6f8c232185 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -10,6 +10,12 @@ on: release: types: - 'published' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (e.g., 5.14.0)' + required: true + type: string permissions: contents: read @@ -21,7 +27,7 @@ concurrency: env: # Tags LATEST_TAG: latest - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} STABLE_TAG: stable WORKING_DIRECTORY: ./ui @@ -44,9 +50,44 @@ jobs: id: set-short-sha run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - container-build-push: + notify-release-started: + if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') needs: setup runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + message-ts: ${{ steps.slack-notification.outputs.ts }} + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Notify container push started + id: slack-notification + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + COMPONENT: UI + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + with: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-started.json" + + container-build-push: + needs: [setup, notify-release-started] + if: always() && needs.setup.result == 'success' && (needs.notify-release-started.result == 'success' || needs.notify-release-started.result == 'skipped') + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 timeout-minutes: 30 permissions: contents: read @@ -54,7 +95,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Login to DockerHub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 @@ -63,41 +104,107 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build and push UI container (latest) + - name: Build and push UI container for ${{ matrix.arch }} + id: container-push + if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: ${{ env.WORKING_DIRECTORY }} + build-args: | + NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }} + NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} + push: true + platforms: ${{ matrix.platform }} + tags: | + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + # Create and push multi-architecture manifest + create-manifest: + needs: [setup, container-build-push] + if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' + runs-on: ubuntu-latest + + steps: + - name: Login to DockerHub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Create and push manifests for push event if: github.event_name == 'push' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: ${{ env.WORKING_DIRECTORY }} - build-args: | - NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ needs.setup.outputs.short-sha }} - NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} - push: true - tags: | - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + run: | + docker buildx imagetools create \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64 - - name: Build and push UI container (release) - if: github.event_name == 'release' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + - name: Create and push manifests for release event + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + run: | + docker buildx imagetools create \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.RELEASE_TAG }} \ + -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64 \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64 + + - name: Install regctl + if: always() + uses: regclient/actions/regctl-installer@main + + - name: Cleanup intermediate architecture tags + if: always() + run: | + echo "Cleaning up intermediate tags..." + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-amd64" || true + regctl tag delete "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-arm64" || true + echo "Cleanup completed" + + notify-release-completed: + if: always() && needs.notify-release-started.result == 'success' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + needs: [setup, notify-release-started, container-build-push, create-manifest] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Determine overall outcome + id: outcome + run: | + if [[ "${{ needs.container-build-push.result }}" == "success" && "${{ needs.create-manifest.result }}" == "success" ]]; then + echo "outcome=success" >> $GITHUB_OUTPUT + else + echo "outcome=failure" >> $GITHUB_OUTPUT + fi + + - name: Notify container push completed + uses: ./.github/actions/slack-notification + env: + SLACK_CHANNEL_ID: ${{ secrets.SLACK_PLATFORM_DEPLOYMENTS }} + MESSAGE_TS: ${{ needs.notify-release-started.outputs.message-ts }} + COMPONENT: UI + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} with: - context: ${{ env.WORKING_DIRECTORY }} - build-args: | - NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${{ env.RELEASE_TAG }} - NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} - push: true - tags: | - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.RELEASE_TAG }} - ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} - cache-from: type=gha - cache-to: type=gha,mode=max + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: "./.github/scripts/slack-messages/container-release-completed.json" + step-outcome: ${{ steps.outcome.outputs.outcome }} + update-ts: ${{ needs.notify-release-started.outputs.message-ts }} trigger-deployment: - if: github.event_name == 'push' needs: [setup, container-build-push] + if: always() && github.event_name == 'push' && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -105,7 +212,7 @@ jobs: steps: - name: Trigger UI deployment - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} repository: ${{ secrets.CLOUD_DISPATCH }} diff --git a/.github/workflows/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index 8675fff7b8..4c027f1cb2 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -5,16 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'ui/**' - - '.github/workflows/ui-container-checks.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'ui/**' - - '.github/workflows/ui-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -26,6 +20,7 @@ env: jobs: ui-dockerfile-lint: + if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -33,11 +28,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: files: ui/Dockerfile @@ -49,7 +44,17 @@ jobs: ignore: DL3018 ui-container-build-and-scan: - runs-on: ubuntu-latest + if: github.repository == 'prowler-cloud/prowler' + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 timeout-minutes: 30 permissions: contents: read @@ -58,21 +63,23 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: ui/** files_ignore: | ui/CHANGELOG.md ui/README.md + ui/AGENTS.md - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build UI container + - name: Build UI container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: @@ -80,17 +87,18 @@ jobs: target: prod push: false load: true - tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}-${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} build-args: | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51LwpXXXX - - name: Scan UI container with Trivy - if: github.repository == 'prowler-cloud/prowler' && steps.check-changes.outputs.any_changed == 'true' + - name: Scan UI container with Trivy for ${{ matrix.arch }} + if: steps.check-changes.outputs.any_changed == 'true' uses: ./.github/actions/trivy-scan with: image-name: ${{ env.IMAGE_NAME }} - image-tag: ${{ github.sha }} + image-tag: ${{ github.sha }}-${{ matrix.arch }} fail-on-critical: 'false' severity: 'CRITICAL' diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml new file mode 100644 index 0000000000..9936737320 --- /dev/null +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -0,0 +1,249 @@ +name: UI - E2E Tests (Optimized) + +# This is an optimized version that runs only relevant E2E tests +# based on changed files. Falls back to running all tests if +# critical paths are changed or if impact analysis fails. + +on: + pull_request: + branches: + - master + - "v5.*" + paths: + - '.github/workflows/ui-e2e-tests-v2.yml' + - '.github/test-impact.yml' + - 'ui/**' + - 'api/**' # API changes can affect UI E2E + +jobs: + # First, analyze which tests need to run + impact-analysis: + if: github.repository == 'prowler-cloud/prowler' + uses: ./.github/workflows/test-impact-analysis.yml + + # Run E2E tests based on impact analysis + e2e-tests: + needs: impact-analysis + if: | + github.repository == 'prowler-cloud/prowler' && + (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') + runs-on: ubuntu-latest + env: + AUTH_SECRET: 'fallback-ci-secret-for-testing' + AUTH_TRUST_HOST: true + NEXTAUTH_URL: 'http://localhost:3000' + NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' + E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} + E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} + E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} + E2E_AWS_PROVIDER_ACCESS_KEY: ${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }} + E2E_AWS_PROVIDER_SECRET_KEY: ${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }} + E2E_AWS_PROVIDER_ROLE_ARN: ${{ secrets.E2E_AWS_PROVIDER_ROLE_ARN }} + E2E_AZURE_SUBSCRIPTION_ID: ${{ secrets.E2E_AZURE_SUBSCRIPTION_ID }} + E2E_AZURE_CLIENT_ID: ${{ secrets.E2E_AZURE_CLIENT_ID }} + E2E_AZURE_SECRET_ID: ${{ secrets.E2E_AZURE_SECRET_ID }} + E2E_AZURE_TENANT_ID: ${{ secrets.E2E_AZURE_TENANT_ID }} + E2E_M365_DOMAIN_ID: ${{ secrets.E2E_M365_DOMAIN_ID }} + E2E_M365_CLIENT_ID: ${{ secrets.E2E_M365_CLIENT_ID }} + E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }} + E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }} + E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }} + E2E_KUBERNETES_CONTEXT: 'kind-kind' + E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config + E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }} + E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_BASE64_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_BASE64_APP_PRIVATE_KEY }} + E2E_GITHUB_USERNAME: ${{ secrets.E2E_GITHUB_USERNAME }} + E2E_GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_PERSONAL_ACCESS_TOKEN }} + E2E_GITHUB_ORGANIZATION: ${{ secrets.E2E_GITHUB_ORGANIZATION }} + E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN }} + E2E_ORGANIZATION_ID: ${{ secrets.E2E_ORGANIZATION_ID }} + E2E_OCI_TENANCY_ID: ${{ secrets.E2E_OCI_TENANCY_ID }} + E2E_OCI_USER_ID: ${{ secrets.E2E_OCI_USER_ID }} + E2E_OCI_FINGERPRINT: ${{ secrets.E2E_OCI_FINGERPRINT }} + E2E_OCI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }} + E2E_OCI_REGION: ${{ secrets.E2E_OCI_REGION }} + E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }} + E2E_ALIBABACLOUD_ACCOUNT_ID: ${{ secrets.E2E_ALIBABACLOUD_ACCOUNT_ID }} + E2E_ALIBABACLOUD_ACCESS_KEY_ID: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_ID }} + E2E_ALIBABACLOUD_ACCESS_KEY_SECRET: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_SECRET }} + E2E_ALIBABACLOUD_ROLE_ARN: ${{ secrets.E2E_ALIBABACLOUD_ROLE_ARN }} + # Pass E2E paths from impact analysis + E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }} + RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }} + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Show test scope + run: | + echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY + if [[ "${{ env.RUN_ALL_TESTS }}" == "true" ]]; then + echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY + else + echo "Running tests matching: \`${{ env.E2E_TEST_PATHS }}\`" >> $GITHUB_STEP_SUMMARY + fi + echo "" + echo "Affected modules: \`${{ needs.impact-analysis.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY + + - name: Create k8s Kind Cluster + uses: helm/kind-action@v1 + with: + cluster_name: kind + + - name: Modify kubeconfig + run: | + kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443 + kubectl config view + + - name: Add network kind to docker compose + run: | + yq -i '.networks.kind.external = true' docker-compose.yml + yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml + + - name: Fix API data directory permissions + run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data + + - name: Add AWS credentials for testing + run: | + echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env + echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env + + - name: Start API services + run: | + export PROWLER_API_VERSION=latest + docker compose up -d api worker worker-beat + + - name: Wait for API to be ready + run: | + echo "Waiting for prowler-api..." + timeout=150 + elapsed=0 + while [ $elapsed -lt $timeout ]; do + if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then + echo "Prowler API is ready!" + exit 0 + fi + echo "Waiting... (${elapsed}s elapsed)" + sleep 5 + elapsed=$((elapsed + 5)) + done + echo "Timeout waiting for prowler-api" + exit 1 + + - name: Load database fixtures + run: | + docker compose exec -T api sh -c ' + for fixture in api/fixtures/dev/*.json; do + if [ -f "$fixture" ]; then + echo "Loading $fixture" + poetry run python manage.py loaddata "$fixture" --database admin + fi + done + ' + + - name: Setup Node.js + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: '24.13.0' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Get pnpm store directory + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm and Next.js cache + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + 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-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}- + ${{ runner.os }}-pnpm-nextjs- + + - name: Install UI dependencies + working-directory: ./ui + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Build UI application + working-directory: ./ui + run: pnpm run build + + - name: Cache Playwright browsers + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install Playwright browsers + working-directory: ./ui + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm run test:e2e:install + + - name: Run E2E tests + working-directory: ./ui + run: | + if [[ "${{ env.RUN_ALL_TESTS }}" == "true" ]]; then + echo "Running ALL E2E tests..." + pnpm run test:e2e + else + echo "Running targeted E2E tests: ${{ env.E2E_TEST_PATHS }}" + # Convert glob patterns to playwright test paths + # e.g., "ui/tests/providers/**" -> "tests/providers" + TEST_PATHS="${{ env.E2E_TEST_PATHS }}" + # Remove ui/ prefix and convert ** to empty (playwright handles recursion) + TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u) + # Drop auth setup helpers (not runnable test suites) + TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/') + if [[ -z "$TEST_PATHS" ]]; then + echo "No runnable E2E test paths after filtering setups" + exit 0 + fi + TEST_PATHS=$(echo "$TEST_PATHS" | tr '\n' ' ') + echo "Resolved test paths: $TEST_PATHS" + pnpm exec playwright test $TEST_PATHS + fi + + - name: Upload test reports + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + if: failure() + with: + name: playwright-report + path: ui/playwright-report/ + retention-days: 30 + + - name: Cleanup services + if: always() + run: | + docker compose down -v || true + + # Skip job - provides clear feedback when no E2E tests needed + skip-e2e: + needs: impact-analysis + if: | + github.repository == 'prowler-cloud/prowler' && + needs.impact-analysis.outputs.has-ui-e2e != 'true' && + needs.impact-analysis.outputs.run-all != 'true' + runs-on: ubuntu-latest + steps: + - name: No E2E tests needed + run: | + echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Affected modules: \`${{ needs.impact-analysis.outputs.modules }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml deleted file mode 100644 index ad03d644bb..0000000000 --- a/.github/workflows/ui-e2e-tests.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: UI - E2E Tests - -on: - pull_request: - branches: - - master - - "v5.*" - paths: - - '.github/workflows/ui-e2e-tests.yml' - - 'ui/**' - -jobs: - e2e-tests: - if: github.repository == 'prowler-cloud/prowler' - runs-on: ubuntu-latest - env: - AUTH_SECRET: 'fallback-ci-secret-for-testing' - AUTH_TRUST_HOST: true - NEXTAUTH_URL: 'http://localhost:3000' - NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' - E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} - E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} - E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} - E2E_AWS_PROVIDER_ACCESS_KEY: ${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }} - E2E_AWS_PROVIDER_SECRET_KEY: ${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }} - E2E_AWS_PROVIDER_ROLE_ARN: ${{ secrets.E2E_AWS_PROVIDER_ROLE_ARN }} - E2E_NEW_PASSWORD: ${{ secrets.E2E_NEW_PASSWORD }} - steps: - - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Fix API data directory permissions - run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data - - name: Start API services - run: | - # Override docker-compose image tag to use latest instead of stable - # This overrides any PROWLER_API_VERSION set in .env file - export PROWLER_API_VERSION=latest - echo "Using PROWLER_API_VERSION=${PROWLER_API_VERSION}" - docker compose up -d api worker worker-beat - - name: Wait for API to be ready - run: | - echo "Waiting for prowler-api..." - timeout=150 # 5 minutes max - elapsed=0 - while [ $elapsed -lt $timeout ]; do - if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then - echo "Prowler API is ready!" - exit 0 - fi - echo "Waiting for prowler-api... (${elapsed}s elapsed)" - sleep 5 - elapsed=$((elapsed + 5)) - done - echo "Timeout waiting for prowler-api to start" - exit 1 - - name: Load database fixtures for E2E tests - run: | - docker compose exec -T api sh -c ' - echo "Loading all fixtures from api/fixtures/dev/..." - for fixture in api/fixtures/dev/*.json; do - if [ -f "$fixture" ]; then - echo "Loading $fixture" - poetry run python manage.py loaddata "$fixture" --database admin - fi - done - echo "All database fixtures loaded successfully!" - ' - - name: Setup Node.js environment - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 - with: - node-version: '20.x' - cache: 'npm' - cache-dependency-path: './ui/package-lock.json' - - name: Install UI dependencies - working-directory: ./ui - run: npm ci - - name: Build UI application - working-directory: ./ui - run: npm run build - - name: Cache Playwright browsers - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - id: playwright-cache - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('ui/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-playwright- - - name: Install Playwright browsers - working-directory: ./ui - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npm run test:e2e:install - - name: Run E2E tests - working-directory: ./ui - run: npm run test:e2e - - name: Upload test reports - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: failure() - with: - name: playwright-report - path: ui/playwright-report/ - retention-days: 30 - - name: Cleanup services - if: always() - run: | - echo "Shutting down services..." - docker compose down -v || true - echo "Cleanup completed" diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index fcc05f743a..e7d1834f91 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -5,16 +5,10 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'ui/**' - - '.github/workflows/ui-tests.yml' pull_request: branches: - 'master' - 'v5.*' - paths: - - 'ui/**' - - '.github/workflows/ui-tests.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -22,7 +16,7 @@ concurrency: env: UI_WORKING_DIR: ./ui - NODE_VERSION: '20.x' + NODE_VERSION: '24.13.0' jobs: ui-tests: @@ -36,32 +30,59 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47.0.0 + uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 with: + files: | + ui/** + .github/workflows/ui-tests.yml files_ignore: | ui/CHANGELOG.md ui/README.md + ui/AGENTS.md - name: Setup Node.js ${{ env.NODE_VERSION }} if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - cache-dependency-path: './ui/package-lock.json' + + - name: Setup pnpm + if: steps.check-changes.outputs.any_changed == 'true' + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Get pnpm store directory + if: steps.check-changes.outputs.any_changed == 'true' + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - 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 }} + ${{ 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-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}- + ${{ runner.os }}-pnpm-nextjs- - name: Install dependencies if: steps.check-changes.outputs.any_changed == 'true' - run: npm ci + run: pnpm install --frozen-lockfile --prefer-offline - name: Run healthcheck if: steps.check-changes.outputs.any_changed == 'true' - run: npm run healthcheck + run: pnpm run healthcheck - name: Build application if: steps.check-changes.outputs.any_changed == 'true' - run: npm run build + run: pnpm run build diff --git a/.gitignore b/.gitignore index bc8c66d39b..4dfb137b89 100644 --- a/.gitignore +++ b/.gitignore @@ -45,21 +45,89 @@ pytest_*.xml .coverage htmlcov/ -# VSCode files +# VSCode files and settings .vscode/ +*.code-workspace +.vscode-test/ -# Cursor files +# VSCode extension settings and workspaces +.history/ +.ionide/ + +# MCP Server Settings (various locations) +**/cline_mcp_settings.json +**/mcp_settings.json +**/mcp-config.json +**/mcpServers.json +.mcp/ + +# AI Coding Assistants - Cursor .cursorignore .cursor/ +.cursorrules -# RooCode files +# AI Coding Assistants - RooCode .roo/ .rooignore .roomodes -# Cline files +# AI Coding Assistants - Cline (formerly Claude Dev) .cline/ .clineignore +.clinerules + +# AI Coding Assistants - Continue +.continue/ +continue.json +.continuerc +.continuerc.json + +# AI Coding Assistants - OpenCode +opencode.json + +# AI Coding Assistants - GitHub Copilot +.copilot/ +.github/copilot/ + +# AI Coding Assistants - Amazon Q Developer (formerly CodeWhisperer) +.aws/ +.codewhisperer/ +.amazonq/ +.aws-toolkit/ + +# AI Coding Assistants - Tabnine +.tabnine/ +tabnine_config.json + +# AI Coding Assistants - Kiro +.kiro/ +.kiroignore +kiro.config.json + +# AI Coding Assistants - Aider +.aider/ +.aider.chat.history.md +.aider.input.history +.aider.tags.cache.v3/ + +# AI Coding Assistants - Windsurf +.windsurf/ +.windsurfignore + +# AI Coding Assistants - Replit Agent +.replit +.replitignore + +# AI Coding Assistants - Supermaven +.supermaven/ + +# AI Coding Assistants - Sourcegraph Cody +.cody/ + +# AI Coding Assistants - General +.ai/ +.aiconfig +ai-config.json # Terraform .terraform* @@ -70,7 +138,6 @@ htmlcov/ ui/.env* api/.env* mcp_server/.env* -.env.local # Coverage .coverage* @@ -83,12 +150,16 @@ node_modules # Persistent data _data/ -# Claude +# AI Instructions (generated by skills/setup.sh from AGENTS.md) CLAUDE.md - -# MCP Server -mcp_server/prowler_mcp_server/prowler_app/server.py -mcp_server/prowler_mcp_server/prowler_app/utils/schema.yaml +GEMINI.md +.github/copilot-instructions.md # Compliance report *.pdf + +# AI Skills symlinks (generated by skills/setup.sh) +.claude/skills +.codex/skills +.github/skills +.gemini/skills diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4bb2435f1..0a4164bc43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,7 @@ repos: rev: v2.3.1 hooks: - id: autoflake + exclude: ^skills/ args: [ "--in-place", @@ -41,22 +42,24 @@ repos: "--remove-unused-variable", ] - - repo: https://github.com/timothycrosley/isort + - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort + exclude: ^skills/ args: ["--profile", "black"] - repo: https://github.com/psf/black rev: 24.4.2 hooks: - id: black + exclude: ^skills/ - repo: https://github.com/pycqa/flake8 rev: 7.0.0 hooks: - id: flake8 - exclude: contrib + exclude: (contrib|^skills/) args: ["--ignore=E266,W503,E203,E501,W605"] - repo: https://github.com/python-poetry/poetry @@ -109,7 +112,7 @@ repos: - id: bandit name: bandit description: "Bandit is a tool for finding common security issues in Python code" - entry: bash -c 'bandit -q -lll -x '*_test.py,./contrib/,./.venv/' -r .' + entry: bash -c 'bandit -q -lll -x '*_test.py,./contrib/,./.venv/,./skills/' -r .' language: system files: '.*\.py' @@ -117,12 +120,22 @@ repos: name: safety description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" # TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X - entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745' + # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 + entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027' language: system - id: vulture name: vulture description: "Vulture finds unused code in Python programs." - entry: bash -c 'vulture --exclude "contrib,.venv,api/src/backend/api/tests/,api/src/backend/conftest.py,api/src/backend/tasks/tests/" --min-confidence 100 .' + entry: bash -c 'vulture --exclude "contrib,.venv,api/src/backend/api/tests/,api/src/backend/conftest.py,api/src/backend/tasks/tests/,skills/" --min-confidence 100 .' language: system files: '.*\.py' + + - id: ui-checks + name: UI - Husky Pre-commit + description: "Run UI pre-commit checks (Claude Code validation + healthcheck)" + entry: bash -c 'cd ui && .husky/pre-commit' + language: system + files: '^ui/.*\.(ts|tsx|js|jsx|json|css)$' + pass_filenames: false + verbose: true diff --git a/AGENTS.md b/AGENTS.md index c6a6027c18..5d31782ef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,109 +2,153 @@ ## How to Use This Guide -- Start here for cross-project norms, Prowler is a monorepo with several components. Every component should have an `AGENTS.md` file that contains the guidelines for the agents in that component. The file is located beside the code you are touching (e.g. `api/AGENTS.md`, `ui/AGENTS.md`, `prowler/AGENTS.md`). -- Follow the stricter rule when guidance conflicts; component docs override this file for their scope. -- Keep instructions synchronized. When you add new workflows or scripts, update both, the relevant component `AGENTS.md` and this file if they apply broadly. +- Start here for cross-project norms. Prowler is a monorepo with several components. +- Each component has an `AGENTS.md` file with specific guidelines (e.g., `api/AGENTS.md`, `ui/AGENTS.md`). +- Component docs override this file when guidance conflicts. + +## Available Skills + +Use these skills for detailed patterns on-demand: + +### Generic Skills (Any Project) +| Skill | Description | URL | +|-------|-------------|-----| +| `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) | +| `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) | +| `nextjs-15` | App Router, Server Actions, streaming | [SKILL.md](skills/nextjs-15/SKILL.md) | +| `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) | +| `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) | + +### Prowler-Specific Skills +| Skill | Description | URL | +|-------|-------------|-----| +| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) | +| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) | +| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) | +| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) | +| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) | +| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) | +| `prowler-test-api` | API testing (pytest-django + RLS) | [SKILL.md](skills/prowler-test-api/SKILL.md) | +| `prowler-test-ui` | E2E testing (Playwright) | [SKILL.md](skills/prowler-test-ui/SKILL.md) | +| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) | +| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) | +| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) | +| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) | +| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) | +| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) | +| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) | +| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) | +| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) | +| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) | + +### Auto-invoke Skills + +When performing these actions, ALWAYS invoke the corresponding skill FIRST: + +| Action | Skill | +|--------|-------| +| Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding DRF pagination or permissions | `django-drf` | +| Adding new providers | `prowler-provider` | +| Adding services to existing providers | `prowler-provider` | +| Adding privilege escalation detection queries | `prowler-attack-paths-query` | +| 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 Attack Paths queries | `prowler-attack-paths-query` | +| 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` | +| Creating/modifying models, views, serializers | `prowler-api` | +| Creating/updating compliance frameworks | `prowler-compliance` | +| 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` | +| 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` | +| Understand CODEOWNERS/labeler-based automation | `prowler-ci` | +| Understand PR title conventional-commit validation | `prowler-ci` | +| Understand changelog gate and no-changelog label behavior | `prowler-ci` | +| Understand review ownership with CODEOWNERS | `prowler-pr` | +| Update CHANGELOG.md in any component | `prowler-changelog` | +| Updating existing Attack Paths queries | `prowler-attack-paths-query` | +| Updating existing checks and metadata | `prowler-sdk-check` | +| Using Zustand stores | `zustand-5` | +| Working on MCP server tools | `prowler-mcp` | +| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | +| Working with Prowler UI test helpers/pages | `prowler-test-ui` | +| Working with Tailwind classes | `tailwind-4` | +| Writing Playwright E2E tests | `playwright` | +| Writing Prowler API tests | `prowler-test-api` | +| Writing Prowler SDK tests | `prowler-test-sdk` | +| Writing Prowler UI E2E tests | `prowler-test-ui` | +| Writing Python tests with pytest | `pytest` | +| Writing React components | `react-19` | +| Writing TypeScript types/interfaces | `typescript` | +| Writing documentation | `prowler-docs` | + +--- ## Project Overview -Prowler is an open-source cloud security assessment tool that supports multiple cloud providers (AWS, Azure, GCP, Kubernetes, GitHub, M365, etc.). The project consists in a monorepo with the following main components: +Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more. -- **Prowler SDK**: Python SDK, includes the Prowler CLI, providers, services, checks, compliances, config, etc. (`prowler/`) -- **Prowler API**: Django-based REST API backend (`api/`) -- **Prowler UI**: Next.js frontend application (`ui/`) -- **Prowler MCP Server**: Model Context Protocol server that gives access to the entire Prowler ecosystem for LLMs (`mcp_server/`) -- **Prowler Dashboard**: Prowler CLI feature that allows to visualize the results of the scans in a simple dashboard (`dashboard/`) +| Component | Location | Tech Stack | +|-----------|----------|------------| +| SDK | `prowler/` | Python 3.9+, Poetry | +| API | `api/` | Django 5.1, DRF, Celery | +| UI | `ui/` | Next.js 15, React 19, Tailwind 4 | +| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ | +| Dashboard | `dashboard/` | Dash, Plotly | -### Project Structure (Key Folders & Files) - -- `prowler/`: Main source code for Prowler SDK (CLI, providers, services, checks, compliances, config, etc.) -- `api/`: Django-based REST API backend components -- `ui/`: Next.js frontend application -- `mcp_server/`: Model Context Protocol server that gives access to the entire Prowler ecosystem for LLMs -- `dashboard/`: Prowler CLI feature that allows to visualize the results of the scans in a simple dashboard -- `docs/`: Documentation -- `examples/`: Example output formats for providers and scripts -- `permissions/`: Permission-related files and policies -- `contrib/`: Community-contributed scripts or modules -- `tests/`: Prowler SDK test suite -- `docker-compose.yml`: Docker compose file to run the Prowler App (API + UI) production environment -- `docker-compose-dev.yml`: Docker compose file to run the Prowler App (API + UI) development environment -- `pyproject.toml`: Poetry Prowler SDK project file -- `.pre-commit-config.yaml`: Pre-commit hooks configuration -- `Makefile`: Makefile to run the project -- `LICENSE`: License file -- `README.md`: README file -- `CONTRIBUTING.md`: Contributing guide +--- ## Python Development -Most of the code is written in Python, so the main files in the root are focused on Python code. - -### Poetry Dev Environment - -For developing in Python we recommend using `poetry` to manage the dependencies. The minimal version is `2.1.1`. So it is recommended to run all commands using `poetry run ...`. - -To install the core dependencies to develop it is needed to run `poetry install --with dev`. - -### Pre-commit hooks - -The project has pre-commit hooks to lint and format the code. They are installed by running `poetry run pre-commit install`. - -When commiting a change, the hooks will be run automatically. Some of them are: - -- Code formatting (black, isort) -- Linting (flake8, pylint) -- Security checks (bandit, safety, trufflehog) -- YAML/JSON validation -- Poetry lock file validation - - -### Linting and Formatting - -We use the following tools to lint and format the code: - -- `flake8`: for linting the code -- `black`: for formatting the code -- `pylint`: for linting the code - -You can run all using the `make` command: ```bash +# Setup +poetry install --with dev +poetry run pre-commit install + +# Code quality poetry run make lint poetry run make format +poetry run pre-commit run --all-files ``` -Or they will be run automatically when you commit your changes using pre-commit hooks. +--- ## Commit & Pull Request Guidelines -For the commit messages and pull requests name follow the conventional-commit style. +Follow conventional-commit style: `[scope]: ` -Befire creating a pull request, complete the checklist in `.github/pull_request_template.md`. Summaries should explain deployment impact, highlight review steps, and note changelog or permission updates. Run all relevant tests and linters before requesting review and link screenshots for UI or dashboard changes. +**Types:** `feat`, `fix`, `docs`, `chore`, `perf`, `refactor`, `style`, `test` -### Conventional Commit Style - -The Conventional Commits specification is a lightweight convention on top of commit messages. It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of. - -The commit message should be structured as follows: - -``` -[optional scope]: - -[optional body] - -[optional footer(s)] -``` - -Any line of the commit message cannot be longer 100 characters! This allows the message to be easier to read on GitHub as well as in various git tools - -#### Commit Types - -- **feat**: code change introuce new functionality to the application -- **fix**: code change that solve a bug in the codebase -- **docs**: documentation only changes -- **chore**: changes related to the build process or auxiliary tools and libraries, that do not affect the application's functionality -- **perf**: code change that improves performance -- **refactor**: code change that neither fixes a bug nor adds a feature -- **style**: changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) -- **test**: adding missing tests or correcting existing tests +Before creating a PR: +1. Complete checklist in `.github/pull_request_template.md` +2. Run all relevant tests and linters +3. Link screenshots for UI changes diff --git a/Dockerfile b/Dockerfile index 5884bda9f3..6819f4736b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,15 @@ LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" ARG POWERSHELL_VERSION=7.5.0 +ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} + +ARG TRIVY_VERSION=0.66.0 +ENV TRIVY_VERSION=${TRIVY_VERSION} # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \ + build-essential pkg-config libzstd-dev zlib1g-dev \ && rm -rf /var/lib/apt/lists/* # Install PowerShell @@ -25,6 +30,24 @@ RUN ARCH=$(uname -m) && \ ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh && \ rm /tmp/powershell.tar.gz +# Install Trivy for IaC scanning +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + TRIVY_ARCH="Linux-64bit" ; \ + elif [ "$ARCH" = "aarch64" ]; then \ + TRIVY_ARCH="Linux-ARM64" ; \ + else \ + echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \ + fi && \ + wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \ + tar zxf /tmp/trivy.tar.gz -C /tmp && \ + mv /tmp/trivy /usr/local/bin/trivy && \ + chmod +x /usr/local/bin/trivy && \ + rm /tmp/trivy.tar.gz && \ + # Create trivy cache directory with proper permissions + mkdir -p /tmp/.cache/trivy && \ + chmod 777 /tmp/.cache/trivy + # Add prowler user RUN addgroup --gid 1000 prowler && \ adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler diff --git a/Makefile b/Makefile index 861c9cf7fe..2000d6d5bd 100644 --- a/Makefile +++ b/Makefile @@ -47,12 +47,12 @@ help: ## Show this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) ##@ Build no cache -build-no-cache-dev: - docker compose -f docker-compose-dev.yml build --no-cache api-dev worker-dev worker-beat +build-no-cache-dev: + docker compose -f docker-compose-dev.yml build --no-cache api-dev worker-dev worker-beat mcp-server ##@ Development Environment -run-api-dev: ## Start development environment with API, PostgreSQL, Valkey, and workers - docker compose -f docker-compose-dev.yml up api-dev postgres valkey worker-dev worker-beat +run-api-dev: ## Start development environment with API, PostgreSQL, Valkey, MCP, and workers + docker compose -f docker-compose-dev.yml up api-dev postgres valkey worker-dev worker-beat mcp-server ##@ Development Environment build-and-run-api-dev: build-no-cache-dev run-api-dev diff --git a/README.md b/README.md index baf226ec89..d583a33a15 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Prowler is the Open Cloud Security platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.

-Learn more at prowler.com +Secure ANY cloud at AI Speed at prowler.com

@@ -23,6 +23,7 @@ Docker Pulls AWS ECR Gallery +

Version @@ -35,28 +36,32 @@


- +

# Description -**Prowler** is an open-source security tool designed to assess and enforce security best practices across AWS, Azure, Google Cloud, and Kubernetes. It supports tasks such as security audits, incident response, continuous monitoring, system hardening, forensic readiness, and remediation processes. +**Prowler** is the world’s most widely used _open-source cloud security platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size. Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including: -- **Industry Standards:** CIS, NIST 800, NIST CSF, and CISA -- **Regulatory Compliance and Governance:** RBI, FedRAMP, and PCI-DSS +- **Prowler ThreatScore:** Weighted risk prioritization scoring that helps you focus on the most critical security findings first +- **Industry Standards:** CIS, NIST 800, NIST CSF, CISA, and MITRE ATT&CK +- **Regulatory Compliance and Governance:** RBI, FedRAMP, PCI-DSS, and NIS2 - **Frameworks for Sensitive Data and Privacy:** GDPR, HIPAA, and FFIEC -- **Frameworks for Organizational Governance and Quality Control:** SOC2 and GXP -- **AWS-Specific Frameworks:** AWS Foundational Technical Review (FTR) and AWS Well-Architected Framework (Security Pillar) -- **National Security Standards:** ENS (Spanish National Security Scheme) +- **Frameworks for Organizational Governance and Quality Control:** SOC2, GXP, and ISO 27001 +- **Cloud-Specific Frameworks:** AWS Foundational Technical Review (FTR), AWS Well-Architected Framework, and BSI C5 +- **National Security Standards:** ENS (Spanish National Security Scheme) and KISA ISMS-P (Korean) - **Custom Security Frameworks:** Tailored to your needs -## Prowler App +## Prowler App / Prowler Cloud -Prowler App is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments. +Prowler App / [Prowler Cloud](https://cloud.prowler.com/) is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments. + +![Prowler App](docs/images/products/overview.png) +![Risk Pipeline](docs/images/products/risk-pipeline.png) +![Threat Map](docs/images/products/threat-map.png) -![Prowler App](docs/products/img/overview.png) >For more details, refer to the [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation) @@ -73,26 +78,46 @@ prowler ```console prowler dashboard ``` -![Prowler Dashboard](docs/products/img/dashboard.png) +![Prowler Dashboard](docs/images/products/dashboard.png) + + +## Attack Paths + +Attack Paths automatically extends every completed AWS scan with a Neo4j graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan and therefore requires: + +- An accessible Neo4j instance (the Docker Compose files already ships a `neo4j` service). +- The following environment variables so Django and Celery can connect: + + | Variable | Description | Default | + | --- | --- | --- | + | `NEO4J_HOST` | Hostname used by the API containers. | `neo4j` | + | `NEO4J_PORT` | Bolt port exposed by Neo4j. | `7687` | + | `NEO4J_USER` / `NEO4J_PASSWORD` | Credentials with rights to create per-tenant databases. | `neo4j` / `neo4j_password` | + +Every AWS provider scan will enqueue an Attack Paths ingestion job automatically. Other cloud providers will be added in future iterations. + # Prowler at a Glance > [!Tip] > For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com). -| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Stage | Interface | -|---|---|---|---|---|---|---|---| -| AWS | 576 | 82 | 38 | 10 | Official | Stable | UI, API, CLI | -| GCP | 79 | 13 | 11 | 3 | Official | Stable | UI, API, CLI | -| Azure | 162 | 19 | 12 | 4 | Official | Stable | UI, API, CLI | -| Kubernetes | 83 | 7 | 5 | 7 | Official | Stable | UI, API, CLI | -| GitHub | 17 | 2 | 1 | 0 | Official | Stable | UI, API, CLI | -| M365 | 70 | 7 | 3 | 2 | Official | Stable | UI, API, CLI | -| OCI | 51 | 13 | 1 | 10 | Official | Stable | UI, API, CLI | -| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | Beta | CLI | -| MongoDB Atlas | 10 | 3 | 0 | 0 | Official | Beta | CLI | -| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | Beta | CLI | -| NHN | 6 | 2 | 1 | 0 | Unofficial | Beta | CLI | +| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface | +|---|---|---|---|---|---|---| +| AWS | 585 | 84 | 40 | 17 | Official | UI, API, CLI | +| Azure | 169 | 22 | 17 | 13 | Official | UI, API, CLI | +| GCP | 100 | 17 | 14 | 7 | Official | UI, API, CLI | +| Kubernetes | 84 | 7 | 7 | 9 | Official | UI, API, CLI | +| GitHub | 20 | 2 | 1 | 2 | Official | UI, API, CLI | +| M365 | 72 | 7 | 4 | 4 | Official | UI, API, CLI | +| OCI | 52 | 14 | 1 | 12 | Official | UI, API, CLI | +| Alibaba Cloud | 64 | 9 | 2 | 9 | Official | UI, API, CLI | +| Cloudflare | 29 | 3 | 0 | 5 | Official | CLI | +| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI | +| MongoDB Atlas | 10 | 3 | 0 | 3 | Official | UI, API, CLI | +| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI | +| OpenStack | 1 | 1 | 0 | 2 | Official | CLI | +| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] > The numbers in the table are updated periodically. @@ -142,9 +167,9 @@ If your workstation's architecture is incompatible, you can resolve this by: ### Common Issues with Docker Pull Installation > [!Note] - If you want to use AWS role assumption (e.g., with the "Connect assuming IAM Role" option), you may need to mount your local `.aws` directory into the container as a volume (e.g., `- "${HOME}/.aws:/home/prowler/.aws:ro"`). There are several ways to configure credentials for Docker containers. See the [Troubleshooting](./docs/troubleshooting.md) section for more details and examples. + If you want to use AWS role assumption (e.g., with the "Connect assuming IAM Role" option), you may need to mount your local `.aws` directory into the container as a volume (e.g., `- "${HOME}/.aws:/home/prowler/.aws:ro"`). There are several ways to configure credentials for Docker containers. See the [Troubleshooting](./docs/troubleshooting.mdx) section for more details and examples. -You can find more information in the [Troubleshooting](./docs/troubleshooting.md) section. +You can find more information in the [Troubleshooting](./docs/troubleshooting.mdx) section. ### From GitHub @@ -153,7 +178,7 @@ You can find more information in the [Troubleshooting](./docs/troubleshooting.md * `git` installed. * `poetry` v2 installed: [poetry installation](https://python-poetry.org/docs/#installation). -* `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). +* `pnpm` installed: [pnpm installation](https://pnpm.io/installation). * `Docker Compose` installed: https://docs.docker.com/compose/install/. **Commands to run the API** @@ -209,9 +234,9 @@ python -m celery -A config.celery beat -l info --scheduler django_celery_beat.sc ``` console git clone https://github.com/prowler-cloud/prowler cd prowler/ui -npm install -npm run build -npm start +pnpm install +pnpm run build +pnpm start ``` > Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started. @@ -271,11 +296,12 @@ python prowler-cli.py -v # ✏️ High level architecture ## Prowler App -**Prowler App** is composed of three key components: +**Prowler App** is composed of four key components: - **Prowler UI**: A web-based interface, built with Next.js, providing a user-friendly experience for executing Prowler scans and visualizing results. - **Prowler API**: A backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results. - **Prowler SDK**: A Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities. +- **Prowler MCP Server**: A Model Context Protocol server that provides AI tools for Lighthouse, the AI-powered security assistant. This is a critical dependency for Lighthouse functionality. ![Prowler App Architecture](docs/products/img/prowler-app-architecture.png) @@ -303,6 +329,45 @@ And many more environments. ![Architecture](docs/img/architecture.png) +# 🤖 AI Skills for Development + +Prowler includes a comprehensive set of **AI Skills** that help AI coding assistants understand Prowler's codebase patterns and conventions. + +## What are AI Skills? + +Skills are structured instructions that give AI assistants the context they need to write code that follows Prowler's standards. They include: + +- **Coding patterns** for each component (SDK, API, UI, MCP Server) +- **Testing conventions** (pytest, Playwright) +- **Architecture guidelines** (Clean Architecture, RLS patterns) +- **Framework-specific rules** (React 19, Next.js 15, Django DRF, Tailwind 4) + +## Available Skills + +| Category | Skills | +|----------|--------| +| **Generic** | `typescript`, `react-19`, `nextjs-15`, `tailwind-4`, `playwright`, `pytest`, `django-drf`, `zod-4`, `zustand-5`, `ai-sdk-5` | +| **Prowler** | `prowler`, `prowler-api`, `prowler-ui`, `prowler-mcp`, `prowler-sdk-check`, `prowler-test-ui`, `prowler-test-api`, `prowler-test-sdk`, `prowler-compliance`, `prowler-provider`, `prowler-pr`, `prowler-docs` | + +## Setup + +```bash +./skills/setup.sh +``` + +This configures skills for AI coding assistants that follow the [agentskills.io](https://agentskills.io) standard: + +| Tool | Configuration | +|------|---------------| +| **Claude Code** | `.claude/skills/` (symlink) | +| **OpenCode** | `.claude/skills/` (symlink) | +| **Codex (OpenAI)** | `.codex/skills/` (symlink) | +| **GitHub Copilot** | `.github/skills/` (symlink) | +| **Gemini CLI** | `.gemini/skills/` (symlink) | + +> **Note:** Restart your AI coding assistant after running setup to load the skills. +> Gemini CLI requires `experimental.skills` enabled in settings. + # 📖 Documentation For installation instructions, usage details, tutorials, and the Developer Guide, visit https://docs.prowler.com/ diff --git a/SECURITY.md b/SECURITY.md index 365699f6a1..f1a2c29942 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -62,4 +62,4 @@ We strive to resolve all problems as quickly as possible, and we would like to p --- -For more information about our security policies, please refer to our [Security](https://docs.prowler.com/projects/prowler-open-source/en/latest/security/) section in our documentation. \ No newline at end of file +For more information about our security policies, please refer to our [Security](https://docs.prowler.com/security) section in our documentation. diff --git a/api/AGENTS.md b/api/AGENTS.md new file mode 100644 index 0000000000..e1e989d751 --- /dev/null +++ b/api/AGENTS.md @@ -0,0 +1,167 @@ +# Prowler API - AI Agent Ruleset + +> **Skills Reference**: For detailed patterns, use these skills: +> - [`prowler-api`](../skills/prowler-api/SKILL.md) - Models, Serializers, Views, RLS patterns +> - [`prowler-test-api`](../skills/prowler-test-api/SKILL.md) - Testing patterns (pytest-django) +> - [`prowler-attack-paths-query`](../skills/prowler-attack-paths-query/SKILL.md) - Attack Paths openCypher queries +> - [`django-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 + +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 privilege escalation detection queries | `prowler-attack-paths-query` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating API endpoints | `jsonapi` | +| Creating Attack Paths queries | `prowler-attack-paths-query` | +| Creating ViewSets, serializers, or filters in api/ | `django-drf` | +| Creating a git commit | `prowler-commit` | +| Creating/modifying models, views, serializers | `prowler-api` | +| 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` | +| Updating existing Attack Paths queries | `prowler-attack-paths-query` | +| Writing Prowler API tests | `prowler-test-api` | +| Writing Python tests with pytest | `pytest` | + +--- + +## CRITICAL RULES - NON-NEGOTIABLE + +### Models +- ALWAYS: UUIDv4 PKs, `inserted_at`/`updated_at` timestamps, `JSONAPIMeta` class +- ALWAYS: Inherit from `RowLevelSecurityProtectedModel` for tenant-scoped data +- NEVER: Auto-increment integer PKs, models without tenant isolation + +### Serializers +- ALWAYS: Separate serializers for Create/Update operations +- ALWAYS: Inherit from `RLSSerializer` for tenant-scoped models +- NEVER: Write logic in serializers (use services/utils) + +### Views +- ALWAYS: Inherit from `BaseRLSViewSet` for tenant-scoped resources +- ALWAYS: Define `filterset_class`, use `@extend_schema` for OpenAPI +- NEVER: Raw SQL queries, business logic in views + +### Row-Level Security (RLS) +- ALWAYS: Use `rls_transaction(tenant_id)` context manager +- NEVER: Query across tenants, trust client-provided tenant_id + +### Celery Tasks +- ALWAYS: `@shared_task` with `name`, `queue`, `RLSTask` base class +- NEVER: Long-running ops in views, request context in tasks + +--- + +## DECISION TREES + +### Serializer Selection +``` +Read → Serializer +Create → CreateSerializer +Update → UpdateSerializer +Nested read → IncludeSerializer +``` + +### Task vs View +``` +< 100ms → View +> 100ms or external API → Celery task +Needs retry → Celery task +``` + +--- + +## TECH STACK + +Django 5.1.x | DRF 3.15.x | djangorestframework-jsonapi 7.x | Celery 5.4.x | PostgreSQL 16 | pytest 8.x + +--- + +## PROJECT STRUCTURE + +``` +api/src/backend/ +├── api/ # Main Django app +│ ├── v1/ # API version 1 (views, serializers, urls) +│ ├── models.py # Django models +│ ├── filters.py # FilterSet classes +│ ├── base_views.py # Base ViewSet classes +│ ├── rls.py # Row-Level Security +│ └── tests/ # Unit tests +├── config/ # Django configuration +└── tasks/ # Celery tasks +``` + +--- + +## COMMANDS + +```bash +# Development +poetry run python src/backend/manage.py runserver +poetry run celery -A config.celery worker -l INFO + +# Database +poetry run python src/backend/manage.py makemigrations +poetry run python src/backend/manage.py migrate + +# Testing & Linting +poetry run pytest -x --tb=short +poetry run make lint +``` + +--- + +## QA CHECKLIST + +- [ ] `poetry run pytest` passes +- [ ] `poetry run make lint` passes +- [ ] Migrations created if models changed +- [ ] New endpoints have `@extend_schema` decorators +- [ ] RLS properly applied for tenant data +- [ ] Tests cover success and error cases + +--- + +## NAMING CONVENTIONS + +| Entity | Pattern | Example | +|--------|---------|---------| +| Serializer (read) | `Serializer` | `ProviderSerializer` | +| Serializer (create) | `CreateSerializer` | `ProviderCreateSerializer` | +| Serializer (update) | `UpdateSerializer` | `ProviderUpdateSerializer` | +| Filter | `Filter` | `ProviderFilter` | +| ViewSet | `ViewSet` | `ProviderViewSet` | +| Task | `__task` | `sync_provider_resources_task` | + +--- + +## API CONVENTIONS (JSON:API) + +```json +{ + "data": { + "type": "providers", + "id": "uuid", + "attributes": { "name": "value" }, + "relationships": { "tenant": { "data": { "type": "tenants", "id": "uuid" } } } + } +} +``` + +- Content-Type: `application/vnd.api+json` +- Pagination: `?page[number]=1&page[size]=20` +- Filtering: `?filter[field]=value`, `?filter[field__in]=val1,val2` +- Sorting: `?sort=field`, `?sort=-field` +- Including: `?include=provider,findings` diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index c73191464d..17d4eda45b 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,23 +2,230 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.15.0] (Prowler UNRELEASED) +## [1.20.0] (Prowler UNRELEASED) -### Added +### 🚀 Added + +- OpenStack provider support [(#10003)](https://github.com/prowler-cloud/prowler/pull/10003) + +### 🔄 Changed + +- Attack Paths: Queries definition now has short description and attribution [(#9983)](https://github.com/prowler-cloud/prowler/pull/9983) +- Attack Paths: Internet node is created while scan [(#9992)](https://github.com/prowler-cloud/prowler/pull/9992) +- Attack Paths: Add full paths set from [pathfinding.cloud](https://pathfinding.cloud/) [(#10008)](https://github.com/prowler-cloud/prowler/pull/10008) +- Support CSA CCM 4.0 for the AWS provider [(#10018)](https://github.com/prowler-cloud/prowler/pull/10018) +- Support CSA CCM 4.0 for the GCP provider [(#10042)](https://github.com/prowler-cloud/prowler/pull/10042) +- Support CSA CCM 4.0 for the Azure provider [(#10039)](https://github.com/prowler-cloud/prowler/pull/10039) +- Support CSA CCM 4.0 for the Oracle Cloud provider [(#10057)](https://github.com/prowler-cloud/prowler/pull/10057) +- Support CSA CCM 4.0 for the Alibaba Cloud provider [(#10061)](https://github.com/prowler-cloud/prowler/pull/10061) +- Attack Paths: Mark attack Paths scan as failed when Celery task fails outside job error handling [(#10065)](https://github.com/prowler-cloud/prowler/pull/10065) + +### 🔐 Security + +- Bump `Pillow` to 12.1.1 (CVE-2021-25289) [(#10027)](https://github.com/prowler-cloud/prowler/pull/10027) + +--- + +## [1.19.2] (Prowler v5.18.2) + +### 🐞 Fixed + +- SAML role mapping now prevents removing the last MANAGE_ACCOUNT user [(#10007)](https://github.com/prowler-cloud/prowler/pull/10007) + +--- + +## [1.19.0] (Prowler v5.18.0) + +### 🚀 Added + +- Cloudflare provider support [(#9907)](https://github.com/prowler-cloud/prowler/pull/9907) +- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885) +- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) +- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444) +- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101) +- Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904) + +### 🔄 Changed + +- Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857) +- Attack Paths: Pinned Cartography to version `0.126.1`, adding AWS scans for SageMaker, CloudFront and Bedrock [(#9893)](https://github.com/prowler-cloud/prowler/issues/9893) +- Remove unused indexes [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904) +- Attack Paths: Modified the behaviour of the Cartography scans to use the same Neo4j database per tenant, instead of individual databases per scans [(#9955)](https://github.com/prowler-cloud/prowler/pull/9955) + +### 🐞 Fixed + +- Attack Paths: `aws-security-groups-open-internet-facing` query returning no results due to incorrect relationship matching [(#9892)](https://github.com/prowler-cloud/prowler/pull/9892) + +--- + +## [1.18.1] (Prowler v5.17.1) + +### 🐞 Fixed + +- Improve API startup process by `manage.py` argument detection [(#9856)](https://github.com/prowler-cloud/prowler/pull/9856) +- Deleting providers don't try to delete a `None` Neo4j database when an Attack Paths scan is scheduled [(#9858)](https://github.com/prowler-cloud/prowler/pull/9858) +- Use replica database for reading Findings to add them to the Attack Paths graph [(#9861)](https://github.com/prowler-cloud/prowler/pull/9861) +- Attack paths findings loading query to use streaming generator for O(batch_size) memory instead of O(total_findings) [(#9862)](https://github.com/prowler-cloud/prowler/pull/9862) +- Lazy load Neo4j driver [(#9868)](https://github.com/prowler-cloud/prowler/pull/9868) +- Use `Findings.all_objects` to avoid the `ActiveProviderPartitionedManager` [(#9869)](https://github.com/prowler-cloud/prowler/pull/9869) +- Lazy load Neo4j driver for workers only [(#9872)](https://github.com/prowler-cloud/prowler/pull/9872) +- Improve Cypher query for inserting Findings into Attack Paths scan graphs [(#9874)](https://github.com/prowler-cloud/prowler/pull/9874) +- Clear Neo4j database cache after Attack Paths scan and each API query [(#9877)](https://github.com/prowler-cloud/prowler/pull/9877) +- Deduplicated scheduled scans for long-running providers [(#9829)](https://github.com/prowler-cloud/prowler/pull/9829) + +--- + +## [1.18.0] (Prowler v5.17.0) + +### 🚀 Added + +- `/api/v1/overviews/compliance-watchlist` endpoint to retrieve the compliance watchlist [(#9596)](https://github.com/prowler-cloud/prowler/pull/9596) +- AlibabaCloud provider support [(#9485)](https://github.com/prowler-cloud/prowler/pull/9485) +- `/api/v1/overviews/resource-groups` endpoint to retrieve an overview of resource groups based on finding severities [(#9694)](https://github.com/prowler-cloud/prowler/pull/9694) +- `group` filter for `GET /findings` and `GET /findings/metadata/latest` endpoints [(#9694)](https://github.com/prowler-cloud/prowler/pull/9694) +- `provider_id` and `provider_id__in` filter aliases for findings endpoints to enable consistent frontend parameter naming [(#9701)](https://github.com/prowler-cloud/prowler/pull/9701) +- Attack Paths: `/api/v1/attack-paths-scans` for AWS providers backed by Neo4j [(#9805)](https://github.com/prowler-cloud/prowler/pull/9805) + +### 🔐 Security + +- Django 5.1.15 (CVE-2025-64460, CVE-2025-13372), Werkzeug 3.1.4 (CVE-2025-66221), sqlparse 0.5.5 (PVE-2025-82038), fonttools 4.60.2 (CVE-2025-66034) [(#9730)](https://github.com/prowler-cloud/prowler/pull/9730) +- `safety` to `3.7.0` and `filelock` to `3.20.3` due to [Safety vulnerability 82754 (CVE-2025-68146)](https://data.safetycli.com/v/82754/97c/) [(#9816)](https://github.com/prowler-cloud/prowler/pull/9816) +- `pyasn1` to v0.6.2 to address [CVE-2026-23490](https://nvd.nist.gov/vuln/detail/CVE-2026-23490) [(#9818)](https://github.com/prowler-cloud/prowler/pull/9818) +- `django-allauth[saml]` to v65.13.0 to address [CVE-2025-65431](https://nvd.nist.gov/vuln/detail/CVE-2025-65431) [(#9575)](https://github.com/prowler-cloud/prowler/pull/9575) + +--- + +## [1.17.1] (Prowler v5.16.1) + +### 🔄 Changed + +- Security Hub integration error when no regions [(#9635)](https://github.com/prowler-cloud/prowler/pull/9635) + +### 🐞 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 + +- 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 + +- 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 + +- 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) + +--- + +## [1.16.1] (Prowler v5.15.1) + +### 🐞 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 + +- 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 + +- 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) + +--- + +## [1.15.2] (Prowler v5.14.2) + +### 🐞 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) + +--- + +## [1.15.1] (Prowler v5.14.1) + +### 🐞 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) + +--- + +## [1.15.0] (Prowler v5.14.0) + +### 🚀 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) - Support for configuring multiple LLM providers [(#8772)](https://github.com/prowler-cloud/prowler/pull/8772) - Support C5 compliance framework for Azure provider [(#9081)](https://github.com/prowler-cloud/prowler/pull/9081) - Support for Oracle Cloud Infrastructure (OCI) provider [(#8927)](https://github.com/prowler-cloud/prowler/pull/8927) +- Support muting findings based on simple rules with custom reason [(#9051)](https://github.com/prowler-cloud/prowler/pull/9051) +- Support C5 compliance framework for the GCP provider [(#9097)](https://github.com/prowler-cloud/prowler/pull/9097) +- Support for Amazon Bedrock and OpenAI compatible providers in Lighthouse AI [(#8957)](https://github.com/prowler-cloud/prowler/pull/8957) +- Support PDF reporting for ENS compliance framework [(#9158)](https://github.com/prowler-cloud/prowler/pull/9158) +- Support PDF reporting for NIS2 compliance framework [(#9170)](https://github.com/prowler-cloud/prowler/pull/9170) +- Tenant-wide ThreatScore overview aggregation and snapshot persistence with backfill support [(#9148)](https://github.com/prowler-cloud/prowler/pull/9148) +- Added `metadata`, `details`, and `partition` attributes to `/resources` endpoint & `details`, and `partition` to `/findings` endpoint [(#9098)](https://github.com/prowler-cloud/prowler/pull/9098) +- Support for MongoDB Atlas provider [(#9167)](https://github.com/prowler-cloud/prowler/pull/9167) +- Support Prowler ThreatScore for the K8S provider [(#9235)](https://github.com/prowler-cloud/prowler/pull/9235) +- 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) -## [1.14.1] (Prowler 5.13.1) +### 🔄 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 + +- 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) +- Refresh output report timestamps for each scan [(#9272)](https://github.com/prowler-cloud/prowler/pull/9272) +- 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 + +- 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) -## [1.14.0] (Prowler 5.13.0) +--- + +## [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) @@ -31,138 +238,163 @@ 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 5.12.3) +## [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 5.12.2) +## [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 5.12.0) +## [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) --- -## [1.12.0] (Prowler 5.11.0) +## [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 5.10.0) +## [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) @@ -170,20 +402,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) @@ -191,7 +427,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) @@ -202,14 +439,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) @@ -220,7 +459,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) @@ -230,7 +469,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) @@ -242,14 +481,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) @@ -257,14 +498,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) @@ -273,19 +516,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/Dockerfile b/api/Dockerfile index a3cfb21782..2d7883a957 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,6 +5,9 @@ LABEL maintainer="https://github.com/prowler-cloud/api" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} +ARG TRIVY_VERSION=0.66.0 +ENV TRIVY_VERSION=${TRIVY_VERSION} + # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ @@ -36,6 +39,24 @@ RUN ARCH=$(uname -m) && \ ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh && \ rm /tmp/powershell.tar.gz +# Install Trivy for IaC scanning +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + TRIVY_ARCH="Linux-64bit" ; \ + elif [ "$ARCH" = "aarch64" ]; then \ + TRIVY_ARCH="Linux-ARM64" ; \ + else \ + echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \ + fi && \ + wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \ + tar zxf /tmp/trivy.tar.gz -C /tmp && \ + mv /tmp/trivy /usr/local/bin/trivy && \ + chmod +x /usr/local/bin/trivy && \ + rm /tmp/trivy.tar.gz && \ + # Create trivy cache directory with proper permissions + mkdir -p /tmp/.cache/trivy && \ + chmod 777 /tmp/.cache/trivy + # Add prowler user RUN addgroup --gid 1000 prowler && \ adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler 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 40313d9fa5..203147d55f 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "about-time" @@ -12,6 +12,83 @@ files = [ {file = "about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341"}, ] +[[package]] +name = "adal" +version = "1.2.7" +description = "Note: This library is already replaced by MSAL Python, available here: https://pypi.org/project/msal/ .ADAL Python remains available here as a legacy. The ADAL for Python library makes it easy for python application to authenticate to Azure Active Directory (AAD) in order to access AAD protected web resources." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "adal-1.2.7-py2.py3-none-any.whl", hash = "sha256:2a7451ed7441ddbc57703042204a3e30ef747478eea022c70f789fc7f084bc3d"}, + {file = "adal-1.2.7.tar.gz", hash = "sha256:d74f45b81317454d96e982fd1c50e6fb5c99ac2223728aea8764433a39f566f1"}, +] + +[package.dependencies] +cryptography = ">=1.1.0" +PyJWT = ">=1.0.0,<3" +python-dateutil = ">=2.1.0,<3" +requests = ">=2.0.0,<3" + +[[package]] +name = "aioboto3" +version = "15.5.0" +description = "Async boto3 wrapper" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6"}, + {file = "aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979"}, +] + +[package.dependencies] +aiobotocore = {version = "2.25.1", extras = ["boto3"]} +aiofiles = ">=23.2.1" + +[package.extras] +chalice = ["chalice (>=1.24.0)"] +s3cse = ["cryptography (>=44.0.1)"] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +description = "Async client for aws services using botocore and aiohttp" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"}, + {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"}, +] + +[package.dependencies] +aiohttp = ">=3.9.2,<4.0.0" +aioitertools = ">=0.5.1,<1.0.0" +boto3 = {version = ">=1.40.46,<1.40.62", optional = true, markers = "extra == \"boto3\""} +botocore = ">=1.40.46,<1.40.62" +jmespath = ">=0.7.1,<2.0.0" +multidict = ">=6.0.0,<7.0.0" +python-dateutil = ">=2.1,<3.0.0" +wrapt = ">=1.10.10,<2.0.0" + +[package.extras] +awscli = ["awscli (>=1.42.46,<1.42.62)"] +boto3 = ["boto3 (>=1.40.46,<1.40.62)"] +httpx = ["httpx (>=0.25.1,<0.29)"] + +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -26,98 +103,132 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.13.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, - {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, - {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, - {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, - {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, - {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, - {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, - {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, - {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, - {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, - {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, - {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, - {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, - {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, - {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, - {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, - {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, - {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, - {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, - {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, - {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, - {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, - {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, - {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, - {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, - {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, - {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, - {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, - {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, - {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, - {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, - {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, - {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, - {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, - {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, - {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, + {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, + {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, + {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, + {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, + {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, + {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, + {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, + {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, + {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, + {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f"}, + {file = "aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1"}, + {file = "aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538"}, + {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, ] [package.dependencies] @@ -130,7 +241,19 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aioitertools" +version = "0.13.0" +description = "itertools and builtins for AsyncIO and mixed iterables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be"}, + {file = "aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c"}, +] [[package]] name = "aiosignal" @@ -148,6 +271,481 @@ files = [ frozenlist = ">=1.1.0" typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} +[[package]] +name = "alibabacloud-actiontrail20200706" +version = "2.4.1" +description = "Alibaba Cloud ActionTrail (20200706) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_actiontrail20200706-2.4.1-py3-none-any.whl", hash = "sha256:5dee0009db9b7cba182fbac742820f6a949287a8faafb843b5107f7dc89136da"}, + {file = "alibabacloud_actiontrail20200706-2.4.1.tar.gz", hash = "sha256:b65c6b37a96443fbe625dd5a4dd1be52a7476006a411db75206908b11588ffa8"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-credentials" +version = "1.0.3" +description = "The alibabacloud credentials module of alibabaCloud Python SDK." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "alibabacloud-credentials-1.0.3.tar.gz", hash = "sha256:9d8707e96afc6f348e23f5677ed15a21c2dfce7cfe6669776548ee4c80e1dfaf"}, + {file = "alibabacloud_credentials-1.0.3-py3-none-any.whl", hash = "sha256:30c8302f204b663c655d97e1c283ee9f9f84a6257d7901b931477d6cf34445a8"}, +] + +[package.dependencies] +aiofiles = ">=22.1.0,<25.0.0" +alibabacloud-credentials-api = ">=1.0.0,<2.0.0" +alibabacloud-tea = ">=0.4.0" +APScheduler = ">=3.10.0,<4.0.0" + +[[package]] +name = "alibabacloud-credentials-api" +version = "1.0.0" +description = "Alibaba Cloud Gateway SPI SDK Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f"}, +] + +[[package]] +name = "alibabacloud-cs20151215" +version = "6.1.0" +description = "Alibaba Cloud CS (20151215) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_cs20151215-6.1.0-py3-none-any.whl", hash = "sha256:75e90b1bb9acca2236244bb0e44234ca4805d456ea4303ba4225ac15152a458e"}, + {file = "alibabacloud_cs20151215-6.1.0.tar.gz", hash = "sha256:5b3d99306701bf499ddd57cd9f2905b7721cb1bb4bb38ffe4d051f7b4e80e355"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-darabonba-array" +version = "0.1.0" +description = "Alibaba Cloud Darabonba Array SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_darabonba_array-0.1.0.tar.gz", hash = "sha256:7f9a7c632518ff4f0cebb0d4e825a48c12e7cf0b9016ea25054dd73732e155aa"}, +] + +[[package]] +name = "alibabacloud-darabonba-encode-util" +version = "0.0.2" +description = "Darabonba Util Library for Alibaba Cloud Python SDK" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_darabonba_encode_util-0.0.2.tar.gz", hash = "sha256:f1c484f276d60450fa49b4b2987194e741fcb2f7faae7f287c0ae65abc85fd4d"}, +] + +[[package]] +name = "alibabacloud-darabonba-map" +version = "0.0.1" +description = "Alibaba Cloud Darabonba Map SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_darabonba_map-0.0.1.tar.gz", hash = "sha256:adb17384658a1a8f72418f1838d4b6a5fd2566bfd392a3ef06d9dbb0a595a23f"}, +] + +[[package]] +name = "alibabacloud-darabonba-signature-util" +version = "0.0.4" +description = "Darabonba Util Library for Alibaba Cloud Python SDK" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_darabonba_signature_util-0.0.4.tar.gz", hash = "sha256:71d79b2ae65957bcfbf699ced894fda782b32f9635f1616635533e5a90d5feb0"}, +] + +[package.dependencies] +cryptography = ">=3.0.0" + +[[package]] +name = "alibabacloud-darabonba-string" +version = "0.0.4" +description = "Alibaba Cloud Darabonba String Library for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "alibabacloud-darabonba-string-0.0.4.tar.gz", hash = "sha256:ec6614c0448dadcbc5e466485838a1f8cfdd911135bea739e20b14511270c6f7"}, +] + +[[package]] +name = "alibabacloud-darabonba-time" +version = "0.0.1" +description = "Alibaba Cloud Darabonba Time SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_darabonba_time-0.0.1.tar.gz", hash = "sha256:0ad9c7b0696570d1a3f40106cc7777f755fd92baa0d1dcab5b7df78dde5b922d"}, +] + +[[package]] +name = "alibabacloud-ecs20140526" +version = "7.2.5" +description = "Alibaba Cloud Elastic Compute Service (20140526) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_ecs20140526-7.2.5-py3-none-any.whl", hash = "sha256:10bda5e185f6ba899e7d51477373595c629d66db7530a8a37433fb4e9034a96f"}, + {file = "alibabacloud_ecs20140526-7.2.5.tar.gz", hash = "sha256:2abbe630ce42d69061821f38950b938c5982cc31902ccd7132d05be328765a55"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-endpoint-util" +version = "0.0.4" +description = "The endpoint-util module of alibabaCloud Python SDK." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90"}, +] + +[[package]] +name = "alibabacloud-gateway-oss" +version = "0.0.17" +description = "Alibaba Cloud OSS SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_gateway_oss-0.0.17.tar.gz", hash = "sha256:8c4b66c8c7dd285fc210ee232ab3f062b5573258752804d19382000746531e29"}, +] + +[package.dependencies] +alibabacloud_credentials = ">=0.3.5" +alibabacloud_darabonba_array = ">=0.1.0,<1.0.0" +alibabacloud_darabonba_encode_util = ">=0.0.2,<1.0.0" +alibabacloud_darabonba_map = ">=0.0.1,<1.0.0" +alibabacloud_darabonba_signature_util = ">=0.0.4,<1.0.0" +alibabacloud_darabonba_string = ">=0.0.4,<1.0.0" +alibabacloud_darabonba_time = ">=0.0.1,<1.0.0" +alibabacloud_gateway_oss_util = ">=0.0.3,<1.0.0" +alibabacloud_gateway_spi = ">=0.0.1,<1.0.0" +alibabacloud_openapi_util = ">=0.2.1,<1.0.0" +alibabacloud_oss_util = ">=0.0.5,<1.0.0" +alibabacloud_tea_util = ">=0.3.11,<1.0.0" +alibabacloud_tea_xml = ">=0.0.2,<1.0.0" + +[[package]] +name = "alibabacloud-gateway-oss-util" +version = "0.0.3" +description = "Alibaba Cloud OSS Util Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_gateway_oss_util-0.0.3.tar.gz", hash = "sha256:5eb7fa450dc7350d5c71577974b9d7f489479e5c5ec7efc1c5376385e8c1c0a5"}, +] + +[[package]] +name = "alibabacloud-gateway-sls" +version = "0.4.0" +description = "Alibaba Cloud SLS Gateway Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "alibabacloud_gateway_sls-0.4.0-py3-none-any.whl", hash = "sha256:a0299a83a5528025983b42b7533a28028461bced5e180a66f97999e0134760a6"}, + {file = "alibabacloud_gateway_sls-0.4.0.tar.gz", hash = "sha256:9d2aceb377c9b3ed0558149fda16fe39fa114cc0a22e22a88dc76efdda34633b"}, +] + +[package.dependencies] +alibabacloud-credentials = ">=1.0.2,<2.0.0" +alibabacloud-darabonba-array = ">=0.1.0,<1.0.0" +alibabacloud-darabonba-encode-util = ">=0.0.2,<1.0.0" +alibabacloud-darabonba-map = ">=0.0.1,<1.0.0" +alibabacloud-darabonba-signature-util = ">=0.0.4,<1.0.0" +alibabacloud-darabonba-string = ">=0.0.4,<1.0.0" +alibabacloud-gateway-sls-util = ">=0.4.0,<1.0.0" +alibabacloud-gateway-spi = ">=0.0.2,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-gateway-sls-util" +version = "0.4.0" +description = "Alibaba Cloud SLS Util Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "alibabacloud_gateway_sls_util-0.4.0-py3-none-any.whl", hash = "sha256:c91ab7fe55af526a01d25b0d431088c4d241b160db055da3d8cb7330bd74595a"}, + {file = "alibabacloud_gateway_sls_util-0.4.0.tar.gz", hash = "sha256:f8b683a36a2ae3fe9a8225d3d97773ea769bdf9cdf4f4d033eab2eb6062ddd1f"}, +] + +[package.dependencies] +aliyun-log-fastpb = ">=0.2.0" +lz4 = ">=4.3.2" +zstd = ">=1.5.5.1" + +[[package]] +name = "alibabacloud-gateway-spi" +version = "0.0.3" +description = "Alibaba Cloud Gateway SPI SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b"}, +] + +[package.dependencies] +alibabacloud_credentials = ">=0.3.4" + +[[package]] +name = "alibabacloud-openapi-util" +version = "0.2.4" +description = "Aliyun Tea OpenApi Library for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd"}, + {file = "alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34"}, +] + +[package.dependencies] +alibabacloud-tea-util = ">=0.3.13,<1.0.0" +cryptography = ">=3.0.0" + +[[package]] +name = "alibabacloud-oss-util" +version = "0.0.6" +description = "The oss util module of alibabaCloud Python SDK." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6"}, +] + +[package.dependencies] +alibabacloud-tea = "*" + +[[package]] +name = "alibabacloud-oss20190517" +version = "1.0.6" +description = "Alibaba Cloud Object Storage Service (20190517) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_oss20190517-1.0.6-py3-none-any.whl", hash = "sha256:365fda353de6658a1a289f4d70dcd0394e2a8e2921b6b5834ba6d9772121d2f6"}, + {file = "alibabacloud_oss20190517-1.0.6.tar.gz", hash = "sha256:7cd0fb16af613ceb38d2e0e529aa1f58038c7cf59eb67c8c8775ae44ea717852"}, +] + +[package.dependencies] +alibabacloud-gateway-oss = ">=0.0.9,<1.0.0" +alibabacloud-gateway-spi = ">=0.0.1,<1.0.0" +alibabacloud-openapi-util = ">=0.2.1,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.6,<1.0.0" +alibabacloud-tea-util = ">=0.3.11,<1.0.0" + +[[package]] +name = "alibabacloud-ram20150501" +version = "1.2.0" +description = "Alibaba Cloud Resource Access Management (20150501) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_ram20150501-1.2.0-py3-none-any.whl", hash = "sha256:03a0f2a0259848787c1f74e802b486184a88e04183486bd9398766971e5eb00a"}, + {file = "alibabacloud_ram20150501-1.2.0.tar.gz", hash = "sha256:6253513c8880769f4fd5b36fedddb362a9ca628ad9ae9c05c0eeacf5fbc95b42"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-rds20140815" +version = "12.0.0" +description = "Alibaba Cloud rds (20140815) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_rds20140815-12.0.0-py3-none-any.whl", hash = "sha256:0bd7e2018a428d86b1b0681087336e74665b48fc3eb0a13c4f4377ed5eab2b08"}, + {file = "alibabacloud_rds20140815-12.0.0.tar.gz", hash = "sha256:e7421d94f18a914c0a06b0e7fad0daff557713f1c97d415d463a78c1270e9b98"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-sas20181203" +version = "6.1.0" +description = "Alibaba Cloud Threat Detection (20181203) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_sas20181203-6.1.0-py3-none-any.whl", hash = "sha256:1ad735332c50c7961be036b17420d56b5ec3b5557e3aea1daa19491e8b75da20"}, + {file = "alibabacloud_sas20181203-6.1.0.tar.gz", hash = "sha256:e49ffd53e630274a8bf5a8299ca753023ad118510c80f6d9c6fb018b7479bf37"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-sls20201230" +version = "5.9.0" +description = "Alibaba Cloud Log Service (20201230) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_sls20201230-5.9.0-py3-none-any.whl", hash = "sha256:c4ae14096817a9686af5a0ae2389f1f6a8781e60b9edb8643445250cf15c26f1"}, + {file = "alibabacloud_sls20201230-5.9.0.tar.gz", hash = "sha256:bea830b64fbc7ed1719ba386ceeefb120f08d705f03eb0e02409dc6f12a291da"}, +] + +[package.dependencies] +alibabacloud-gateway-sls = ">=0.3.0,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-sts20150401" +version = "1.1.6" +description = "Alibaba Cloud Sts (20150401) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_sts20150401-1.1.6-py3-none-any.whl", hash = "sha256:627f5ca1f86e19b0bf8ce0e99071a36fb65579fad9256fbee38fdc8d500598e9"}, + {file = "alibabacloud_sts20150401-1.1.6.tar.gz", hash = "sha256:c2529b41e0e4531e21cb393e4df346e19fd6d54cc6337d1138dbcd2191438d4c"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.15,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + +[[package]] +name = "alibabacloud-tea" +version = "0.4.3" +description = "The tea module of alibabaCloud Python SDK." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a"}, +] + +[package.dependencies] +aiohttp = ">=3.7.0,<4.0.0" +requests = ">=2.21.0,<3.0.0" + +[[package]] +name = "alibabacloud-tea-openapi" +version = "0.4.1" +description = "Alibaba Cloud openapi SDK Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "alibabacloud_tea_openapi-0.4.1-py3-none-any.whl", hash = "sha256:e46bfa3ca34086d2c357d217a0b7284ecbd4b3bab5c88e075e73aec637b0e4a0"}, + {file = "alibabacloud_tea_openapi-0.4.1.tar.gz", hash = "sha256:2384b090870fdb089c3c40f3fb8cf0145b8c7d6c14abbac521f86a01abb5edaf"}, +] + +[package.dependencies] +alibabacloud-credentials = ">=1.0.2,<2.0.0" +alibabacloud-gateway-spi = ">=0.0.2,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" +cryptography = ">=3.0.0,<45.0.0" +darabonba-core = ">=1.0.3,<2.0.0" + +[[package]] +name = "alibabacloud-tea-util" +version = "0.3.14" +description = "The tea-util module of alibabaCloud Python SDK." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe"}, + {file = "alibabacloud_tea_util-0.3.14.tar.gz", hash = "sha256:708e7c9f64641a3c9e0e566365d2f23675f8d7c2a3e2971d9402ceede0408cdb"}, +] + +[package.dependencies] +alibabacloud-tea = ">=0.3.3" + +[[package]] +name = "alibabacloud-tea-xml" +version = "0.0.3" +description = "The tea-xml module of alibabaCloud Python SDK." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c"}, +] + +[package.dependencies] +alibabacloud-tea = ">=0.4.0" + +[[package]] +name = "alibabacloud-vpc20160428" +version = "6.13.0" +description = "Alibaba Cloud Virtual Private Cloud (20160428) SDK Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "alibabacloud_vpc20160428-6.13.0-py3-none-any.whl", hash = "sha256:933cf1e74322a20a2df27ca6323760d857744a4246eeadc9fb3eae01322fb1c6"}, + {file = "alibabacloud_vpc20160428-6.13.0.tar.gz", hash = "sha256:daf00679a83d422799f9fcf263739fe1f360641675843cbfbe623833fc8b1681"}, +] + +[package.dependencies] +alibabacloud-endpoint-util = ">=0.0.4,<1.0.0" +alibabacloud-openapi-util = ">=0.2.2,<1.0.0" +alibabacloud-tea-openapi = ">=0.3.16,<1.0.0" +alibabacloud-tea-util = ">=0.3.13,<1.0.0" + [[package]] name = "alive-progress" version = "3.3.0" @@ -164,6 +762,32 @@ files = [ about-time = "4.2.1" graphemeu = "0.7.2" +[[package]] +name = "aliyun-log-fastpb" +version = "0.2.0" +description = "Fast protobuf serialization for Aliyun Log using PyO3 and quick-protobuf" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:51633d92d2b349aed4843c0b503454fb4f7d73eeaaa54f82aa5a36c10c064ef5"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d2984aafc61ccbbf1db2589ce90b6d5a26e72dba137fb1fdf7f61ce3faa967c0"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:181fc61ac9934f58b0880fa5617a4a4dc709dba09f8be95b5a71e828f2e48053"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12b8bfddf0bc5450f16f1954c6387a73da124fae10d1205a17a0117e66bb56db"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8fbc83cbaa51d332e5e68871c1200014f1f3de54a8cba4fb55a634ee145cd4e4"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a86a6e11dd227d595fa23f69d30588446af19d045d1003bd1b66b5c9a55485"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd92c0b84ba300c1d1c227204c5f2fff243cea80bc3f9399293385e87c82ee3e"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c07a6d81a3eab6666949240da305236ed2350c305154d7e39fcc121fc52291"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cff4fbdd0edff94adcee1dcabf16daacb5d336a12fc897887aa6e4f0ad25152"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5a451809e2a062accbb8dae8750e507e58806e4a8da48d69215cdeef428e9d63"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:61f09df30232f1f5628d13310cf0e175171399ea1c75a8470e9f9d97b045bfb5"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:a5fbf0d41d8c0c964a3dc8dd0ee2e732f876b803e0ed3432550ef3b84dde84f1"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ae2f84ed0777e00045791044a56413f370afbd5b061505f5ded540c04b19c58e"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-win32.whl", hash = "sha256:967f9656c805602fd9be07d8c2756ad89204c852c99689c3c71aa035416ef42a"}, + {file = "aliyun_log_fastpb-0.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:bbdcf7b85f0f3437c2a8e8a1db0ef5584d21468b7c7a358269a4c651c84f4a54"}, + {file = "aliyun_log_fastpb-0.2.0.tar.gz", hash = "sha256:91c714e76fb941c9a0db6b1aa1f4c56cb1626254ff5444c1179860f5e5b63d93"}, +] + [[package]] name = "amqp" version = "5.3.1" @@ -193,34 +817,88 @@ files = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.12.1" 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"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [package.dependencies] idna = ">=2.8" -sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.26.1)"] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] + +[[package]] +name = "applicationinsights" +version = "0.11.10" +description = "This project extends the Application Insights API surface to support Python." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea"}, + {file = "applicationinsights-0.11.10.tar.gz", hash = "sha256:0b761f3ef0680acf4731906dfc1807faa6f2a57168ae74592db0084a6099f7b3"}, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d"}, + {file = "apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytest-timeout", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "argcomplete" +version = "3.5.3" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argcomplete-3.5.3-py3-none-any.whl", hash = "sha256:2ab2c4a215c59fd6caaff41a869480a23e8f6a5f910b266c1808037f4e375b61"}, + {file = "argcomplete-3.5.3.tar.gz", hash = "sha256:c12bf50eded8aebb298c7b7da7a5ff3ee24dffd9f5281867dfe1424b58c55392"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] [[package]] name = "asgiref" -version = "3.9.1" +version = "3.11.0" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c"}, - {file = "asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142"}, + {file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"}, + {file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"}, ] [package.extras] @@ -253,34 +931,26 @@ files = [ [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - [[package]] name = "authlib" -version = "1.6.5" +version = "1.6.6" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a"}, - {file = "authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b"}, + {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"}, + {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"}, ] [package.dependencies] @@ -313,6 +983,58 @@ files = [ {file = "awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0"}, ] +[[package]] +name = "azure-cli-core" +version = "2.82.0" +description = "Microsoft Azure Command-Line Tools Core Module" +optional = false +python-versions = ">=3.10.0" +groups = ["main"] +files = [ + {file = "azure_cli_core-2.82.0-py3-none-any.whl", hash = "sha256:998792de4e4d44f7f048ef46c5a07c8b30cff291e9b141682fd8a2c01421c826"}, + {file = "azure_cli_core-2.82.0.tar.gz", hash = "sha256:d2de9423d19373665a4cdaae8db3139bcdcbb6cf10bfd417ef4610cb7733f1cd"}, +] + +[package.dependencies] +argcomplete = ">=3.5.2,<3.6.0" +azure-cli-telemetry = "==1.1.0.*" +azure-core = ">=1.37.0,<1.38.0" +azure-mgmt-core = ">=1.2.0,<2" +cryptography = "*" +distro = {version = "*", markers = "sys_platform == \"linux\""} +humanfriendly = ">=10.0,<11.0" +jmespath = "*" +knack = ">=0.11.0,<0.12.0" +microsoft-security-utilities-secret-masker = ">=1.0.0b4,<1.1.0" +msal = [ + {version = "1.34.0b1", extras = ["broker"], markers = "sys_platform == \"win32\""}, + {version = "1.34.0b1", markers = "sys_platform != \"win32\""}, +] +msal-extensions = "1.2.0" +packaging = ">=20.9" +pkginfo = ">=1.5.0.1" +psutil = {version = ">=5.9", markers = "sys_platform != \"cygwin\""} +py-deviceid = "*" +PyJWT = ">=2.1.0" +pyopenssl = ">=17.1.0" +requests = {version = "*", extras = ["socks"]} + +[[package]] +name = "azure-cli-telemetry" +version = "1.1.0" +description = "Microsoft Azure CLI Telemetry Package" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "azure-cli-telemetry-1.1.0.tar.gz", hash = "sha256:d922379cda1b48952be75fb3bd2ac5e7ceecf569492a6088bab77894c624a278"}, + {file = "azure_cli_telemetry-1.1.0-py3-none-any.whl", hash = "sha256:2fc12608c0cf0ea6e69b392af9cab92f1249340b8caff7e9674cf91b3becb337"}, +] + +[package.dependencies] +applicationinsights = ">=0.11.1,<0.12" +portalocker = ">=1.6,<3" + [[package]] name = "azure-common" version = "1.1.28" @@ -327,19 +1049,18 @@ files = [ [[package]] name = "azure-core" -version = "1.35.0" +version = "1.37.0" description = "Microsoft Azure Core Library for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1"}, - {file = "azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c"}, + {file = "azure_core-1.37.0-py3-none-any.whl", hash = "sha256:b3abe2c59e7d6bb18b38c275a5029ff80f98990e7c90a5e646249a56630fcc19"}, + {file = "azure_core-1.37.0.tar.gz", hash = "sha256:7064f2c11e4b97f340e8e8c6d923b822978be3016e46b7bc4aa4b337cfb48aee"}, ] [package.dependencies] requests = ">=2.21.0" -six = ">=1.11.0" typing-extensions = ">=4.6.0" [package.extras] @@ -365,6 +1086,23 @@ msal = ">=1.30.0" msal-extensions = ">=1.2.0" typing-extensions = ">=4.0.0" +[[package]] +name = "azure-keyvault-certificates" +version = "4.10.0" +description = "Microsoft Corporation Key Vault Certificates Client Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "azure_keyvault_certificates-4.10.0-py3-none-any.whl", hash = "sha256:fa76cbc329274cb5f4ab61b0ed7d209d44377df4b4d6be2fd01e741c2fbb83a9"}, + {file = "azure_keyvault_certificates-4.10.0.tar.gz", hash = "sha256:004ff47a73152f9f40f678e5a07719b753a3ca86f0460bfeaaf6a23304872e05"}, +] + +[package.dependencies] +azure-core = ">=1.31.0" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-keyvault-keys" version = "4.10.0" @@ -383,6 +1121,23 @@ cryptography = ">=2.1.4" isodate = ">=0.6.1" typing-extensions = ">=4.0.1" +[[package]] +name = "azure-keyvault-secrets" +version = "4.10.0" +description = "Microsoft Corporation Key Vault Secrets Client Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "azure_keyvault_secrets-4.10.0-py3-none-any.whl", hash = "sha256:9dbde256077a4ee1a847646671580692e3f9bea36bcfc189c3cf2b9a94eb38b9"}, + {file = "azure_keyvault_secrets-4.10.0.tar.gz", hash = "sha256:666fa42892f9cee749563e551a90f060435ab878977c95265173a8246d546a36"}, +] + +[package.dependencies] +azure-core = ">=1.31.0" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-mgmt-apimanagement" version = "5.0.0" @@ -454,6 +1209,23 @@ azure-mgmt-core = ">=1.3.2" isodate = ">=0.6.1" typing-extensions = ">=4.6.0" +[[package]] +name = "azure-mgmt-containerinstance" +version = "10.1.0" +description = "Microsoft Azure Container Instance Client Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "azure-mgmt-containerinstance-10.1.0.zip", hash = "sha256:78d437adb28574f448c838ed5f01f9ced378196098061deb59d9f7031704c17e"}, + {file = "azure_mgmt_containerinstance-10.1.0-py3-none-any.whl", hash = "sha256:ee7977b7b70f2233e44ec6ce8c99027f3f7892bb3452b4bad46df340d9f98959"}, +] + +[package.dependencies] +azure-common = ">=1.1,<2.0" +azure-mgmt-core = ">=1.3.2,<2.0.0" +isodate = ">=0.6.1,<1.0.0" + [[package]] name = "azure-mgmt-containerregistry" version = "12.0.0" @@ -540,6 +1312,60 @@ azure-common = ">=1.1,<2.0" azure-mgmt-core = ">=1.3.2,<2.0.0" isodate = ">=0.6.1,<1.0.0" +[[package]] +name = "azure-mgmt-datafactory" +version = "9.2.0" +description = "Microsoft Azure Data Factory Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_datafactory-9.2.0-py3-none-any.whl", hash = "sha256:d870a7a6099227e91d1c258a956c2aa32c2ea4c0a4409913d8f215887349f128"}, + {file = "azure_mgmt_datafactory-9.2.0.tar.gz", hash = "sha256:5132e9c24c441ac225f2a60225924baa55079ca81eff7db99a70d661d64bb0d7"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + +[[package]] +name = "azure-mgmt-eventgrid" +version = "10.4.0" +description = "Microsoft Azure Event Grid Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_eventgrid-10.4.0-py3-none-any.whl", hash = "sha256:5e4637245bbff33298d5f427971b870dbb03d873a3ef68f328190a7b7a38c56f"}, + {file = "azure_mgmt_eventgrid-10.4.0.tar.gz", hash = "sha256:303e5e27cf4bb5ec833ba4e5a9ef70b5bc410e190412ec47cde59d82e413fb7e"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + +[[package]] +name = "azure-mgmt-eventhub" +version = "11.2.0" +description = "Microsoft Azure Event Hub Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_eventhub-11.2.0-py3-none-any.whl", hash = "sha256:a7e2618eca58d8e52c7ff7d4a04a4fae12685351746e6d01b933b43e7ea3b906"}, + {file = "azure_mgmt_eventhub-11.2.0.tar.gz", hash = "sha256:31c47f18f73d2d83345cde5909568e28858c2548a35b10e23194b4767a9ce7e3"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-mgmt-keyvault" version = "10.3.1" @@ -575,6 +1401,23 @@ azure-common = ">=1.1,<2.0" azure-mgmt-core = ">=1.2.0,<2.0.0" msrest = ">=0.6.21" +[[package]] +name = "azure-mgmt-logic" +version = "10.0.0" +description = "Microsoft Azure Logic Apps Management Client Library for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "azure-mgmt-logic-10.0.0.zip", hash = "sha256:b3fa4864f14aaa7af41d778d925f051ed29b6016f46344765ecd0f49d0f04dd6"}, + {file = "azure_mgmt_logic-10.0.0-py3-none-any.whl", hash = "sha256:525c78afedf3edb35eb0a16152c8beba89769ee1bc6af01bcdc42842a551e443"}, +] + +[package.dependencies] +azure-common = ">=1.1,<2.0" +azure-mgmt-core = ">=1.3.0,<2.0.0" +msrest = ">=0.6.21" + [[package]] name = "azure-mgmt-monitor" version = "6.0.2" @@ -610,6 +1453,24 @@ azure-mgmt-core = ">=1.3.2" isodate = ">=0.6.1" typing-extensions = ">=4.6.0" +[[package]] +name = "azure-mgmt-postgresqlflexibleservers" +version = "1.1.0" +description = "Microsoft Azure Postgresqlflexibleservers Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_postgresqlflexibleservers-1.1.0-py3-none-any.whl", hash = "sha256:87ddb5a5e6d12c45769485d234cfe0322140e3a0a7636d0e61fb00ac544b5d20"}, + {file = "azure_mgmt_postgresqlflexibleservers-1.1.0.tar.gz", hash = "sha256:9ede9d8ba63e9d2879cb74adc903c649af3bc5460a02787287b0cd18d754af14"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-mgmt-rdbms" version = "10.1.0" @@ -767,6 +1628,23 @@ azure-common = ">=1.1,<2.0" azure-mgmt-core = ">=1.3.2,<2.0.0" msrest = ">=0.7.1" +[[package]] +name = "azure-mgmt-synapse" +version = "2.0.0" +description = "Microsoft Azure Synapse Management Client Library for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "azure-mgmt-synapse-2.0.0.zip", hash = "sha256:bec6bdfaeb55b4fdd159f2055e8875bf50a720bb0fce80a816e92a2359b898c8"}, + {file = "azure_mgmt_synapse-2.0.0-py2.py3-none-any.whl", hash = "sha256:e901274009be843a7bf2eedeab32c0941fabb2addea9a1ad1560395073965f0f"}, +] + +[package.dependencies] +azure-common = ">=1.1,<2.0" +azure-mgmt-core = ">=1.2.0,<2.0.0" +msrest = ">=0.6.21" + [[package]] name = "azure-mgmt-web" version = "8.0.0" @@ -823,6 +1701,36 @@ typing-extensions = ">=4.6.0" [package.extras] aio = ["azure-core[aio] (>=1.30.0)"] +[[package]] +name = "azure-synapse-artifacts" +version = "0.21.0" +description = "Microsoft Azure Synapse Artifacts Client Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "azure_synapse_artifacts-0.21.0-py3-none-any.whl", hash = "sha256:3311919df13a2b42f1fb9debf5d512080c35d64d02b9f84ff944848835289a8d"}, + {file = "azure_synapse_artifacts-0.21.0.tar.gz", hash = "sha256:d7e37516cf8569e03c604d921e3407d7140cf7523b67b67f757caf999e3c8ee7"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.6.0" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + +[[package]] +name = "backoff" +version = "2.2.1" +description = "Function decoration for backoff and retry" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + [[package]] name = "bandit" version = "1.7.9" @@ -850,14 +1758,14 @@ yaml = ["PyYAML"] [[package]] name = "billiard" -version = "4.2.1" +version = "4.2.4" description = "Python multiprocessing fork with improvements and bugfixes" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb"}, - {file = "billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f"}, + {file = "billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5"}, + {file = "billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f"}, ] [[package]] @@ -874,34 +1782,34 @@ files = [ [[package]] name = "boto3" -version = "1.39.15" +version = "1.40.61" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.39.15-py3-none-any.whl", hash = "sha256:38fc54576b925af0075636752de9974e172c8a2cf7133400e3e09b150d20fb6a"}, - {file = "boto3-1.39.15.tar.gz", hash = "sha256:b4483625f0d8c35045254dee46cd3c851bbc0450814f20b9b25bee1b5c0d8409"}, + {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, + {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, ] [package.dependencies] -botocore = ">=1.39.15,<1.40.0" +botocore = ">=1.40.61,<1.41.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.13.0,<0.14.0" +s3transfer = ">=0.14.0,<0.15.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.39.15" +version = "1.40.61" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.39.15-py3-none-any.whl", hash = "sha256:eb9cfe918ebfbfb8654e1b153b29f0c129d586d2c0d7fb4032731d49baf04cff"}, - {file = "botocore-1.39.15.tar.gz", hash = "sha256:2aa29a717f14f8c7ca058c2e297aaed0aa10ecea24b91514eee802814d1b7600"}, + {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"}, + {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"}, ] [package.dependencies] @@ -910,270 +1818,397 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] -crt = ["awscrt (==0.23.8)"] +crt = ["awscrt (==0.27.6)"] [[package]] -name = "cachetools" -version = "5.5.2" -description = "Extensible memoizing collections and decorators" +name = "cartography" +version = "0.126.1" +description = "Explore assets and their relationships across your technical infrastructure." optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["main"] -files = [ - {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, - {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, -] +files = [] +develop = false + +[package.dependencies] +adal = ">=1.2.4" +aioboto3 = ">=13.0.0" +azure-cli-core = ">=2.26.0" +azure-identity = ">=1.5.0" +azure-keyvault-certificates = ">=4.0.0" +azure-keyvault-keys = ">=4.0.0" +azure-keyvault-secrets = ">=4.0.0" +azure-mgmt-authorization = ">=0.60.0" +azure-mgmt-compute = ">=5.0.0" +azure-mgmt-containerinstance = ">=10.0.0" +azure-mgmt-containerservice = ">=30.0.0" +azure-mgmt-cosmosdb = ">=6.0.0" +azure-mgmt-datafactory = ">=8.0.0" +azure-mgmt-eventgrid = ">=10.0.0" +azure-mgmt-eventhub = ">=10.1.0" +azure-mgmt-keyvault = ">=10.0.0" +azure-mgmt-logic = ">=10.0.0" +azure-mgmt-monitor = ">=3.0.0" +azure-mgmt-network = ">=25.0.0" +azure-mgmt-resource = ">=10.2.0" +azure-mgmt-security = ">=5.0.0" +azure-mgmt-sql = ">=3.0.1,<4" +azure-mgmt-storage = ">=16.0.0" +azure-mgmt-synapse = ">=2.0.0" +azure-mgmt-web = ">=7.0.0" +azure-synapse-artifacts = ">=0.17.0" +backoff = ">=2.1.2" +boto3 = ">=1.15.1" +botocore = ">=1.18.1" +cloudflare = ">=4.1.0,<5.0.0" +crowdstrike-falconpy = ">=0.5.1" +dnspython = ">=1.15.0" +duo-client = "*" +google-api-python-client = ">=1.7.8" +google-auth = ">=2.37.0" +google-cloud-asset = ">=1.0.0" +google-cloud-resource-manager = ">=1.14.2" +httpx = ">=0.24.0" +kubernetes = ">=22.6.0" +marshmallow = ">=3.0.0rc7" +msgraph-sdk = "*" +msrestazure = ">=0.6.4" +neo4j = ">=5.28.2,<6.0.0" +oci = ">=2.71.0" +okta = "<1.0.0" +packaging = "*" +pdpyras = ">=4.3.0" +policyuniverse = ">=1.1.0.0" +python-dateutil = "*" +python-digitalocean = ">=1.16.0" +pyyaml = ">=5.3.1" +requests = ">=2.22.0" +scaleway = ">=2.10.0" +slack-sdk = ">=3.37.0" +statsd = "*" +typer = ">=0.9.0" +types-aiobotocore-ecr = "*" +xmltodict = "*" + +[package.source] +type = "git" +url = "https://github.com/prowler-cloud/cartography" +reference = "0.126.1" +resolved_reference = "9e3dd6459bec027461e1fe998c034a0f3fb83e3d" [[package]] name = "celery" -version = "5.4.0" +version = "5.6.2" description = "Distributed Task Queue." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64"}, - {file = "celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706"}, + {file = "celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5"}, + {file = "celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b"}, ] [package.dependencies] -billiard = ">=4.2.0,<5.0" +billiard = ">=4.2.1,<5.0" click = ">=8.1.2,<9.0" click-didyoumean = ">=0.3.0" click-plugins = ">=1.1.1" click-repl = ">=0.2.0" -kombu = ">=5.3.4,<6.0" -pytest-celery = {version = ">=1.0.0", extras = ["all"], optional = true, markers = "extra == \"pytest\""} +kombu = ">=5.6.0" python-dateutil = ">=2.8.2" -tzdata = ">=2022.7" +tzlocal = "*" vine = ">=5.1.0,<6.0" [package.extras] arangodb = ["pyArango (>=2.0.2)"] -auth = ["cryptography (==42.0.5)"] -azureblockblob = ["azure-storage-blob (>=12.15.0)"] +auth = ["cryptography (==46.0.3)"] +azureblockblob = ["azure-identity (>=1.19.0)", "azure-storage-blob (>=12.15.0)"] brotli = ["brotli (>=1.0.0) ; platform_python_implementation == \"CPython\"", "brotlipy (>=0.7.0) ; platform_python_implementation == \"PyPy\""] cassandra = ["cassandra-driver (>=3.25.0,<4)"] consul = ["python-consul2 (==0.1.5)"] cosmosdbsql = ["pydocumentdb (==2.3.5)"] couchbase = ["couchbase (>=3.0.0) ; platform_python_implementation != \"PyPy\" and (platform_system != \"Windows\" or python_version < \"3.10\")"] -couchdb = ["pycouchdb (==1.14.2)"] +couchdb = ["pycouchdb (==1.16.0)"] django = ["Django (>=2.2.28)"] dynamodb = ["boto3 (>=1.26.143)"] -elasticsearch = ["elastic-transport (<=8.13.0)", "elasticsearch (<=8.13.0)"] +elasticsearch = ["elastic-transport (<=9.1.0)", "elasticsearch (<=9.1.2)"] eventlet = ["eventlet (>=0.32.0) ; python_version < \"3.10\""] -gcs = ["google-cloud-storage (>=2.10.0)"] +gcs = ["google-cloud-firestore (==2.22.0)", "google-cloud-storage (>=2.10.0)", "grpcio (==1.75.1)"] gevent = ["gevent (>=1.5.0)"] librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] memcache = ["pylibmc (==1.6.3) ; platform_system != \"Windows\""] -mongodb = ["pymongo[srv] (>=4.0.2)"] -msgpack = ["msgpack (==1.0.8)"] +mongodb = ["kombu[mongodb]"] +msgpack = ["kombu[msgpack]"] +pydantic = ["pydantic (>=2.12.0a1) ; python_version >= \"3.14\"", "pydantic (>=2.4) ; python_version < \"3.14\""] pymemcache = ["python-memcached (>=1.61)"] pyro = ["pyro4 (==4.82) ; python_version < \"3.11\""] -pytest = ["pytest-celery[all] (>=1.0.0)"] -redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] +pytest = ["pytest-celery[all] (>=1.2.0,<1.3.0)"] +redis = ["kombu[redis]"] s3 = ["boto3 (>=1.26.143)"] -slmq = ["softlayer-messaging (>=1.0.3)"] -solar = ["ephem (==4.1.5) ; platform_python_implementation != \"PyPy\""] -sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] -sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.3.4)", "pycurl (>=7.43.0.5) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16)"] -tblib = ["tblib (>=1.3.0) ; python_version < \"3.8.0\"", "tblib (>=1.5.0) ; python_version >= \"3.8.0\""] -yaml = ["PyYAML (>=3.10)"] +slmq = ["softlayer_messaging (>=1.0.3)"] +solar = ["ephem (==4.2) ; platform_python_implementation != \"PyPy\""] +sqlalchemy = ["kombu[sqlalchemy]"] +sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.5.0)", "pycurl (>=7.43.0.5,<7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version < \"3.9\"", "pycurl (>=7.45.4) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "urllib3 (>=1.26.16)"] +tblib = ["tblib (==3.2.2)"] +yaml = ["kombu[yaml]"] zookeeper = ["kazoo (>=1.3.1)"] -zstd = ["zstandard (==0.22.0)"] +zstd = ["zstandard (==0.23.0)"] [[package]] name = "certifi" -version = "2025.8.3" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, - {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] name = "cffi" -version = "1.17.1" +version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] +markers = "platform_python_implementation != \"PyPy\"" files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] -pycparser = "*" +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.3" +version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, - {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, - {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, - {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, - {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, - {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, - {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, - {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, - {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, - {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "circuitbreaker" +version = "2.1.3" +description = "Python Circuit Breaker pattern implementation" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1"}, + {file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"}, ] [[package]] name = "click" -version = "8.2.1" +version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, ] [package.dependencies] @@ -1231,6 +2266,26 @@ prompt-toolkit = ">=3.0.36" [package.extras] testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] +[[package]] +name = "cloudflare" +version = "4.3.1" +description = "The official Python library for the cloudflare API" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6"}, + {file = "cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +httpx = ">=0.23.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +typing-extensions = ">=4.10,<5" + [[package]] name = "colorama" version = "0.4.6" @@ -1242,7 +2297,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "contextlib2" @@ -1415,58 +2470,87 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cron-descriptor" -version = "1.4.5" +version = "2.0.6" description = "A Python library that converts cron expressions into human readable strings." optional = false -python-versions = "*" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013"}, - {file = "cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca"}, + {file = "cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6"}, + {file = "cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157"}, ] +[package.dependencies] +typing_extensions = "*" + [package.extras] -dev = ["polib"] +dev = ["mypy", "polib", "ruff"] +test = ["pytest"] + +[[package]] +name = "crowdstrike-falconpy" +version = "1.6.0" +description = "The CrowdStrike Falcon SDK for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "crowdstrike_falconpy-1.6.0-py3-none-any.whl", hash = "sha256:2cae39e42510f473e77f3a647d56aa8f55d9de0a1963bad353b195cb5ae61ea4"}, + {file = "crowdstrike_falconpy-1.6.0.tar.gz", hash = "sha256:663402ac9bc56625478460b4865446371de2d74f5e96cb5d16672119c113346f"}, +] + +[package.dependencies] +requests = "*" +urllib3 = "*" + +[package.extras] +dev = ["bandit", "coverage", "flake8", "pydocstyle", "pylint", "pytest", "pytest-cov"] [[package]] name = "cryptography" -version = "44.0.1" +version = "44.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" groups = ["main", "dev"] files = [ - {file = "cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009"}, - {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f"}, - {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2"}, - {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911"}, - {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69"}, - {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026"}, - {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd"}, - {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0"}, - {file = "cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf"}, - {file = "cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864"}, - {file = "cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a"}, - {file = "cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00"}, - {file = "cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008"}, - {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862"}, - {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3"}, - {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7"}, - {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a"}, - {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c"}, - {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62"}, - {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41"}, - {file = "cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b"}, - {file = "cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7"}, - {file = "cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9"}, - {file = "cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f"}, - {file = "cryptography-44.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1f9a92144fa0c877117e9748c74501bea842f93d21ee00b0cf922846d9d0b183"}, - {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:610a83540765a8d8ce0f351ce42e26e53e1f774a6efb71eb1b41eb01d01c3d12"}, - {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5fed5cd6102bb4eb843e3315d2bf25fede494509bddadb81e03a859c1bc17b83"}, - {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f4daefc971c2d1f82f03097dc6f216744a6cd2ac0f04c68fb935ea2ba2a0d420"}, - {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94f99f2b943b354a5b6307d7e8d19f5c423a794462bde2bf310c770ba052b1c4"}, - {file = "cryptography-44.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d9c5b9f698a83c8bd71e0f4d3f9f839ef244798e5ffe96febfa9714717db7af7"}, - {file = "cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14"}, + {file = "cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d"}, + {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904"}, + {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44"}, + {file = "cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d"}, + {file = "cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d"}, + {file = "cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f"}, + {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5"}, + {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b"}, + {file = "cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028"}, + {file = "cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c"}, + {file = "cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053"}, ] [package.dependencies] @@ -1479,7 +2563,7 @@ nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -1498,6 +2582,22 @@ files = [ docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] tests = ["pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "darabonba-core" +version = "1.0.5" +description = "The darabonba module of alibabaCloud Python SDK." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c"}, +] + +[package.dependencies] +aiohttp = ">=3.7.0,<4.0.0" +alibabacloud-tea = "*" +requests = ">=2.21.0,<3.0.0" + [[package]] name = "dash" version = "3.1.1" @@ -1550,54 +2650,70 @@ pandas = ["numpy (>=2.0.2)", "pandas (>=2.2.3)"] [[package]] name = "debugpy" -version = "1.8.16" +version = "1.8.20" description = "An implementation of the Debug Adapter Protocol for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "debugpy-1.8.16-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:2a3958fb9c2f40ed8ea48a0d34895b461de57a1f9862e7478716c35d76f56c65"}, - {file = "debugpy-1.8.16-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5ca7314042e8a614cc2574cd71f6ccd7e13a9708ce3c6d8436959eae56f2378"}, - {file = "debugpy-1.8.16-cp310-cp310-win32.whl", hash = "sha256:8624a6111dc312ed8c363347a0b59c5acc6210d897e41a7c069de3c53235c9a6"}, - {file = "debugpy-1.8.16-cp310-cp310-win_amd64.whl", hash = "sha256:fee6db83ea5c978baf042440cfe29695e1a5d48a30147abf4c3be87513609817"}, - {file = "debugpy-1.8.16-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:67371b28b79a6a12bcc027d94a06158f2fde223e35b5c4e0783b6f9d3b39274a"}, - {file = "debugpy-1.8.16-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2abae6dd02523bec2dee16bd6b0781cccb53fd4995e5c71cc659b5f45581898"}, - {file = "debugpy-1.8.16-cp311-cp311-win32.whl", hash = "sha256:f8340a3ac2ed4f5da59e064aa92e39edd52729a88fbde7bbaa54e08249a04493"}, - {file = "debugpy-1.8.16-cp311-cp311-win_amd64.whl", hash = "sha256:70f5fcd6d4d0c150a878d2aa37391c52de788c3dc680b97bdb5e529cb80df87a"}, - {file = "debugpy-1.8.16-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:b202e2843e32e80b3b584bcebfe0e65e0392920dc70df11b2bfe1afcb7a085e4"}, - {file = "debugpy-1.8.16-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64473c4a306ba11a99fe0bb14622ba4fbd943eb004847d9b69b107bde45aa9ea"}, - {file = "debugpy-1.8.16-cp312-cp312-win32.whl", hash = "sha256:833a61ed446426e38b0dd8be3e9d45ae285d424f5bf6cd5b2b559c8f12305508"}, - {file = "debugpy-1.8.16-cp312-cp312-win_amd64.whl", hash = "sha256:75f204684581e9ef3dc2f67687c3c8c183fde2d6675ab131d94084baf8084121"}, - {file = "debugpy-1.8.16-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:85df3adb1de5258dca910ae0bb185e48c98801ec15018a263a92bb06be1c8787"}, - {file = "debugpy-1.8.16-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee89e948bc236a5c43c4214ac62d28b29388453f5fd328d739035e205365f0b"}, - {file = "debugpy-1.8.16-cp313-cp313-win32.whl", hash = "sha256:cf358066650439847ec5ff3dae1da98b5461ea5da0173d93d5e10f477c94609a"}, - {file = "debugpy-1.8.16-cp313-cp313-win_amd64.whl", hash = "sha256:b5aea1083f6f50023e8509399d7dc6535a351cc9f2e8827d1e093175e4d9fa4c"}, - {file = "debugpy-1.8.16-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:2801329c38f77c47976d341d18040a9ac09d0c71bf2c8b484ad27c74f83dc36f"}, - {file = "debugpy-1.8.16-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:687c7ab47948697c03b8f81424aa6dc3f923e6ebab1294732df1ca9773cc67bc"}, - {file = "debugpy-1.8.16-cp38-cp38-win32.whl", hash = "sha256:a2ba6fc5d7c4bc84bcae6c5f8edf5988146e55ae654b1bb36fecee9e5e77e9e2"}, - {file = "debugpy-1.8.16-cp38-cp38-win_amd64.whl", hash = "sha256:d58c48d8dbbbf48a3a3a638714a2d16de537b0dace1e3432b8e92c57d43707f8"}, - {file = "debugpy-1.8.16-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:135ccd2b1161bade72a7a099c9208811c137a150839e970aeaf121c2467debe8"}, - {file = "debugpy-1.8.16-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:211238306331a9089e253fd997213bc4a4c65f949271057d6695953254095376"}, - {file = "debugpy-1.8.16-cp39-cp39-win32.whl", hash = "sha256:88eb9ffdfb59bf63835d146c183d6dba1f722b3ae2a5f4b9fc03e925b3358922"}, - {file = "debugpy-1.8.16-cp39-cp39-win_amd64.whl", hash = "sha256:c2c47c2e52b40449552843b913786499efcc3dbc21d6c49287d939cd0dbc49fd"}, - {file = "debugpy-1.8.16-py2.py3-none-any.whl", hash = "sha256:19c9521962475b87da6f673514f7fd610328757ec993bf7ec0d8c96f9a325f9e"}, - {file = "debugpy-1.8.16.tar.gz", hash = "sha256:31e69a1feb1cf6b51efbed3f6c9b0ef03bc46ff050679c4be7ea6d2e23540870"}, + {file = "debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64"}, + {file = "debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642"}, + {file = "debugpy-1.8.20-cp310-cp310-win32.whl", hash = "sha256:c29dd9d656c0fbd77906a6e6a82ae4881514aa3294b94c903ff99303e789b4a2"}, + {file = "debugpy-1.8.20-cp310-cp310-win_amd64.whl", hash = "sha256:3ca85463f63b5dd0aa7aaa933d97cbc47c174896dcae8431695872969f981893"}, + {file = "debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b"}, + {file = "debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344"}, + {file = "debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec"}, + {file = "debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb"}, + {file = "debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d"}, + {file = "debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b"}, + {file = "debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390"}, + {file = "debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3"}, + {file = "debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a"}, + {file = "debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf"}, + {file = "debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393"}, + {file = "debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7"}, + {file = "debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173"}, + {file = "debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad"}, + {file = "debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f"}, + {file = "debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be"}, + {file = "debugpy-1.8.20-cp38-cp38-macosx_15_0_x86_64.whl", hash = "sha256:b773eb026a043e4d9c76265742bc846f2f347da7e27edf7fe97716ea19d6bfc5"}, + {file = "debugpy-1.8.20-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:20d6e64ea177ab6732bffd3ce8fc6fb8879c60484ce14c3b3fe183b1761459ca"}, + {file = "debugpy-1.8.20-cp38-cp38-win32.whl", hash = "sha256:0dfd9adb4b3c7005e9c33df430bcdd4e4ebba70be533e0066e3a34d210041b66"}, + {file = "debugpy-1.8.20-cp38-cp38-win_amd64.whl", hash = "sha256:60f89411a6c6afb89f18e72e9091c3dfbcfe3edc1066b2043a1f80a3bbb3e11f"}, + {file = "debugpy-1.8.20-cp39-cp39-macosx_15_0_x86_64.whl", hash = "sha256:bff8990f040dacb4c314864da95f7168c5a58a30a66e0eea0fb85e2586a92cd6"}, + {file = "debugpy-1.8.20-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:70ad9ae09b98ac307b82c16c151d27ee9d68ae007a2e7843ba621b5ce65333b5"}, + {file = "debugpy-1.8.20-cp39-cp39-win32.whl", hash = "sha256:9eeed9f953f9a23850c85d440bf51e3c56ed5d25f8560eeb29add815bd32f7ee"}, + {file = "debugpy-1.8.20-cp39-cp39-win_amd64.whl", hash = "sha256:760813b4fff517c75bfe7923033c107104e76acfef7bda011ffea8736e9a66f8"}, + {file = "debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7"}, + {file = "debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33"}, +] + +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, ] [[package]] name = "deprecated" -version = "1.2.18" +version = "1.3.1" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" groups = ["main"] files = [ - {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, - {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, + {file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"}, + {file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"}, ] [package.dependencies] -wrapt = ">=1.10,<2" +wrapt = ">=1.10,<3" [package.extras] dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] @@ -1624,14 +2740,14 @@ word-list = ["pyahocorasick"] [[package]] name = "dill" -version = "0.4.0" +version = "0.4.1" description = "serialize all of Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, - {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, + {file = "dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d"}, + {file = "dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa"}, ] [package.extras] @@ -1671,14 +2787,14 @@ with-social = ["django-allauth[socialaccount] (>=64.0.0)"] [[package]] name = "django" -version = "5.1.13" +version = "5.1.15" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "django-5.1.13-py3-none-any.whl", hash = "sha256:06f257f79dc4c17f3f9e23b106a4c5ed1335abecbe731e83c598c941d14fbeed"}, - {file = "django-5.1.13.tar.gz", hash = "sha256:543ff21679f15e80edfc01fe7ea35f8291b6d4ea589433882913626a7c1cf929"}, + {file = "django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432"}, + {file = "django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947"}, ] [package.dependencies] @@ -1692,13 +2808,14 @@ bcrypt = ["bcrypt"] [[package]] name = "django-allauth" -version = "65.11.0" +version = "65.14.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.14.0-py3-none-any.whl", hash = "sha256:448f5f7877f95fcbe1657256510fe7822d7871f202521a29e23ef937f3325a97"}, + {file = "django_allauth-65.14.0.tar.gz", hash = "sha256:5529227aba2b1377d900e9274a3f24496c645e65400fbae3cad5789944bc4d0b"}, ] [package.dependencies] @@ -1710,6 +2827,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)"] @@ -1859,18 +2977,18 @@ sqlparse = "*" [[package]] name = "django-timezone-field" -version = "7.1" +version = "7.2.1" description = "A Django app providing DB, form, and REST framework fields for zoneinfo and pytz timezone objects." optional = false python-versions = "<4.0,>=3.8" groups = ["main"] files = [ - {file = "django_timezone_field-7.1-py3-none-any.whl", hash = "sha256:93914713ed882f5bccda080eda388f7006349f25930b6122e9b07bf8db49c4b4"}, - {file = "django_timezone_field-7.1.tar.gz", hash = "sha256:b3ef409d88a2718b566fabe10ea996f2838bc72b22d3a2900c0aa905c761380c"}, + {file = "django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44"}, + {file = "django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e"}, ] [package.dependencies] -Django = ">=3.2,<6.0" +Django = ">=3.2,<6.1" [[package]] name = "djangorestframework" @@ -1936,24 +3054,24 @@ test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "p [[package]] name = "dnspython" -version = "2.7.0" +version = "2.8.0" description = "DNS toolkit" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, - {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, ] [package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=43)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=1.0.0)"] -idna = ["idna (>=3.7)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] [[package]] name = "docker" @@ -1978,6 +3096,31 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] ssh = ["paramiko (>=2.4.3)"] websockets = ["websocket-client (>=1.3.0)"] +[[package]] +name = "dogpile-cache" +version = "1.5.0" +description = "A caching front-end based on the Dogpile lock." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d"}, + {file = "dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec"}, +] + +[package.dependencies] +decorator = ">=4.0.0" +stevedore = ">=3.0.0" + +[package.extras] +bmemcached = ["python-binary-memcached"] +memcached = ["python-memcached"] +pifpaf = ["pifpaf (>=3.3.0)"] +pylibmc = ["pylibmc"] +pymemcache = ["pymemcache"] +redis = ["redis"] +valkey = ["valkey"] + [[package]] name = "dparse" version = "0.6.4" @@ -2018,14 +3161,14 @@ packaging = ">=24.1" [[package]] name = "drf-nested-routers" -version = "0.94.2" +version = "0.95.0" description = "Nested resources for the Django Rest Framework" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "drf_nested_routers-0.94.2-py2.py3-none-any.whl", hash = "sha256:74dbdceeae2a32f8668ba0df8e3eeabeb9b1c64d2621d914901ae653e4e3bcff"}, - {file = "drf_nested_routers-0.94.2.tar.gz", hash = "sha256:aa70923b716dc47cd93b8129b06be6c15706b405cf5f718f59cb8eed01de59cc"}, + {file = "drf_nested_routers-0.95.0-py2.py3-none-any.whl", hash = "sha256:dd489c33d667aaa81383ffaa8c74781d2b353d8f0795716ae37fc59ee297b7c4"}, + {file = "drf_nested_routers-0.95.0.tar.gz", hash = "sha256:815978f802e578fd7035c74040c104909cbe97615de89a275d77e928f4029891"}, ] [package.dependencies] @@ -2144,6 +3287,21 @@ merge = ["merge3"] paramiko = ["paramiko"] pgp = ["gpg"] +[[package]] +name = "duo-client" +version = "5.5.0" +description = "Reference client for Duo Security APIs" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "duo_client-5.5.0-py3-none-any.whl", hash = "sha256:4fbf1e97a2b25ef64e9f88171ab817162cf45bafc1c63026af4883baf8892a12"}, + {file = "duo_client-5.5.0.tar.gz", hash = "sha256:303109e047fe7525ba4fc4a294c1f3deb4125066e89c10d33f7430378867b1d6"}, +] + +[package.dependencies] +setuptools = "*" + [[package]] name = "durationpy" version = "0.10" @@ -2174,14 +3332,14 @@ idna = ">=2.0.0" [[package]] name = "execnet" -version = "2.1.1" +version = "2.1.2" description = "execnet: rapid multi-Python deployment" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, - {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, ] [package.extras] @@ -2189,21 +3347,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" @@ -2230,83 +3383,75 @@ dotenv = ["python-dotenv"] [[package]] name = "fonttools" -version = "4.60.1" +version = "4.61.1" description = "Tools to manipulate font files" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28"}, - {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15"}, - {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c"}, - {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea"}, - {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652"}, - {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a"}, - {file = "fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce"}, - {file = "fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038"}, - {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f"}, - {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2"}, - {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914"}, - {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1"}, - {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d"}, - {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa"}, - {file = "fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258"}, - {file = "fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf"}, - {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc"}, - {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877"}, - {file = "fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c"}, - {file = "fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401"}, - {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903"}, - {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed"}, - {file = "fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6"}, - {file = "fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383"}, - {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb"}, - {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4"}, - {file = "fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c"}, - {file = "fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77"}, - {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199"}, - {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c"}, - {file = "fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272"}, - {file = "fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac"}, - {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3"}, - {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85"}, - {file = "fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537"}, - {file = "fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003"}, - {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08"}, - {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99"}, - {file = "fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6"}, - {file = "fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987"}, - {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299"}, - {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01"}, - {file = "fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801"}, - {file = "fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc"}, - {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc"}, - {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed"}, - {file = "fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259"}, - {file = "fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c"}, - {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2"}, - {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036"}, - {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856"}, - {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7"}, - {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854"}, - {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da"}, - {file = "fonttools-4.60.1-cp39-cp39-win32.whl", hash = "sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a"}, - {file = "fonttools-4.60.1-cp39-cp39-win_amd64.whl", hash = "sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217"}, - {file = "fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb"}, - {file = "fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9"}, + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"}, + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"}, + {file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"}, + {file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"}, + {file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"}, + {file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"}, + {file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"}, + {file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"}, + {file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"}, + {file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"}, + {file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"}, + {file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"}, + {file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"}, + {file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"}, + {file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"}, + {file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"}, ] [package.extras] -all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0) ; python_version <= \"3.12\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] graphite = ["lz4 (>=1.7.4.2)"] interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] lxml = ["lxml (>=4.0)"] pathops = ["skia-pathops (>=0.5.0)"] plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.23.0)"] +repacker = ["uharfbuzz (>=0.45.0)"] symfont = ["sympy"] type1 = ["xattr ; sys_platform == \"darwin\""] -unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] +unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] [[package]] @@ -2326,140 +3471,234 @@ python-dateutil = ">=2.7" [[package]] name = "frozenlist" -version = "1.7.0" +version = "1.8.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, - {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, - {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, - {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, - {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, - {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, - {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, - {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, - {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71"}, - {file = "frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87"}, - {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd"}, - {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb"}, - {file = "frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e"}, - {file = "frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63"}, - {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, - {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] +[[package]] +name = "gevent" +version = "25.9.1" +description = "Coroutine-based network library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "gevent-25.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:856b990be5590e44c3a3dc6c8d48a40eaccbb42e99d2b791d11d1e7711a4297e"}, + {file = "gevent-25.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fe1599d0b30e6093eb3213551751b24feeb43db79f07e89d98dd2f3330c9063e"}, + {file = "gevent-25.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:f0d8b64057b4bf1529b9ef9bd2259495747fba93d1f836c77bfeaacfec373fd0"}, + {file = "gevent-25.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56cbc820e3136ba52cd690bdf77e47a4c239964d5f80dc657c1068e0fe9521c"}, + {file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5fa9ce5122c085983e33e0dc058f81f5264cebe746de5c401654ab96dddfca8"}, + {file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:03c74fec58eda4b4edc043311fca8ba4f8744ad1632eb0a41d5ec25413581975"}, + {file = "gevent-25.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8ae9f895e8651d10b0a8328a61c9c53da11ea51b666388aa99b0ce90f9fdc27"}, + {file = "gevent-25.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5aff9e8342dc954adb9c9c524db56c2f3557999463445ba3d9cbe3dada7b7"}, + {file = "gevent-25.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1cdf6db28f050ee103441caa8b0448ace545364f775059d5e2de089da975c457"}, + {file = "gevent-25.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:812debe235a8295be3b2a63b136c2474241fa5c58af55e6a0f8cfc29d4936235"}, + {file = "gevent-25.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b28b61ff9216a3d73fe8f35669eefcafa957f143ac534faf77e8a19eb9e6883a"}, + {file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e4b6278b37373306fc6b1e5f0f1cf56339a1377f67c35972775143d8d7776ff"}, + {file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d99f0cb2ce43c2e8305bf75bee61a8bde06619d21b9d0316ea190fc7a0620a56"}, + {file = "gevent-25.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:72152517ecf548e2f838c61b4be76637d99279dbaa7e01b3924df040aa996586"}, + {file = "gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86"}, + {file = "gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692"}, + {file = "gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2"}, + {file = "gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74"}, + {file = "gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51"}, + {file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5"}, + {file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f"}, + {file = "gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3"}, + {file = "gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed"}, + {file = "gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245"}, + {file = "gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82"}, + {file = "gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48"}, + {file = "gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7"}, + {file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47"}, + {file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117"}, + {file = "gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa"}, + {file = "gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1"}, + {file = "gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356"}, + {file = "gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8"}, + {file = "gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e"}, + {file = "gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c"}, + {file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f"}, + {file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6"}, + {file = "gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7"}, + {file = "gevent-25.9.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f18f80aef6b1f6907219affe15b36677904f7cfeed1f6a6bc198616e507ae2d7"}, + {file = "gevent-25.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b274a53e818124a281540ebb4e7a2c524778f745b7a99b01bdecf0ca3ac0ddb0"}, + {file = "gevent-25.9.1-cp39-cp39-win32.whl", hash = "sha256:c6c91f7e33c7f01237755884316110ee7ea076f5bdb9aa0982b6dc63243c0a38"}, + {file = "gevent-25.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a44b0121f3d7c800740ff80351c897e85e76a7e4764690f35c5ad9ec17de5"}, + {file = "gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd"}, +] + +[package.dependencies] +cffi = {version = ">=1.17.1", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} +greenlet = {version = ">=3.2.2", markers = "platform_python_implementation == \"CPython\""} +"zope.event" = "*" +"zope.interface" = "*" + +[package.extras] +dnspython = ["dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\""] +docs = ["furo", "repoze.sphinx.autointerface", "sphinx", "sphinxcontrib-programoutput", "zope.schema"] +monitor = ["psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""] +recommended = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""] +test = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "coverage (>=5.0) ; sys_platform != \"win32\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "objgraph", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\"", "requests"] + [[package]] name = "google-api-core" -version = "2.25.1" +version = "2.29.0" description = "Google API client core library" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, - {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, + {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"}, + {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"}, ] [package.dependencies] google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.56.2,<2.0.0" +grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = ">=1.22.3,<2.0.0" protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" [package.extras] async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] @@ -2484,60 +3723,159 @@ uritemplate = ">=3.0.1,<5" [[package]] name = "google-auth" -version = "2.40.3" +version = "2.48.0" description = "Google Authentication Library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, - {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, + {file = "google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f"}, + {file = "google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce"}, ] [package.dependencies] -cachetools = ">=2.0.0,<6.0" +cryptography = ">=38.0.3" pyasn1-modules = ">=0.2.1" rsa = ">=3.1.4,<5" [package.extras] aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] -enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +cryptography = ["cryptography (>=38.0.3)"] +enterprise-cert = ["pyopenssl"] +pyjwt = ["pyjwt (>=2.0)"] +pyopenssl = ["pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0)"] -testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] urllib3 = ["packaging", "urllib3"] [[package]] name = "google-auth-httplib2" -version = "0.2.0" +version = "0.2.1" description = "Google Authentication Library: httplib2 transport" optional = false -python-versions = "*" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, - {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, + {file = "google_auth_httplib2-0.2.1-py3-none-any.whl", hash = "sha256:1be94c611db91c01f9703e7f62b0a59bbd5587a95571c7b6fade510d648bc08b"}, + {file = "google_auth_httplib2-0.2.1.tar.gz", hash = "sha256:5ef03be3927423c87fb69607b42df23a444e434ddb2555b73b3679793187b7de"}, ] [package.dependencies] -google-auth = "*" -httplib2 = ">=0.19.0" +google-auth = ">=1.32.0,<3.0.0" +httplib2 = ">=0.19.0,<1.0.0" + +[[package]] +name = "google-cloud-access-context-manager" +version = "0.3.0" +description = "Google Cloud Access Context Manager Protobufs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_access_context_manager-0.3.0-py3-none-any.whl", hash = "sha256:5d15ad51547f06c281e35f16b4ffcb3e98bb2d898b01470f88b94edfb2eeb0a3"}, + {file = "google_cloud_access_context_manager-0.3.0.tar.gz", hash = "sha256:f3aa35c9225b7aaef85ecdacedcc1577789be8d458b7a41b6ad23b504786e5f9"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-asset" +version = "4.2.0" +description = "Google Cloud Asset API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_asset-4.2.0-py3-none-any.whl", hash = "sha256:fd7ea04c64948a4779790343204cd5b41d4772d6ab1d05a9125e28a637ac0862"}, + {file = "google_cloud_asset-4.2.0.tar.gz", hash = "sha256:1734906cfd9b6ea6922861c8f1b4fcabe90d53ca267ee88499e8532b7593b35f"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +google-cloud-access-context-manager = ">=0.1.2,<1.0.0" +google-cloud-org-policy = ">=0.1.2,<2.0.0" +google-cloud-os-config = ">=1.0.0,<2.0.0" +grpc-google-iam-v1 = ">=0.14.0,<1.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-org-policy" +version = "1.16.0" +description = "Google Cloud Org Policy API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_org_policy-1.16.0-py3-none-any.whl", hash = "sha256:96d1ed38f795182600a58f8eb2879e1577ce663b6b27df0b8a3050960cff87a5"}, + {file = "google_cloud_org_policy-1.16.0.tar.gz", hash = "sha256:c72147127d88d9809af8738b2abe34806eac529c3cdc57aa915cc08a1b842a13"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-os-config" +version = "1.23.0" +description = "Google Cloud Os Config API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_os_config-1.23.0-py3-none-any.whl", hash = "sha256:fea865018391abca42a9a74d270ddab516ae0865d6e9ad3bcb503286ca01c069"}, + {file = "google_cloud_os_config-1.23.0.tar.gz", hash = "sha256:a629cf55b3ede36b2df89814c6ccf3c1d43c7f1b43db6c7c02eb4860851baf3a"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "google-cloud-resource-manager" +version = "1.16.0" +description = "Google Cloud Resource Manager API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28"}, + {file = "google_cloud_resource_manager-1.16.0.tar.gz", hash = "sha256:cc938f87cc36c2672f062b1e541650629e0d954c405a4dac35ceedee70c267c3"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpc-google-iam-v1 = ">=0.14.0,<1.0.0" +grpcio = ">=1.33.2,<2.0.0" +proto-plus = ">=1.22.3,<2.0.0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "googleapis-common-protos" -version = "1.70.0" +version = "1.72.0" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, - {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, + {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, + {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, ] [package.dependencies] +grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [package.extras] @@ -2571,6 +3909,185 @@ files = [ dev = ["pytest"] docs = ["sphinx", "sphinx-autobuild"] +[[package]] +name = "greenlet" +version = "3.3.1" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation == \"CPython\"" +files = [ + {file = "greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5"}, + {file = "greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe"}, + {file = "greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729"}, + {file = "greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4"}, + {file = "greenlet-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:7932f5f57609b6a3b82cc11877709aa7a98e3308983ed93552a1c377069b20c8"}, + {file = "greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f"}, + {file = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2"}, + {file = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9"}, + {file = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f"}, + {file = "greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b"}, + {file = "greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4"}, + {file = "greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca"}, + {file = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336"}, + {file = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1"}, + {file = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149"}, + {file = "greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a"}, + {file = "greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1"}, + {file = "greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e"}, + {file = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3"}, + {file = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951"}, + {file = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2"}, + {file = "greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946"}, + {file = "greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d"}, + {file = "greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d"}, + {file = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f"}, + {file = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683"}, + {file = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1"}, + {file = "greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a"}, + {file = "greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79"}, + {file = "greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab"}, + {file = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2"}, + {file = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53"}, + {file = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249"}, + {file = "greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451"}, + {file = "greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +description = "IAM API client library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6"}, + {file = "grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389"}, +] + +[package.dependencies] +googleapis-common-protos = {version = ">=1.56.0,<2.0.0", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "grpcio" +version = "1.76.0" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"}, + {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"}, + {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"}, + {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"}, + {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"}, + {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"}, + {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"}, + {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"}, + {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"}, + {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"}, + {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"}, + {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"}, + {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"}, + {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"}, + {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"}, + {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"}, + {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"}, + {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"}, + {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"}, + {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"}, + {file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"}, + {file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"}, + {file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"}, + {file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"}, + {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.76.0)"] + +[[package]] +name = "grpcio-status" +version = "1.76.0" +description = "Status proto mapping for gRPC" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18"}, + {file = "grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.76.0" +protobuf = ">=6.31.1,<7.0.0" + [[package]] name = "gunicorn" version = "23.0.0" @@ -2599,7 +4116,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"}, @@ -2639,7 +4156,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"}, @@ -2657,18 +4174,18 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httplib2" -version = "0.22.0" +version = "0.31.2" description = "A comprehensive HTTP client library." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" groups = ["main"] files = [ - {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, - {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, + {file = "httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349"}, + {file = "httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24"}, ] [package.dependencies] -pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} +pyparsing = ">=3.1,<4" [[package]] name = "httpx" @@ -2676,7 +4193,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"}, @@ -2696,6 +4213,21 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "humanfriendly" +version = "10.0" +description = "Human friendly output for text interfaces using Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} + [[package]] name = "hyperframe" version = "6.1.0" @@ -2710,26 +4242,26 @@ files = [ [[package]] name = "iamdata" -version = "0.1.202507291" +version = "0.1.202602021" description = "IAM data for AWS actions, resources, and conditions based on IAM policy documents. Checked for updates daily." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "iamdata-0.1.202507291-py3-none-any.whl", hash = "sha256:11dfdacc3ce0312468aa5ccafee461cd39b1deb7be112042deea91cbcd4b292b"}, - {file = "iamdata-0.1.202507291.tar.gz", hash = "sha256:b386ce94819464554dc1258238ee1b232d86f0467edc13fffbf4de7332b3c7ad"}, + {file = "iamdata-0.1.202602021-py3-none-any.whl", hash = "sha256:48419662d75dd0e1ea22b9cc98fd70201d4c72760c6897acc46ad9ab90633d18"}, + {file = "iamdata-0.1.202602021.tar.gz", hash = "sha256:c24265fc3694076f65da91a8aa9361b60da25f7b8cfd8ba4ddd6aa1b9bb5153e"}, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] @@ -2737,14 +4269,14 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, ] [package.dependencies] @@ -2754,10 +4286,10 @@ zipp = ">=3.20" check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] +enabler = ["pytest-enabler (>=3.4)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] [[package]] name = "inflection" @@ -2773,14 +4305,26 @@ files = [ [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +description = "Simple module to parse ISO 8601 dates" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, + {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, ] [[package]] @@ -2842,101 +4386,184 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "jiter" -version = "0.10.0" +version = "0.13.0" description = "Fast iterable JSON parser." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "jiter-0.10.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2fb72b02478f06a900a5782de2ef47e0396b3e1f7d5aba30daeb1fce66f303"}, - {file = "jiter-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32bb468e3af278f095d3fa5b90314728a6916d89ba3d0ffb726dd9bf7367285e"}, - {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8b3e0068c26ddedc7abc6fac37da2d0af16b921e288a5a613f4b86f050354f"}, - {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:286299b74cc49e25cd42eea19b72aa82c515d2f2ee12d11392c56d8701f52224"}, - {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ed5649ceeaeffc28d87fb012d25a4cd356dcd53eff5acff1f0466b831dda2a7"}, - {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2ab0051160cb758a70716448908ef14ad476c3774bd03ddce075f3c1f90a3d6"}, - {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03997d2f37f6b67d2f5c475da4412be584e1cec273c1cfc03d642c46db43f8cf"}, - {file = "jiter-0.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c404a99352d839fed80d6afd6c1d66071f3bacaaa5c4268983fc10f769112e90"}, - {file = "jiter-0.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66e989410b6666d3ddb27a74c7e50d0829704ede652fd4c858e91f8d64b403d0"}, - {file = "jiter-0.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b532d3af9ef4f6374609a3bcb5e05a1951d3bf6190dc6b176fdb277c9bbf15ee"}, - {file = "jiter-0.10.0-cp310-cp310-win32.whl", hash = "sha256:da9be20b333970e28b72edc4dff63d4fec3398e05770fb3205f7fb460eb48dd4"}, - {file = "jiter-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:f59e533afed0c5b0ac3eba20d2548c4a550336d8282ee69eb07b37ea526ee4e5"}, - {file = "jiter-0.10.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3bebe0c558e19902c96e99217e0b8e8b17d570906e72ed8a87170bc290b1e978"}, - {file = "jiter-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:558cc7e44fd8e507a236bee6a02fa17199ba752874400a0ca6cd6e2196cdb7dc"}, - {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d613e4b379a07d7c8453c5712ce7014e86c6ac93d990a0b8e7377e18505e98d"}, - {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f62cf8ba0618eda841b9bf61797f21c5ebd15a7a1e19daab76e4e4b498d515b2"}, - {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:919d139cdfa8ae8945112398511cb7fca58a77382617d279556b344867a37e61"}, - {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13ddbc6ae311175a3b03bd8994881bc4635c923754932918e18da841632349db"}, - {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c440ea003ad10927a30521a9062ce10b5479592e8a70da27f21eeb457b4a9c5"}, - {file = "jiter-0.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc347c87944983481e138dea467c0551080c86b9d21de6ea9306efb12ca8f606"}, - {file = "jiter-0.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:13252b58c1f4d8c5b63ab103c03d909e8e1e7842d302473f482915d95fefd605"}, - {file = "jiter-0.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7d1bbf3c465de4a24ab12fb7766a0003f6f9bce48b8b6a886158c4d569452dc5"}, - {file = "jiter-0.10.0-cp311-cp311-win32.whl", hash = "sha256:db16e4848b7e826edca4ccdd5b145939758dadf0dc06e7007ad0e9cfb5928ae7"}, - {file = "jiter-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c9c1d5f10e18909e993f9641f12fe1c77b3e9b533ee94ffa970acc14ded3812"}, - {file = "jiter-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1e274728e4a5345a6dde2d343c8da018b9d4bd4350f5a472fa91f66fda44911b"}, - {file = "jiter-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7202ae396446c988cb2a5feb33a543ab2165b786ac97f53b59aafb803fef0744"}, - {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23ba7722d6748b6920ed02a8f1726fb4b33e0fd2f3f621816a8b486c66410ab2"}, - {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:371eab43c0a288537d30e1f0b193bc4eca90439fc08a022dd83e5e07500ed026"}, - {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c675736059020365cebc845a820214765162728b51ab1e03a1b7b3abb70f74c"}, - {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c5867d40ab716e4684858e4887489685968a47e3ba222e44cde6e4a2154f959"}, - {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395bb9a26111b60141757d874d27fdea01b17e8fac958b91c20128ba8f4acc8a"}, - {file = "jiter-0.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6842184aed5cdb07e0c7e20e5bdcfafe33515ee1741a6835353bb45fe5d1bd95"}, - {file = "jiter-0.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:62755d1bcea9876770d4df713d82606c8c1a3dca88ff39046b85a048566d56ea"}, - {file = "jiter-0.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533efbce2cacec78d5ba73a41756beff8431dfa1694b6346ce7af3a12c42202b"}, - {file = "jiter-0.10.0-cp312-cp312-win32.whl", hash = "sha256:8be921f0cadd245e981b964dfbcd6fd4bc4e254cdc069490416dd7a2632ecc01"}, - {file = "jiter-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7c7d785ae9dda68c2678532a5a1581347e9c15362ae9f6e68f3fdbfb64f2e49"}, - {file = "jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644"}, - {file = "jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a"}, - {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6"}, - {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3"}, - {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2"}, - {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25"}, - {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041"}, - {file = "jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca"}, - {file = "jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4"}, - {file = "jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e"}, - {file = "jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d"}, - {file = "jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4"}, - {file = "jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca"}, - {file = "jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070"}, - {file = "jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca"}, - {file = "jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522"}, - {file = "jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8"}, - {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216"}, - {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4"}, - {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426"}, - {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12"}, - {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9"}, - {file = "jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a"}, - {file = "jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853"}, - {file = "jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86"}, - {file = "jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357"}, - {file = "jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00"}, - {file = "jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5"}, - {file = "jiter-0.10.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bd6292a43c0fc09ce7c154ec0fa646a536b877d1e8f2f96c19707f65355b5a4d"}, - {file = "jiter-0.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39de429dcaeb6808d75ffe9effefe96a4903c6a4b376b2f6d08d77c1aaee2f18"}, - {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52ce124f13a7a616fad3bb723f2bfb537d78239d1f7f219566dc52b6f2a9e48d"}, - {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:166f3606f11920f9a1746b2eea84fa2c0a5d50fd313c38bdea4edc072000b0af"}, - {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28dcecbb4ba402916034fc14eba7709f250c4d24b0c43fc94d187ee0580af181"}, - {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86c5aa6910f9bebcc7bc4f8bc461aff68504388b43bfe5e5c0bd21efa33b52f4"}, - {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceeb52d242b315d7f1f74b441b6a167f78cea801ad7c11c36da77ff2d42e8a28"}, - {file = "jiter-0.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ff76d8887c8c8ee1e772274fcf8cc1071c2c58590d13e33bd12d02dc9a560397"}, - {file = "jiter-0.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a9be4d0fa2b79f7222a88aa488bd89e2ae0a0a5b189462a12def6ece2faa45f1"}, - {file = "jiter-0.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ab7fd8738094139b6c1ab1822d6f2000ebe41515c537235fd45dabe13ec9324"}, - {file = "jiter-0.10.0-cp39-cp39-win32.whl", hash = "sha256:5f51e048540dd27f204ff4a87f5d79294ea0aa3aa552aca34934588cf27023cf"}, - {file = "jiter-0.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:1b28302349dc65703a9e4ead16f163b1c339efffbe1049c30a44b001a2a4fff9"}, - {file = "jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500"}, + {file = "jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e"}, + {file = "jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a"}, + {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5"}, + {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721"}, + {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060"}, + {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c"}, + {file = "jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae"}, + {file = "jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2"}, + {file = "jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5"}, + {file = "jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b"}, + {file = "jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894"}, + {file = "jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d"}, + {file = "jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096"}, + {file = "jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911"}, + {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701"}, + {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c"}, + {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4"}, + {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165"}, + {file = "jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018"}, + {file = "jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411"}, + {file = "jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5"}, + {file = "jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3"}, + {file = "jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1"}, + {file = "jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654"}, + {file = "jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5"}, + {file = "jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663"}, + {file = "jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505"}, + {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152"}, + {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726"}, + {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0"}, + {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089"}, + {file = "jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93"}, + {file = "jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08"}, + {file = "jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2"}, + {file = "jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228"}, + {file = "jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394"}, + {file = "jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92"}, + {file = "jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9"}, + {file = "jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf"}, + {file = "jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a"}, + {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb"}, + {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2"}, + {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f"}, + {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159"}, + {file = "jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663"}, + {file = "jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa"}, + {file = "jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820"}, + {file = "jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68"}, + {file = "jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72"}, + {file = "jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc"}, + {file = "jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b"}, + {file = "jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10"}, + {file = "jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef"}, + {file = "jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6"}, + {file = "jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d"}, + {file = "jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d"}, + {file = "jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0"}, + {file = "jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91"}, + {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09"}, + {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607"}, + {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66"}, + {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2"}, + {file = "jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad"}, + {file = "jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d"}, + {file = "jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df"}, + {file = "jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d"}, + {file = "jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6"}, + {file = "jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f"}, + {file = "jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d"}, + {file = "jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0"}, + {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40"}, + {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202"}, + {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0"}, + {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95"}, + {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59"}, + {file = "jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe"}, + {file = "jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939"}, + {file = "jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9"}, + {file = "jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6"}, + {file = "jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8"}, + {file = "jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024"}, + {file = "jiter-0.13.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:4397ee562b9f69d283e5674445551b47a5e8076fdde75e71bfac5891113dc543"}, + {file = "jiter-0.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f90023f8f672e13ea1819507d2d21b9d2d1c18920a3b3a5f1541955a85b5504"}, + {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed0240dd1536a98c3ab55e929c60dfff7c899fecafcb7d01161b21a99fc8c363"}, + {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6207fc61c395b26fffdcf637a0b06b4326f35bfa93c6e92fe1a166a21aeb6731"}, + {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00203f47c214156df427b5989de74cb340c65c8180d09be1bf9de81d0abad599"}, + {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c26ad6967c9dcedf10c995a21539c3aa57d4abad7001b7a84f621a263a6b605"}, + {file = "jiter-0.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a576f5dce9ac7de5d350b8e2f552cf364f32975ed84717c35379a51c7cb198bd"}, + {file = "jiter-0.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b22945be8425d161f2e536cdae66da300b6b000f1c0ba3ddf237d1bfd45d21b8"}, + {file = "jiter-0.13.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6eeb7db8bc77dc20476bc2f7407a23dbe3d46d9cc664b166e3d474e1c1de4baa"}, + {file = "jiter-0.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:19cd6f85e1dc090277c3ce90a5b7d96f32127681d825e71c9dce28788e39fc0c"}, + {file = "jiter-0.13.0-cp39-cp39-win32.whl", hash = "sha256:dc3ce84cfd4fa9628fe62c4f85d0d597a4627d4242cfafac32a12cc1455d00f7"}, + {file = "jiter-0.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:9ffda299e417dc83362963966c50cb76d42da673ee140de8a8ac762d4bb2378b"}, + {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c"}, + {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2"}, + {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434"}, + {file = "jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d"}, + {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a"}, + {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f"}, + {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59"}, + {file = "jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19"}, + {file = "jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4"}, ] [[package]] name = "jmespath" -version = "1.0.1" +version = "1.1.0" description = "JSON Matching Expressions" optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + +[[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 = "jsonpatch" +version = "1.33" +description = "Apply JSON-Patches (RFC 6902)" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +groups = ["main"] +files = [ + {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, + {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, +] + +[package.dependencies] +jsonpointer = ">=1.9" + +[[package]] +name = "jsonpickle" +version = "4.1.1" +description = "jsonpickle encodes/decodes any Python object to/from JSON" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91"}, + {file = "jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1"}, +] + +[package.extras] +cov = ["pytest-cov"] +dev = ["black", "pyupgrade"] +docs = ["furo", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +packaging = ["build", "setuptools (>=61.2)", "setuptools_scm[toml] (>=6.0)", "twine"] +testing = ["PyYAML", "atheris (>=2.3.0,<2.4.0) ; python_version < \"3.12\"", "bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=6.0,!=8.1.*)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy (>=1.9.3) ; python_version > \"3.10\"", "scipy ; python_version <= \"3.10\"", "simplejson", "sqlalchemy", "ujson"] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +description = "Identify specific nodes in a JSON document (RFC 6901)" +optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, - {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, + {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, + {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, ] [[package]] @@ -2963,19 +4590,45 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jsonschema-specifications" -version = "2025.4.1" +version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, - {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, ] [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "keystoneauth1" +version = "5.13.0" +description = "Authentication Library for OpenStack Identity" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41"}, + {file = "keystoneauth1-5.13.0.tar.gz", hash = "sha256:57c9ca407207899b50d8ff1ca8abb4a4e7427461bfc1877eb8519c3989ce63ec"}, +] + +[package.dependencies] +iso8601 = ">=2.0.0" +os-service-types = ">=1.2.0" +pbr = ">=2.0.0" +requests = ">=2.14.2" +stevedore = ">=1.20.0" +typing-extensions = ">=4.12" + +[package.extras] +betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"] +kerberos = ["requests-kerberos (>=0.8.0)"] +oauth1 = ["oauthlib (>=0.6.2)"] +saml2 = ["lxml (>=4.2.0)"] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -3088,21 +4741,41 @@ files = [ ] [[package]] -name = "kombu" -version = "5.5.4" -description = "Messaging library for Python." +name = "knack" +version = "0.11.0" +description = "A Command-Line Interface framework" optional = false -python-versions = ">=3.8" +python-versions = "*" groups = ["main"] files = [ - {file = "kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8"}, - {file = "kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363"}, + {file = "knack-0.11.0-py3-none-any.whl", hash = "sha256:6704c867840978a119a193914a90e2e98c7be7dff764c8fcd8a2286c5a978d00"}, + {file = "knack-0.11.0.tar.gz", hash = "sha256:eb6568001e9110b1b320941431c51033d104cc98cda2254a5c2b09ba569fd494"}, +] + +[package.dependencies] +argcomplete = "*" +jmespath = "*" +packaging = "*" +pygments = "*" +pyyaml = "*" +tabulate = "*" + +[[package]] +name = "kombu" +version = "5.6.2" +description = "Messaging library for Python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93"}, + {file = "kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55"}, ] [package.dependencies] amqp = ">=5.1.1,<6.0.0" packaging = "*" -tzdata = {version = ">=2025.2", markers = "python_version >= \"3.9\""} +tzdata = ">=2025.2" vine = "5.1.0" [package.extras] @@ -3110,16 +4783,16 @@ azureservicebus = ["azure-servicebus (>=7.10.0)"] azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6.0)"] confluentkafka = ["confluent-kafka (>=2.2.0)"] consul = ["python-consul2 (==0.1.5)"] -gcpubsub = ["google-cloud-monitoring (>=2.16.0)", "google-cloud-pubsub (>=2.18.4)", "grpcio (==1.67.0)", "protobuf (==4.25.5)"] +gcpubsub = ["google-cloud-monitoring (>=2.16.0)", "google-cloud-pubsub (>=2.18.4)", "grpcio (==1.75.1)", "protobuf (==6.32.1)"] librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] -mongodb = ["pymongo (==4.10.1)"] -msgpack = ["msgpack (==1.1.0)"] +mongodb = ["pymongo (==4.15.3)"] +msgpack = ["msgpack (==1.1.2)"] pyro = ["pyro4 (==4.82)"] -qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] -redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<=5.2.1)"] +qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"] +redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"] slmq = ["softlayer_messaging (>=1.0.3)"] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] -sqs = ["boto3 (>=1.26.143)", "urllib3 (>=1.26.16)"] +sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16)"] yaml = ["PyYAML (>=3.10)"] zookeeper = ["kazoo (>=2.8.0)"] @@ -3306,6 +4979,78 @@ html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] source = ["Cython (>=3.0.11,<3.1.0)"] +[[package]] +name = "lz4" +version = "4.4.5" +description = "LZ4 Bindings for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d"}, + {file = "lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f"}, + {file = "lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3"}, + {file = "lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758"}, + {file = "lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1"}, + {file = "lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc"}, + {file = "lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd"}, + {file = "lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397"}, + {file = "lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4"}, + {file = "lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43"}, + {file = "lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7"}, + {file = "lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb"}, + {file = "lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989"}, + {file = "lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d"}, + {file = "lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004"}, + {file = "lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b"}, + {file = "lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e"}, + {file = "lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a"}, + {file = "lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5"}, + {file = "lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e"}, + {file = "lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e"}, + {file = "lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50"}, + {file = "lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33"}, + {file = "lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301"}, + {file = "lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c"}, + {file = "lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a"}, + {file = "lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d"}, + {file = "lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c"}, + {file = "lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64"}, + {file = "lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832"}, + {file = "lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22"}, + {file = "lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9"}, + {file = "lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f"}, + {file = "lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba"}, + {file = "lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d"}, + {file = "lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67"}, + {file = "lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d"}, + {file = "lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901"}, + {file = "lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb"}, + {file = "lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd"}, + {file = "lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f"}, + {file = "lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6"}, + {file = "lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9"}, + {file = "lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668"}, + {file = "lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f"}, + {file = "lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67"}, + {file = "lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be"}, + {file = "lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7"}, + {file = "lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad"}, + {file = "lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294"}, + {file = "lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86"}, + {file = "lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547"}, + {file = "lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581"}, + {file = "lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce"}, + {file = "lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7"}, + {file = "lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0"}, + {file = "lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0"}, +] + +[package.extras] +docs = ["sphinx (>=1.6.0)", "sphinx_bootstrap_theme"] +flake8 = ["flake8"] +tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"] + [[package]] name = "markdown" version = "3.9" @@ -3328,7 +5073,7 @@ version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, @@ -3348,85 +5093,113 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] [[package]] name = "markupsafe" -version = "3.0.2" +version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] [[package]] name = "marshmallow" -version = "3.26.1" +version = "3.26.2" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ - {file = "marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c"}, - {file = "marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6"}, + {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, + {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, ] [package.dependencies] @@ -3439,67 +5212,67 @@ tests = ["pytest", "simplejson"] [[package]] name = "matplotlib" -version = "3.10.6" +version = "3.10.8" description = "Python plotting package" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "matplotlib-3.10.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bc7316c306d97463a9866b89d5cc217824e799fa0de346c8f68f4f3d27c8693d"}, - {file = "matplotlib-3.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d00932b0d160ef03f59f9c0e16d1e3ac89646f7785165ce6ad40c842db16cc2e"}, - {file = "matplotlib-3.10.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fa4c43d6bfdbfec09c733bca8667de11bfa4970e8324c471f3a3632a0301c15"}, - {file = "matplotlib-3.10.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea117a9c1627acaa04dbf36265691921b999cbf515a015298e54e1a12c3af837"}, - {file = "matplotlib-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08fc803293b4e1694ee325896030de97f74c141ccff0be886bb5915269247676"}, - {file = "matplotlib-3.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:2adf92d9b7527fbfb8818e050260f0ebaa460f79d61546374ce73506c9421d09"}, - {file = "matplotlib-3.10.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:905b60d1cb0ee604ce65b297b61cf8be9f4e6cfecf95a3fe1c388b5266bc8f4f"}, - {file = "matplotlib-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bac38d816637343e53d7185d0c66677ff30ffb131044a81898b5792c956ba76"}, - {file = "matplotlib-3.10.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:942a8de2b5bfff1de31d95722f702e2966b8a7e31f4e68f7cd963c7cd8861cf6"}, - {file = "matplotlib-3.10.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3276c85370bc0dfca051ec65c5817d1e0f8f5ce1b7787528ec8ed2d524bbc2f"}, - {file = "matplotlib-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9df5851b219225731f564e4b9e7f2ac1e13c9e6481f941b5631a0f8e2d9387ce"}, - {file = "matplotlib-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:abb5d9478625dd9c9eb51a06d39aae71eda749ae9b3138afb23eb38824026c7e"}, - {file = "matplotlib-3.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:886f989ccfae63659183173bb3fced7fd65e9eb793c3cc21c273add368536951"}, - {file = "matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347"}, - {file = "matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75"}, - {file = "matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95"}, - {file = "matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb"}, - {file = "matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07"}, - {file = "matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b"}, - {file = "matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa"}, - {file = "matplotlib-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:819e409653c1106c8deaf62e6de6b8611449c2cd9939acb0d7d4e57a3d95cc7a"}, - {file = "matplotlib-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:59c8ac8382fefb9cb71308dde16a7c487432f5255d8f1fd32473523abecfecdf"}, - {file = "matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84e82d9e0fd70c70bc55739defbd8055c54300750cbacf4740c9673a24d6933a"}, - {file = "matplotlib-3.10.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7a3eb42d6c1c56e89eacd495661fc815ffc08d9da750bca766771c0fd9110"}, - {file = "matplotlib-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9c862d91ec0b7842920a4cfdaaec29662195301914ea54c33e01f1a28d014b2"}, - {file = "matplotlib-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:1b53bd6337eba483e2e7d29c5ab10eee644bc3a2491ec67cc55f7b44583ffb18"}, - {file = "matplotlib-3.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:cbd5eb50b7058b2892ce45c2f4e92557f395c9991f5c886d1bb74a1582e70fd6"}, - {file = "matplotlib-3.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:acc86dd6e0e695c095001a7fccff158c49e45e0758fdf5dcdbb0103318b59c9f"}, - {file = "matplotlib-3.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e228cd2ffb8f88b7d0b29e37f68ca9aaf83e33821f24a5ccc4f082dd8396bc27"}, - {file = "matplotlib-3.10.6-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:658bc91894adeab669cf4bb4a186d049948262987e80f0857216387d7435d833"}, - {file = "matplotlib-3.10.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8913b7474f6dd83ac444c9459c91f7f0f2859e839f41d642691b104e0af056aa"}, - {file = "matplotlib-3.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:091cea22e059b89f6d7d1a18e2c33a7376c26eee60e401d92a4d6726c4e12706"}, - {file = "matplotlib-3.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:491e25e02a23d7207629d942c666924a6b61e007a48177fdd231a0097b7f507e"}, - {file = "matplotlib-3.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3d80d60d4e54cda462e2cd9a086d85cd9f20943ead92f575ce86885a43a565d5"}, - {file = "matplotlib-3.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:70aaf890ce1d0efd482df969b28a5b30ea0b891224bb315810a3940f67182899"}, - {file = "matplotlib-3.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1565aae810ab79cb72e402b22facfa6501365e73ebab70a0fdfb98488d2c3c0c"}, - {file = "matplotlib-3.10.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b23315a01981689aa4e1a179dbf6ef9fbd17143c3eea77548c2ecfb0499438"}, - {file = "matplotlib-3.10.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30fdd37edf41a4e6785f9b37969de57aea770696cb637d9946eb37470c94a453"}, - {file = "matplotlib-3.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc31e693da1c08012c764b053e702c1855378e04102238e6a5ee6a7117c53a47"}, - {file = "matplotlib-3.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:05be9bdaa8b242bc6ff96330d18c52f1fc59c6fb3a4dd411d953d67e7e1baf98"}, - {file = "matplotlib-3.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:f56a0d1ab05d34c628592435781d185cd99630bdfd76822cd686fb5a0aecd43a"}, - {file = "matplotlib-3.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:94f0b4cacb23763b64b5dace50d5b7bfe98710fed5f0cef5c08135a03399d98b"}, - {file = "matplotlib-3.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cc332891306b9fb39462673d8225d1b824c89783fee82840a709f96714f17a5c"}, - {file = "matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee1d607b3fb1590deb04b69f02ea1d53ed0b0bf75b2b1a5745f269afcbd3cdd3"}, - {file = "matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:376a624a218116461696b27b2bbf7a8945053e6d799f6502fc03226d077807bf"}, - {file = "matplotlib-3.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:83847b47f6524c34b4f2d3ce726bb0541c48c8e7692729865c3df75bfa0f495a"}, - {file = "matplotlib-3.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c7e0518e0d223683532a07f4b512e2e0729b62674f1b3a1a69869f98e6b1c7e3"}, - {file = "matplotlib-3.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:4dd83e029f5b4801eeb87c64efd80e732452781c16a9cf7415b7b63ec8f374d7"}, - {file = "matplotlib-3.10.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:13fcd07ccf17e354398358e0307a1f53f5325dca22982556ddb9c52837b5af41"}, - {file = "matplotlib-3.10.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:470fc846d59d1406e34fa4c32ba371039cd12c2fe86801159a965956f2575bd1"}, - {file = "matplotlib-3.10.6-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7173f8551b88f4ef810a94adae3128c2530e0d07529f7141be7f8d8c365f051"}, - {file = "matplotlib-3.10.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f2d684c3204fa62421bbf770ddfebc6b50130f9cad65531eeba19236d73bb488"}, - {file = "matplotlib-3.10.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6f4a69196e663a41d12a728fab8751177215357906436804217d6d9cf0d4d6cf"}, - {file = "matplotlib-3.10.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d6ca6ef03dfd269f4ead566ec6f3fb9becf8dab146fb999022ed85ee9f6b3eb"}, - {file = "matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c"}, + {file = "matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7"}, + {file = "matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656"}, + {file = "matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df"}, + {file = "matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17"}, + {file = "matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933"}, + {file = "matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a"}, + {file = "matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160"}, + {file = "matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78"}, + {file = "matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4"}, + {file = "matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2"}, + {file = "matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6"}, + {file = "matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9"}, + {file = "matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2"}, + {file = "matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a"}, + {file = "matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58"}, + {file = "matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04"}, + {file = "matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f"}, + {file = "matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466"}, + {file = "matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf"}, + {file = "matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b"}, + {file = "matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6"}, + {file = "matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1"}, + {file = "matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486"}, + {file = "matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce"}, + {file = "matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6"}, + {file = "matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149"}, + {file = "matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645"}, + {file = "matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077"}, + {file = "matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22"}, + {file = "matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39"}, + {file = "matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565"}, + {file = "matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a"}, + {file = "matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958"}, + {file = "matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5"}, + {file = "matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f"}, + {file = "matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b"}, + {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d"}, + {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008"}, + {file = "matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c"}, + {file = "matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11"}, + {file = "matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8"}, + {file = "matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50"}, + {file = "matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908"}, + {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a"}, + {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1"}, + {file = "matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c"}, + {file = "matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b"}, + {file = "matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f"}, + {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8"}, + {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7"}, + {file = "matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3"}, + {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1"}, + {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a"}, + {file = "matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2"}, + {file = "matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3"}, ] [package.dependencies] @@ -3510,7 +5283,7 @@ kiwisolver = ">=1.3.1" numpy = ">=1.23" packaging = ">=20.0" pillow = ">=8" -pyparsing = ">=2.3.1" +pyparsing = ">=3" python-dateutil = ">=2.7" [package.extras] @@ -3534,7 +5307,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -3654,21 +5427,38 @@ files = [ [package.dependencies] microsoft-kiota-abstractions = ">=1.9.2,<1.10.0" +[[package]] +name = "microsoft-security-utilities-secret-masker" +version = "1.0.0b4" +description = "A tool for detecting and masking secrets" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "microsoft_security_utilities_secret_masker-1.0.0b4-py3-none-any.whl", hash = "sha256:0429fcaad10fc8ae3f940ab84fd2926e4f50ede134162144123b35937be831a8"}, + {file = "microsoft_security_utilities_secret_masker-1.0.0b4.tar.gz", hash = "sha256:a30bd361ac18c8b52f6844076bc26465335949ea9c7a004d95f5196ec6fdef3e"}, +] + [[package]] name = "msal" -version = "1.33.0" +version = "1.34.0b1" description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "msal-1.33.0-py3-none-any.whl", hash = "sha256:c0cd41cecf8eaed733ee7e3be9e040291eba53b0f262d3ae9c58f38b04244273"}, - {file = "msal-1.33.0.tar.gz", hash = "sha256:836ad80faa3e25a7d71015c990ce61f704a87328b1e73bcbb0623a18cbf17510"}, + {file = "msal-1.34.0b1-py3-none-any.whl", hash = "sha256:3b6373325e3509d97873e36965a75e9cc9393f1b579d12cc03c0ca0ef6d37eb4"}, + {file = "msal-1.34.0b1.tar.gz", hash = "sha256:86cdbfec14955e803379499d017056c6df4ed40f717fd6addde94bdeb4babd78"}, ] [package.dependencies] cryptography = ">=2.5,<48" PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]} +pymsalruntime = [ + {version = ">=0.14,<0.19", optional = true, markers = "python_version >= \"3.6\" and platform_system == \"Windows\" and extra == \"broker\""}, + {version = ">=0.17,<0.19", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Darwin\" and extra == \"broker\""}, + {version = ">=0.18,<0.19", optional = true, markers = "python_version >= \"3.8\" and platform_system == \"Linux\" and extra == \"broker\""}, +] requests = ">=2.0.0,<3" [package.extras] @@ -3676,32 +5466,30 @@ broker = ["pymsalruntime (>=0.14,<0.19) ; python_version >= \"3.6\" and platform [[package]] name = "msal-extensions" -version = "1.3.1" +version = "1.2.0" description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." optional = false -python-versions = ">=3.9" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, - {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, + {file = "msal_extensions-1.2.0-py3-none-any.whl", hash = "sha256:cf5ba83a2113fa6dc011a254a72f1c223c88d7dfad74cc30617c4679a417704d"}, + {file = "msal_extensions-1.2.0.tar.gz", hash = "sha256:6f41b320bfd2933d631a215c91ca0dd3e67d84bd1a2f50ce917d5874ec646bef"}, ] [package.dependencies] msal = ">=1.29,<2" - -[package.extras] -portalocker = ["portalocker (>=1.4,<4)"] +portalocker = ">=1.4,<3" [[package]] name = "msgraph-core" -version = "1.3.5" +version = "1.3.8" description = "Core component of the Microsoft Graph Python SDK" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "msgraph_core-1.3.5-py3-none-any.whl", hash = "sha256:bc496c6f99c626bc534012c6fe9afa35c37bcdce0f92acf26e4210f4ff9bb154"}, - {file = "msgraph_core-1.3.5.tar.gz", hash = "sha256:43aec9df1c011f1c6a1e14f2b5e9266c05a723ed750a5d3ea1eb0c0f1deb9975"}, + {file = "msgraph_core-1.3.8-py3-none-any.whl", hash = "sha256:86d83edcf62119946f201d13b7e857c947ef67addb088883940197081de85bea"}, + {file = "msgraph_core-1.3.8.tar.gz", hash = "sha256:6e883f9d4c4ad57501234749e07b010478c1a5f19550ef4cf005bbcac4a63ae7"}, ] [package.dependencies] @@ -3758,124 +5546,177 @@ requests-oauthlib = ">=0.5.0" [package.extras] async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] +[[package]] +name = "msrestazure" +version = "0.6.4.post1" +description = "AutoRest swagger generator Python client runtime. Azure-specific module." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "msrestazure-0.6.4.post1-py2.py3-none-any.whl", hash = "sha256:2264493b086c2a0a82ddf5fd87b35b3fffc443819127fed992ac5028354c151e"}, + {file = "msrestazure-0.6.4.post1.tar.gz", hash = "sha256:39842007569e8c77885ace5c46e4bf2a9108fcb09b1e6efdf85b6e2c642b55d4"}, +] + +[package.dependencies] +adal = ">=0.6.0,<2.0.0" +msrest = ">=0.6.0,<2.0.0" +six = "*" + [[package]] name = "multidict" -version = "6.6.4" +version = "6.7.1" description = "multidict implementation" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f"}, - {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb"}, - {file = "multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0"}, - {file = "multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987"}, - {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f"}, - {file = "multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f"}, - {file = "multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0"}, - {file = "multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729"}, - {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c"}, - {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb"}, - {file = "multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50"}, - {file = "multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b"}, - {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f"}, - {file = "multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2"}, - {file = "multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e"}, - {file = "multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf"}, - {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8"}, - {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3"}, - {file = "multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c"}, - {file = "multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802"}, - {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24"}, - {file = "multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793"}, - {file = "multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e"}, - {file = "multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364"}, - {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e"}, - {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657"}, - {file = "multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a"}, - {file = "multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812"}, - {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a"}, - {file = "multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69"}, - {file = "multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf"}, - {file = "multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605"}, - {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb"}, - {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e"}, - {file = "multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45"}, - {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0"}, - {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92"}, - {file = "multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e"}, - {file = "multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4"}, - {file = "multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad"}, - {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:af7618b591bae552b40dbb6f93f5518328a949dac626ee75927bba1ecdeea9f4"}, - {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b6819f83aef06f560cb15482d619d0e623ce9bf155115150a85ab11b8342a665"}, - {file = "multidict-6.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d09384e75788861e046330308e7af54dd306aaf20eb760eb1d0de26b2bea2cb"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a59c63061f1a07b861c004e53869eb1211ffd1a4acbca330e3322efa6dd02978"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350f6b0fe1ced61e778037fdc7613f4051c8baf64b1ee19371b42a3acdb016a0"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c5cbac6b55ad69cb6aa17ee9343dfbba903118fd530348c330211dc7aa756d1"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630f70c32b8066ddfd920350bc236225814ad94dfa493fe1910ee17fe4365cbb"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d4916a81697faec6cb724a273bd5457e4c6c43d82b29f9dc02c5542fd21fc9"}, - {file = "multidict-6.6.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e42332cf8276bb7645d310cdecca93a16920256a5b01bebf747365f86a1675b"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f3be27440f7644ab9a13a6fc86f09cdd90b347c3c5e30c6d6d860de822d7cb53"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:21f216669109e02ef3e2415ede07f4f8987f00de8cdfa0cc0b3440d42534f9f0"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d9890d68c45d1aeac5178ded1d1cccf3bc8d7accf1f976f79bf63099fb16e4bd"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:edfdcae97cdc5d1a89477c436b61f472c4d40971774ac4729c613b4b133163cb"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0b2e886624be5773e69cf32bcb8534aecdeb38943520b240fed3d5596a430f2f"}, - {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:be5bf4b3224948032a845d12ab0f69f208293742df96dc14c4ff9b09e508fc17"}, - {file = "multidict-6.6.4-cp39-cp39-win32.whl", hash = "sha256:10a68a9191f284fe9d501fef4efe93226e74df92ce7a24e301371293bd4918ae"}, - {file = "multidict-6.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:ee25f82f53262f9ac93bd7e58e47ea1bdcc3393cef815847e397cba17e284210"}, - {file = "multidict-6.6.4-cp39-cp39-win_arm64.whl", hash = "sha256:f9867e55590e0855bcec60d4f9a092b69476db64573c9fe17e92b0c50614c16a"}, - {file = "multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c"}, - {file = "multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, + {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, + {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, + {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, + {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, + {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, + {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, + {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, + {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, + {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, + {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, + {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, + {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, + {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, + {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, + {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, + {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, + {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, + {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, + {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, + {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, + {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, + {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, + {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, + {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, + {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, + {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, ] [[package]] @@ -3939,20 +5780,20 @@ files = [ [[package]] name = "narwhals" -version = "2.1.2" +version = "2.16.0" description = "Extremely lightweight compatibility layer between dataframe libraries" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "narwhals-2.1.2-py3-none-any.whl", hash = "sha256:136b2f533a4eb3245c54254f137c5d14cef5c4668cff67dc6e911a602acd3547"}, - {file = "narwhals-2.1.2.tar.gz", hash = "sha256:afb9597e76d5b38c2c4b7c37d27a2418b8cc8049a66b8a5aca9581c92ae8f8bf"}, + {file = "narwhals-2.16.0-py3-none-any.whl", hash = "sha256:846f1fd7093ac69d63526e50732033e86c30ea0026a44d9b23991010c7d1485d"}, + {file = "narwhals-2.16.0.tar.gz", hash = "sha256:155bb45132b370941ba0396d123cf9ed192bf25f39c4cea726f2da422ca4e145"}, ] [package.extras] -cudf = ["cudf (>=24.10.0)"] +cudf = ["cudf-cu12 (>=24.10.0)"] dask = ["dask[dataframe] (>=2024.8)"] -duckdb = ["duckdb (>=1.0)"] +duckdb = ["duckdb (>=1.1)"] ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"] modin = ["modin"] pandas = ["pandas (>=1.1.3)"] @@ -3960,7 +5801,28 @@ polars = ["polars (>=0.20.4)"] pyarrow = ["pyarrow (>=13.0.0)"] pyspark = ["pyspark (>=3.5.0)"] pyspark-connect = ["pyspark[connect] (>=3.5.0)"] -sqlframe = ["sqlframe (>=3.22.0)"] +sql = ["duckdb (>=1.1)", "sqlparse"] +sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"] + +[[package]] +name = "neo4j" +version = "5.28.3" +description = "Neo4j Bolt driver for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "neo4j-5.28.3-py3-none-any.whl", hash = "sha256:dbf6d9211b861bc3dd62dccbf8a74d1e33e0c602084dd123b753edf46e1fdfad"}, + {file = "neo4j-5.28.3.tar.gz", hash = "sha256:0625aaaf0963bc99a7231e946952f579792c3be22687192b20e0b74aa1233a2b"}, +] + +[package.dependencies] +pytz = "*" + +[package.extras] +numpy = ["numpy (>=1.7.0,<3.0.0)"] +pandas = ["numpy (>=1.7.0,<3.0.0)", "pandas (>=1.1.0,<3.0.0)"] +pyarrow = ["pyarrow (>=1.0.0)"] [[package]] name = "nest-asyncio" @@ -3974,6 +5836,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" @@ -4046,16 +5934,55 @@ rsa = ["cryptography (>=3.0.0)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "oci" +version = "2.160.3" +description = "Oracle Cloud Infrastructure Python SDK" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"}, + {file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"}, +] + +[package.dependencies] +certifi = "*" +circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""} +cryptography = ">=3.2.1,<46.0.0" +pyOpenSSL = ">=17.5.0,<25.0.0" +python-dateutil = ">=2.5.3,<3.0.0" +pytz = ">=2016.10" + +[package.extras] +adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""] + +[[package]] +name = "okta" +version = "0.0.4" +description = "Okta client APIs" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "okta-0.0.4.tar.gz", hash = "sha256:53e792c68d3684ff4140b4cb1c02af3821090368f8110fde54c0bdb638449332"}, +] + +[package.dependencies] +python-dateutil = ">=2.4.2" +requests = ">=2.5.3" +six = ">=1.9.0" + [[package]] name = "openai" -version = "1.101.0" +version = "1.109.1" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "openai-1.101.0-py3-none-any.whl", hash = "sha256:6539a446cce154f8d9fb42757acdfd3ed9357ab0d34fcac11096c461da87133b"}, - {file = "openai-1.101.0.tar.gz", hash = "sha256:29f56df2236069686e64aca0e13c24a4ec310545afb25ef7da2ab1a18523f22d"}, + {file = "openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315"}, + {file = "openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869"}, ] [package.dependencies] @@ -4074,16 +6001,43 @@ datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] realtime = ["websockets (>=13,<16)"] voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] +[[package]] +name = "openstacksdk" +version = "4.2.0" +description = "An SDK for building applications to work with OpenStack" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "openstacksdk-4.2.0-py3-none-any.whl", hash = "sha256:238be0fa5d9899872b00787ab38e84f92fd6dc87525fde0965dadcdc12196dc6"}, + {file = "openstacksdk-4.2.0.tar.gz", hash = "sha256:5cb9450dcce8054a2caf89d8be9e55057ddfa219a954e781032241eb29280445"}, +] + +[package.dependencies] +cryptography = ">=2.7" +decorator = ">=4.4.1" +"dogpile.cache" = ">=0.6.5" +iso8601 = ">=0.1.11" +jmespath = ">=0.9.0" +jsonpatch = ">=1.16,<1.20 || >1.20" +keystoneauth1 = ">=3.18.0" +os-service-types = ">=1.7.0" +pbr = ">=2.0.0,<2.1.0 || >2.1.0" +platformdirs = ">=3" +psutil = ">=3.2.2" +PyYAML = ">=3.13" +requestsexceptions = ">=1.2.0" + [[package]] name = "opentelemetry-api" -version = "1.36.0" +version = "1.39.1" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "opentelemetry_api-1.36.0-py3-none-any.whl", hash = "sha256:02f20bcacf666e1333b6b1f04e647dc1d5111f86b8e510238fcc56d7762cda8c"}, - {file = "opentelemetry_api-1.36.0.tar.gz", hash = "sha256:9a72572b9c416d004d492cbc6e61962c0501eaf945ece9b5a0f56597d8348aa0"}, + {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, + {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, ] [package.dependencies] @@ -4092,47 +6046,63 @@ typing-extensions = ">=4.5.0" [[package]] name = "opentelemetry-sdk" -version = "1.36.0" +version = "1.39.1" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "opentelemetry_sdk-1.36.0-py3-none-any.whl", hash = "sha256:19fe048b42e98c5c1ffe85b569b7073576ad4ce0bcb6e9b4c6a39e890a6c45fb"}, - {file = "opentelemetry_sdk-1.36.0.tar.gz", hash = "sha256:19c8c81599f51b71670661ff7495c905d8fdf6976e41622d5245b791b06fa581"}, + {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, + {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, ] [package.dependencies] -opentelemetry-api = "1.36.0" -opentelemetry-semantic-conventions = "0.57b0" +opentelemetry-api = "1.39.1" +opentelemetry-semantic-conventions = "0.60b1" typing-extensions = ">=4.5.0" [[package]] name = "opentelemetry-semantic-conventions" -version = "0.57b0" +version = "0.60b1" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "opentelemetry_semantic_conventions-0.57b0-py3-none-any.whl", hash = "sha256:757f7e76293294f124c827e514c2a3144f191ef175b069ce8d1211e1e38e9e78"}, - {file = "opentelemetry_semantic_conventions-0.57b0.tar.gz", hash = "sha256:609a4a79c7891b4620d64c7aac6898f872d790d75f22019913a660756f27ff32"}, + {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, + {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, ] [package.dependencies] -opentelemetry-api = "1.36.0" +opentelemetry-api = "1.39.1" typing-extensions = ">=4.5.0" +[[package]] +name = "os-service-types" +version = "1.8.2" +description = "Python library for consuming OpenStack sevice-types-authority data" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e"}, + {file = "os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" +typing-extensions = ">=4.1.0" + [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] @@ -4223,171 +6193,186 @@ xml = ["lxml (>=4.9.2)"] [[package]] name = "pbr" -version = "7.0.1" +version = "7.0.3" description = "Python Build Reasonableness" optional = false python-versions = ">=2.6" -groups = ["dev"] +groups = ["main"] files = [ - {file = "pbr-7.0.1-py2.py3-none-any.whl", hash = "sha256:32df5156fbeccb6f8a858d1ebc4e465dcf47d6cc7a4895d5df9aa951c712fc35"}, - {file = "pbr-7.0.1.tar.gz", hash = "sha256:3ecbcb11d2b8551588ec816b3756b1eb4394186c3b689b17e04850dfc20f7e57"}, + {file = "pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b"}, + {file = "pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29"}, ] [package.dependencies] setuptools = "*" [[package]] -name = "pillow" -version = "11.3.0" -description = "Python Imaging Library (Fork)" +name = "pdpyras" +version = "5.4.1" +description = "PagerDuty Python REST API Sessions." optional = false -python-versions = ">=3.9" +python-versions = ">=3.6" groups = ["main"] files = [ - {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, - {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, - {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, - {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, - {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, - {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, - {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, - {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, - {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, - {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, - {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, - {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, - {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, - {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, - {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, - {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, - {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, - {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, - {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, - {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, - {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, - {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, - {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, - {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, - {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, - {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, - {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, - {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, - {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, - {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, - {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, - {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, - {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, - {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, - {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, - {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, - {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, - {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, - {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, - {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, - {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, - {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, - {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, - {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, - {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, - {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, - {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, - {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, - {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, - {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, - {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, - {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, - {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, + {file = "pdpyras-5.4.1-py2.py3-none-any.whl", hash = "sha256:e16020cf57e4c916ab3dace7c7dffe21a2e7059ab7411ce3ddf1e620c54e9c89"}, + {file = "pdpyras-5.4.1.tar.gz", hash = "sha256:36021aff5979a79f1d87edc95e0c46e98ce8549292bc0cab3d9f33501795703b"}, +] + +[package.dependencies] +requests = "*" +urllib3 = "*" + +[[package]] +name = "pillow" +version = "12.1.1" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, + {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, + {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, + {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, + {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, + {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, + {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, + {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, + {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, + {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, + {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, + {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, + {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, + {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, + {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, + {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, + {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, + {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, + {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, + {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, + {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, + {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, + {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, + {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, ] [package.extras] docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] -test-arrow = ["pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions ; python_version < \"3.10\""] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] -name = "platformdirs" -version = "4.3.8" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +name = "pkginfo" +version = "1.12.1.2" +description = "Query metadata from sdists / bdists / installed packages." optional = false -python-versions = ">=3.9" -groups = ["dev"] +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, - {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, + {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, + {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, ] [package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] +testing = ["pytest", "pytest-cov", "wheel"] + +[[package]] +name = "platformdirs" +version = "4.5.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, + {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, +] + +[package.extras] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] [[package]] name = "plotly" -version = "6.3.0" +version = "6.5.2" description = "An open-source interactive data visualization library for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "plotly-6.3.0-py3-none-any.whl", hash = "sha256:7ad806edce9d3cdd882eaebaf97c0c9e252043ed1ed3d382c3e3520ec07806d4"}, - {file = "plotly-6.3.0.tar.gz", hash = "sha256:8840a184d18ccae0f9189c2b9a2943923fd5cae7717b723f36eef78f444e5a73"}, + {file = "plotly-6.5.2-py3-none-any.whl", hash = "sha256:91757653bd9c550eeea2fa2404dba6b85d1e366d54804c340b2c874e5a7eb4a4"}, + {file = "plotly-6.5.2.tar.gz", hash = "sha256:7478555be0198562d1435dee4c308268187553cc15516a2f4dd034453699e393"}, ] [package.dependencies] @@ -4400,7 +6385,7 @@ dev-build = ["build", "jupyter", "plotly[dev-core]"] dev-core = ["pytest", "requests", "ruff (==0.11.12)"] dev-optional = ["anywidget", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "numpy", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "plotly[dev-build]", "plotly[kaleido]", "polars[timezone]", "pyarrow", "pyshp", "pytz", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] express = ["numpy"] -kaleido = ["kaleido (>=1.0.0)"] +kaleido = ["kaleido (>=1.1.0)"] [[package]] name = "pluggy" @@ -4418,16 +6403,52 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "policyuniverse" +version = "1.5.1.20231109" +description = "Parse and Process AWS IAM Policies, Statements, ARNs, and wildcards." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321"}, + {file = "policyuniverse-1.5.1.20231109.tar.gz", hash = "sha256:74e56d410560915c2c5132e361b0130e4bffe312a2f45230eac50d7c094bc40a"}, +] + +[package.extras] +dev = ["black", "pre-commit"] +tests = ["bandit", "coveralls", "pytest"] + +[[package]] +name = "portalocker" +version = "2.10.1" +description = "Wraps the portalocker recipe for easy usage" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "portalocker-2.10.1-py3-none-any.whl", hash = "sha256:53a5984ebc86a025552264b459b46a2086e269b21823cb572f8f28ee759e45bf"}, + {file = "portalocker-2.10.1.tar.gz", hash = "sha256:ef1bf844e878ab08aee7e40184156e1151f228f103aa5c6bd0724cc330960f8f"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + +[package.extras] +docs = ["sphinx (>=1.7.1)"] +redis = ["redis"] +tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"] + [[package]] name = "prompt-toolkit" -version = "3.0.51" +version = "3.0.52" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07"}, - {file = "prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed"}, + {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, + {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, ] [package.dependencies] @@ -4435,122 +6456,146 @@ wcwidth = "*" [[package]] name = "propcache" -version = "0.3.2" +version = "0.4.1" description = "Accelerated property cache" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c"}, - {file = "propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70"}, - {file = "propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e"}, - {file = "propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897"}, - {file = "propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1"}, - {file = "propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1"}, - {file = "propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43"}, - {file = "propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02"}, - {file = "propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330"}, - {file = "propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394"}, - {file = "propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4"}, - {file = "propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef"}, - {file = "propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb"}, - {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe"}, - {file = "propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1"}, - {file = "propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9"}, - {file = "propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f"}, - {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, ] [[package]] name = "proto-plus" -version = "1.26.1" +version = "1.27.0" description = "Beautiful, Pythonic protocol buffers" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, - {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, + {file = "proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82"}, + {file = "proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4"}, ] [package.dependencies] @@ -4561,26 +6606,27 @@ testing = ["google-api-core (>=1.31.5)"] [[package]] name = "protobuf" -version = "6.32.0" +version = "6.33.5" description = "" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "protobuf-6.32.0-cp310-abi3-win32.whl", hash = "sha256:84f9e3c1ff6fb0308dbacb0950d8aa90694b0d0ee68e75719cb044b7078fe741"}, - {file = "protobuf-6.32.0-cp310-abi3-win_amd64.whl", hash = "sha256:a8bdbb2f009cfc22a36d031f22a625a38b615b5e19e558a7b756b3279723e68e"}, - {file = "protobuf-6.32.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d52691e5bee6c860fff9a1c86ad26a13afbeb4b168cd4445c922b7e2cf85aaf0"}, - {file = "protobuf-6.32.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:501fe6372fd1c8ea2a30b4d9be8f87955a64d6be9c88a973996cef5ef6f0abf1"}, - {file = "protobuf-6.32.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:75a2aab2bd1aeb1f5dc7c5f33bcb11d82ea8c055c9becbb41c26a8c43fd7092c"}, - {file = "protobuf-6.32.0-cp39-cp39-win32.whl", hash = "sha256:7db8ed09024f115ac877a1427557b838705359f047b2ff2f2b2364892d19dacb"}, - {file = "protobuf-6.32.0-cp39-cp39-win_amd64.whl", hash = "sha256:15eba1b86f193a407607112ceb9ea0ba9569aed24f93333fe9a497cf2fda37d3"}, - {file = "protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783"}, - {file = "protobuf-6.32.0.tar.gz", hash = "sha256:a81439049127067fc49ec1d36e25c6ee1d1a2b7be930675f919258d03c04e7d2"}, + {file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"}, + {file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"}, + {file = "protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5"}, + {file = "protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190"}, + {file = "protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd"}, + {file = "protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0"}, + {file = "protobuf-6.33.5-cp39-cp39-win32.whl", hash = "sha256:a3157e62729aafb8df6da2c03aa5c0937c7266c626ce11a278b6eb7963c4e37c"}, + {file = "protobuf-6.33.5-cp39-cp39-win_amd64.whl", hash = "sha256:8f04fa32763dcdb4973d537d6b54e615cc61108c7cb38fe59310c3192d29510a"}, + {file = "protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02"}, + {file = "protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c"}, ] [[package]] name = "prowler" -version = "5.13.0" +version = "5.19.0" description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks." optional = false python-versions = ">3.9.1,<3.13" @@ -4589,6 +6635,19 @@ files = [] develop = false [package.dependencies] +alibabacloud_actiontrail20200706 = "2.4.1" +alibabacloud_credentials = "1.0.3" +alibabacloud_cs20151215 = "6.1.0" +alibabacloud_ecs20140526 = "7.2.5" +alibabacloud-gateway-oss-util = "0.0.3" +alibabacloud_oss20190517 = "1.0.6" +alibabacloud_ram20150501 = "1.2.0" +alibabacloud-rds20140815 = "12.0.0" +alibabacloud_sas20181203 = "6.1.0" +alibabacloud-sls20201230 = "5.9.0" +alibabacloud_sts20150401 = "1.1.6" +alibabacloud_tea_openapi = "0.4.1" +alibabacloud_vpc20160428 = "6.13.0" alive-progress = "3.3.0" awsipranges = "0.3.3" azure-identity = "1.21.0" @@ -4605,6 +6664,7 @@ azure-mgmt-keyvault = "10.3.1" azure-mgmt-loganalytics = "12.0.0" azure-mgmt-monitor = "6.0.2" azure-mgmt-network = "28.1.0" +azure-mgmt-postgresqlflexibleservers = "1.1.0" azure-mgmt-rdbms = "10.1.0" azure-mgmt-recoveryservices = "3.1.0" azure-mgmt-recoveryservicesbackup = "9.2.0" @@ -4617,10 +6677,11 @@ azure-mgmt-subscription = "3.1.1" azure-mgmt-web = "8.0.0" azure-monitor-query = "2.0.0" azure-storage-blob = "12.24.1" -boto3 = "1.39.15" -botocore = "1.39.15" +boto3 = "1.40.61" +botocore = "1.40.61" +cloudflare = "4.3.1" colorama = "0.4.6" -cryptography = "44.0.1" +cryptography = "44.0.3" dash = "3.1.1" dash-bootstrap-components = "2.0.3" detect-secrets = "1.5.0" @@ -4634,16 +6695,18 @@ markdown = "3.9.0" microsoft-kiota-abstractions = "1.9.2" msgraph-sdk = "1.23.0" numpy = "2.0.2" +oci = "2.160.3" +openstacksdk = "4.2.0" pandas = "2.2.3" py-iam-expand = "0.1.0" -py-ocsf-models = "0.5.0" +py-ocsf-models = "0.8.1" pydantic = ">=2.0,<3.0" pygithub = "2.5.0" python-dateutil = ">=2.9.0.post0,<3.0.0" pytz = "2025.1" schema = "0.7.5" shodan = "1.31.0" -slack-sdk = "3.34.0" +slack-sdk = "3.39.0" tabulate = "0.9.0" tzlocal = "5.3.1" @@ -4651,37 +6714,42 @@ tzlocal = "5.3.1" type = "git" url = "https://github.com/prowler-cloud/prowler.git" reference = "master" -resolved_reference = "a52697bfdfee83d14a49c11dcbe96888b5cd767e" +resolved_reference = "ceb4691c3657e7db3d178896bfc241d14f194295" [[package]] name = "psutil" -version = "6.0.0" -description = "Cross-platform lib for process and system monitoring in Python." +version = "7.2.2" +description = "Cross-platform lib for process and system monitoring." optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -groups = ["main", "dev"] +python-versions = ">=3.6" +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-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, + {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, + {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, + {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, + {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, + {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, + {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, + {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, + {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, ] [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", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] +test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] [[package]] name = "psycopg2-binary" @@ -4765,6 +6833,18 @@ files = [ {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, ] +[[package]] +name = "py-deviceid" +version = "0.1.1" +description = "A simple library to get or create a unique device id for a device in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "py_deviceid-0.1.1-py3-none-any.whl", hash = "sha256:c0e32815e87a08087a0811c18f4402ee88b28a321f997753d75ecdaab570321b"}, + {file = "py_deviceid-0.1.1.tar.gz", hash = "sha256:c3e7577ada23666e7f39e69370dfdaa76fe9de79c02635376d6aa0229bfa30e3"}, +] + [[package]] name = "py-iam-expand" version = "0.1.0" @@ -4782,31 +6862,31 @@ iamdata = ">=0.1.202504091" [[package]] name = "py-ocsf-models" -version = "0.5.0" +version = "0.8.1" description = "This is a Python implementation of the OCSF models. The models are used to represent the data of the OCSF Schema defined in https://schema.ocsf.io/." optional = false -python-versions = "<3.14,>3.9.1" +python-versions = "<3.15,>3.9.1" groups = ["main"] files = [ - {file = "py_ocsf_models-0.5.0-py3-none-any.whl", hash = "sha256:7933253f56782c04c412d976796db429577810b951fe4195351794500b5962d8"}, - {file = "py_ocsf_models-0.5.0.tar.gz", hash = "sha256:bf05e955809d1ec3ab1007e4a4b2a8a0afa74b6e744ea8ffbf386e46b3af0a76"}, + {file = "py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e"}, + {file = "py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c"}, ] [package.dependencies] -cryptography = "44.0.1" +cryptography = ">=44.0.3,<47" email-validator = "2.2.0" -pydantic = ">=2.9.2,<3.0.0" +pydantic = ">=2.12.0,<3.0.0" [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.2" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, + {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, + {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, ] [[package]] @@ -4838,76 +6918,34 @@ files = [ [[package]] name = "pycparser" -version = "2.22" +version = "3.0" description = "C parser in Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main", "dev"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] -markers = {dev = "platform_python_implementation != \"PyPy\""} - -[[package]] -name = "pycurl" -version = "7.45.6" -description = "PycURL -- A Python Interface To The cURL library" -optional = false -python-versions = ">=3.5" -groups = ["main"] -markers = "sys_platform != \"win32\" and platform_python_implementation == \"CPython\"" -files = [ - {file = "pycurl-7.45.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c31b390f1e2cd4525828f1bb78c1f825c0aab5d1588228ed71b22c4784bdb593"}, - {file = "pycurl-7.45.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:942b352b69184cb26920db48e0c5cb95af39874b57dbe27318e60f1e68564e37"}, - {file = "pycurl-7.45.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3441ee77e830267aa6e2bb43b29fd5f8a6bd6122010c76a6f0bf84462e9ea9c7"}, - {file = "pycurl-7.45.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2a21e13278d7553a04b421676c458449f6c10509bebf04993f35154b06ee2b20"}, - {file = "pycurl-7.45.6-cp310-cp310-win32.whl", hash = "sha256:d0b5501d527901369aba307354530050f56cd102410f2a3bacd192dc12c645e3"}, - {file = "pycurl-7.45.6-cp310-cp310-win_amd64.whl", hash = "sha256:abe1b204a2f96f2eebeaf93411f03505b46d151ef6d9d89326e6dece7b3a008a"}, - {file = "pycurl-7.45.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f57ad26d6ab390391ad5030790e3f1a831c1ee54ad3bf969eb378f5957eeb0a"}, - {file = "pycurl-7.45.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6fd295f03c928da33a00f56c91765195155d2ac6f12878f6e467830b5dce5f5"}, - {file = "pycurl-7.45.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:334721ce1ccd71ff8e405470768b3d221b4393570ccc493fcbdbef4cd62e91ed"}, - {file = "pycurl-7.45.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0cd6b7794268c17f3c660162ed6381769ce0ad260331ef49191418dfc3a2d61a"}, - {file = "pycurl-7.45.6-cp311-cp311-win32.whl", hash = "sha256:357ea634395310085b9d5116226ac5ec218a6ceebf367c2451ebc8d63a6e9939"}, - {file = "pycurl-7.45.6-cp311-cp311-win_amd64.whl", hash = "sha256:878ae64484db18f8f10ba99bffc83fefb4fe8f5686448754f93ec32fa4e4ee93"}, - {file = "pycurl-7.45.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c872d4074360964697c39c1544fe8c91bfecbff27c1cdda1fee5498e5fdadcda"}, - {file = "pycurl-7.45.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56d1197eadd5774582b259cde4364357da71542758d8e917f91cc6ed7ed5b262"}, - {file = "pycurl-7.45.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8a99e56d2575aa74c48c0cd08852a65d5fc952798f76a34236256d5589bf5aa0"}, - {file = "pycurl-7.45.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c04230b9e9cfdca9cf3eb09a0bec6cf2f084640f1f1ca1929cca51411af85de2"}, - {file = "pycurl-7.45.6-cp312-cp312-win32.whl", hash = "sha256:ae893144b82d72d95c932ebdeb81fc7e9fde758e5ecd5dd10ad5b67f34a8b8ee"}, - {file = "pycurl-7.45.6-cp312-cp312-win_amd64.whl", hash = "sha256:56f841b6f2f7a8b2d3051b9ceebd478599dbea3c8d1de8fb9333c895d0c1eea5"}, - {file = "pycurl-7.45.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c09b7180799af70fc1d4eed580cfb1b9f34fda9081f73a3e3bc9a0e5a4c0e9b"}, - {file = "pycurl-7.45.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:361bf94b2a057c7290f9ab84e935793ca515121fc012f4b6bef6c3b5e4ea4397"}, - {file = "pycurl-7.45.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bb9eff0c7794af972da769a887c87729f1bcd8869297b1c01a2732febbb75876"}, - {file = "pycurl-7.45.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26839d43dc7fff6b80e0067f185cc1d0e9be2ae6e2e2361ae8488cead5901c04"}, - {file = "pycurl-7.45.6-cp313-cp313-win32.whl", hash = "sha256:a721c2696a71b1aa5ecf82e6d0ade64bc7211b7317f1c9c66e82f82e2264d8b4"}, - {file = "pycurl-7.45.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0198ebcda8686b3a0c66d490a687fa5fd466f8ecc2f20a0ed0931579538ae3d"}, - {file = "pycurl-7.45.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a554a2813d415a7bb9a996a6298f3829f57e987635dcab9f1197b2dccd0ab3b2"}, - {file = "pycurl-7.45.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f721e3394e5bd7079802ec1819b19c5be4842012268cc45afcb3884efb31cf0"}, - {file = "pycurl-7.45.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:81005c0f681d31d5af694d1d3c18bbf1bed0bc8b2bb10fb7388cb1378ba9bd6a"}, - {file = "pycurl-7.45.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:3fc0b505c37c7c54d88ced27e1d9e3241130987c24bf1611d9bbd9a3e499e07c"}, - {file = "pycurl-7.45.6-cp39-cp39-win32.whl", hash = "sha256:1309fc0f558a80ca444a3a5b0bdb1572a4d72b195233f0e65413b4d4dd78809b"}, - {file = "pycurl-7.45.6-cp39-cp39-win_amd64.whl", hash = "sha256:2d1a49418b8b4c61f52e06d97b9c16142b425077bd997a123a2ba9ef82553203"}, - {file = "pycurl-7.45.6.tar.gz", hash = "sha256:2b73e66b22719ea48ac08a93fc88e57ef36d46d03cb09d972063c9aa86bb74e6"}, + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] [[package]] name = "pydantic" -version = "2.11.7" +version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" 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.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [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.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -4915,115 +6953,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" 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.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.14.1" [[package]] name = "pygithub" @@ -5051,7 +7111,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -5062,14 +7122,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.11.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, + {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, ] [package.dependencies] @@ -5077,9 +7137,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pylint" @@ -5110,47 +7170,151 @@ spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] [[package]] -name = "pynacl" -version = "1.5.0" -description = "Python binding to the Networking and Cryptography (NaCl) library" +name = "pymsalruntime" +version = "0.18.1" +description = "The MSALRuntime Python Interop Package" optional = false python-versions = ">=3.6" groups = ["main"] +markers = "(platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\") and sys_platform == \"win32\"" files = [ - {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, - {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0c22e2e83faa10de422bbfaacc1bb2887c9025ee8a53f0fc2e4f7db01c4a7b66"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8ce2944a0f944833d047bb121396091e00287e2b6373716106da86ea99abf379"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:9f7945ae0ee78357e9ca87d381f1c19763629a7197391ae7f84f4967a9f06e5b"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-win32.whl", hash = "sha256:10020abdfc34bbbf3414b86359de551d2d8bc7c241bc38c59a2468c4d49f21d5"}, + {file = "pymsalruntime-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:f9aec2f44470d71feae35b611d1d8f15a549d96446e4f60e1ca1fb71856fffed"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e9320fb187fe1298d2165fa248af00907ca15d3a903a1d35fed86f6bc20b5880"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:9b2cecf3a570b7812d2007764df6dfbc27fca401a0d74532d5403aa20a9ef380"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:6f66fd99668abc3d4b8d93a9eb80c75178dc63186c79e6dbe133427b279835e0"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-win32.whl", hash = "sha256:74416947b1071054f3258cac3448a7adf708888727bf283267df2bb27f0998f1"}, + {file = "pymsalruntime-0.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:beb926655aae3367b7e4bda2baad86f9271beefee1121f71642da0ed4de37fd2"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6c07651cf4e07690d1b022da0977f56820ef553ac6dcbf4c9e68e9611020997"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0b6c4f54ec13309cc7b717ac8760c2d9856d4924cefa2b794b6d03db4cfdeef8"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:06c73a47f024fcf36006b89fe32f2f6f6a004aa661cf8a03d3e496d1ef84cfe8"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-win32.whl", hash = "sha256:ace12bf9b7fcbf1bf21a03c227717e09ba99acd9190623fe0821a08832ece4eb"}, + {file = "pymsalruntime-0.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:f9fd8ea52395f52f7d62498e47754adf2bfe6530816ff57eff1ba6f524aee51b"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:047a98b6709cddf6a1f50f78ee16d06fea0f42a44971b6d3e2988537277a1a17"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:910e653c65cd66fa9ce46dec103d3948da2276f7d4d315631a145eaab968d9a8"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:7ae0b160983ea0715d8ac69b441bbd29e7a9f31c9a5a2c350c79a794f5599f38"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-win32.whl", hash = "sha256:adf4200a1b423fe5d8e984c142cc64f0b76a9b0f7f8ff767490a2dde94fa642b"}, + {file = "pymsalruntime-0.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:5a759aa551d084b160799f6df59c9891898ab305eb75ff1705bf04281675eb4b"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_arm64.whl", hash = "sha256:12b8990c4da1327ea46f6271bd57b28a90d3e795deacb370052914c3ff40d4c5"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:8dd68f9fedc200950093378b30a2ade4517324cef060788a759b575ea58dc6b2"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-manylinux_2_35_x86_64.whl", hash = "sha256:7183b1b1542a277db119fe55285c7609c661b8506b99cd7e53b7066ce6b838e4"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-win32.whl", hash = "sha256:56c3d708ba86311f049b004de81aa97655fed82782d3ec67e14ae1e27d4f5e5b"}, + {file = "pymsalruntime-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:a8adc80fcf723b980976b81a0b409affe80f32d89ae6096d856fd20471d2f0c1"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:600d0f2b9b03dfb457ee1e13f191c2c217c0f6bceca512f1741e5215bc4bc5dc"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:daae8515ae8adac8662d8230f22af242f87c72d86f308ec51b7432f316199c1b"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-manylinux_2_35_x86_64.whl", hash = "sha256:864b8b9555a180c6baf8a57df3976b2e511582d54099561fbfe73f9f0b95c9f5"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-win32.whl", hash = "sha256:b90a3c8079ded9d5abc765bd90fdc34f6e49412793740ddbc6122a601008d50f"}, + {file = "pymsalruntime-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:852dc82b3eaad0cce2c583314705183bf216e7fa7178040defd3a13195c1c406"}, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, ] [package.dependencies] -cffi = ">=1.4.1" +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} [package.extras] -docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + +[[package]] +name = "pyopenssl" +version = "24.3.0" +description = "Python wrapper module around the OpenSSL library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"}, + {file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"}, +] + +[package.dependencies] +cryptography = ">=41.0.5,<45" + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] [[package]] name = "pyparsing" -version = "3.2.3" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" +version = "3.3.2" +description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf"}, - {file = "pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be"}, + {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, + {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, ] [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyreadline3" +version = "3.5.4" +description = "A python implementation of GNU readline." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, + {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, +] + +[package.extras] +dev = ["build", "flake8", "mypy", "pytest", "twine"] + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + [[package]] name = "pytest" version = "8.2.2" @@ -5174,36 +7338,32 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-celery" -version = "1.1.3" +version = "1.2.1" description = "Pytest plugin for Celery" optional = false python-versions = "<4.0,>=3.8" groups = ["main"] files = [ - {file = "pytest_celery-1.1.3-py3-none-any.whl", hash = "sha256:4cdb5f658dc472509e8be71f745d26bcb8246397661534f5709d2a55edc43286"}, - {file = "pytest_celery-1.1.3.tar.gz", hash = "sha256:ac7eee546b4d9fb5c742eaaece98187f1f5e5f5622fbaa8e7729bb46923c54fc"}, + {file = "pytest_celery-1.2.1-py3-none-any.whl", hash = "sha256:0441ab0c2a712b775be16ffda3d7deb31995fd7b5e9d71630e7ea98b474346a3"}, + {file = "pytest_celery-1.2.1.tar.gz", hash = "sha256:7873fb3cf4fbfe9b0dd15d359bdb8bbab4a41c7e48f5b0adb7d36138d3704d52"}, ] [package.dependencies] -boto3 = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"sqs\""} -botocore = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"sqs\""} celery = "*" -debugpy = ">=1.8.5,<2.0.0" +debugpy = ">=1.8.12,<2.0.0" docker = ">=7.1.0,<8.0.0" -psutil = ">=6.0.0" -pycurl = {version = "*", optional = true, markers = "sys_platform != \"win32\" and platform_python_implementation == \"CPython\" and (extra == \"all\" or extra == \"sqs\")"} +kombu = "*" +psutil = ">=7.0.0" pytest-docker-tools = ">=3.1.3" -python-memcached = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"memcached\""} redis = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"redis\""} -setuptools = ">=75.1.0" +setuptools = {version = ">=75.8.0", markers = "python_version >= \"3.9\" and python_version < \"4.0\""} tenacity = ">=9.0.0" -urllib3 = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"sqs\""} [package.extras] -all = ["boto3", "botocore", "pycurl ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "python-memcached", "redis", "urllib3"] +all = ["boto3", "botocore", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"] memcached = ["python-memcached"] redis = ["redis"] -sqs = ["boto3", "botocore", "pycurl ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3"] +sqs = ["boto3", "botocore", "urllib3 (>=1.26.16,<2.0)"] [[package]] name = "pytest-cov" @@ -5345,17 +7505,21 @@ files = [ six = ">=1.5" [[package]] -name = "python-memcached" -version = "1.62" -description = "Pure python memcached client" +name = "python-digitalocean" +version = "1.17.0" +description = "digitalocean.com API to manage Droplets and Images" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "python-memcached-1.62.tar.gz", hash = "sha256:0285470599b7f593fbf3bec084daa1f483221e68c1db2cf1d846a9f7c2655103"}, - {file = "python_memcached-1.62-py2.py3-none-any.whl", hash = "sha256:1bdd8d2393ff53e80cd5e9442d750e658e0b35c3eebb3211af137303e3b729d1"}, + {file = "python-digitalocean-1.17.0.tar.gz", hash = "sha256:107854fde1aafa21774e8053cf253b04173613c94531f75d5a039ad770562b24"}, + {file = "python_digitalocean-1.17.0-py3-none-any.whl", hash = "sha256:0032168e022e85fca314eb3f8dfaabf82087f2ed40839eb28f1eeeeca5afb1fa"}, ] +[package.dependencies] +jsonpickle = "*" +requests = "*" + [[package]] name = "python3-saml" version = "1.16.0" @@ -5396,7 +7560,6 @@ description = "Python for Window Extensions" optional = false python-versions = "*" groups = ["main", "dev"] -markers = "sys_platform == \"win32\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -5419,100 +7582,122 @@ files = [ {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, ] +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] name = "redis" -version = "6.4.0" +version = "7.1.0" description = "Python client for Redis database and key-value store" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f"}, - {file = "redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010"}, + {file = "redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b"}, + {file = "redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c"}, ] [package.dependencies] async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} [package.extras] +circuit-breaker = ["pybreaker (>=1.4.0)"] hiredis = ["hiredis (>=3.2.0)"] jwt = ["pyjwt (>=2.9.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" description = "JSON Referencing + Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, - {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, ] [package.dependencies] @@ -5520,16 +7705,157 @@ 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" +version = "4.4.9" description = "The Reportlab Toolkit" optional = false python-versions = "<4,>=3.9" groups = ["main"] files = [ - {file = "reportlab-4.4.4-py3-none-any.whl", hash = "sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb"}, - {file = "reportlab-4.4.4.tar.gz", hash = "sha256:cb2f658b7f4a15be2cc68f7203aa67faef67213edd4f2d4bdd3eb20dab75a80d"}, + {file = "reportlab-4.4.9-py3-none-any.whl", hash = "sha256:68e2d103ae8041a37714e8896ec9b79a1c1e911d68c3bd2ea17546568cf17bfd"}, + {file = "reportlab-4.4.9.tar.gz", hash = "sha256:7cf487764294ee791a4781f5a157bebce262a666ae4bbb87786760a9676c9378"}, ] [package.dependencies] @@ -5559,6 +7885,7 @@ files = [ certifi = ">=2017.4.17" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" +PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7", optional = true, markers = "extra == \"socks\""} urllib3 = ">=1.21.1,<3" [package.extras] @@ -5567,14 +7894,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "requests-file" -version = "2.1.0" +version = "3.0.1" description = "File transport adapter for Requests" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c"}, - {file = "requests_file-2.1.0.tar.gz", hash = "sha256:0f549a3f3b0699415ac04d167e9cb39bccfb730cb832b4d20be3d9867356e658"}, + {file = "requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2"}, + {file = "requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576"}, ] [package.dependencies] @@ -5599,6 +7926,18 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "requestsexceptions" +version = "1.4.0" +description = "Import exceptions from potentially bundled packages in requests." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3"}, + {file = "requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065"}, +] + [[package]] name = "retrying" version = "1.4.2" @@ -5613,14 +7952,14 @@ files = [ [[package]] name = "rich" -version = "14.1.0" +version = "14.3.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["dev"] +groups = ["main", "dev"] files = [ - {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, - {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, + {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, + {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, ] [package.dependencies] @@ -5632,167 +7971,127 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "rpds-py" -version = "0.27.0" +version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "rpds_py-0.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:130c1ffa5039a333f5926b09e346ab335f0d4ec393b030a18549a7c7e7c2cea4"}, - {file = "rpds_py-0.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a4cf32a26fa744101b67bfd28c55d992cd19438aff611a46cac7f066afca8fd4"}, - {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64a0fe3f334a40b989812de70160de6b0ec7e3c9e4a04c0bbc48d97c5d3600ae"}, - {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a0ff7ee28583ab30a52f371b40f54e7138c52ca67f8ca17ccb7ccf0b383cb5f"}, - {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15ea4d2e182345dd1b4286593601d766411b43f868924afe297570658c31a62b"}, - {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36184b44bf60a480863e51021c26aca3dfe8dd2f5eeabb33622b132b9d8b8b54"}, - {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b78430703cfcf5f5e86eb74027a1ed03a93509273d7c705babb547f03e60016"}, - {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:dbd749cff1defbde270ca346b69b3baf5f1297213ef322254bf2a28537f0b046"}, - {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bde37765564cd22a676dd8101b657839a1854cfaa9c382c5abf6ff7accfd4ae"}, - {file = "rpds_py-0.27.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1d66f45b9399036e890fb9c04e9f70c33857fd8f58ac8db9f3278cfa835440c3"}, - {file = "rpds_py-0.27.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d85d784c619370d9329bbd670f41ff5f2ae62ea4519761b679d0f57f0f0ee267"}, - {file = "rpds_py-0.27.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5df559e9e7644d9042f626f2c3997b555f347d7a855a15f170b253f6c5bfe358"}, - {file = "rpds_py-0.27.0-cp310-cp310-win32.whl", hash = "sha256:b8a4131698b6992b2a56015f51646711ec5d893a0b314a4b985477868e240c87"}, - {file = "rpds_py-0.27.0-cp310-cp310-win_amd64.whl", hash = "sha256:cbc619e84a5e3ab2d452de831c88bdcad824414e9c2d28cd101f94dbdf26329c"}, - {file = "rpds_py-0.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dbc2ab5d10544eb485baa76c63c501303b716a5c405ff2469a1d8ceffaabf622"}, - {file = "rpds_py-0.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7ec85994f96a58cf7ed288caa344b7fe31fd1d503bdf13d7331ead5f70ab60d5"}, - {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:190d7285cd3bb6d31d37a0534d7359c1ee191eb194c511c301f32a4afa5a1dd4"}, - {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c10d92fb6d7fd827e44055fcd932ad93dac6a11e832d51534d77b97d1d85400f"}, - {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd2c1d27ebfe6a015cfa2005b7fe8c52d5019f7bbdd801bc6f7499aab9ae739e"}, - {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4790c9d5dd565ddb3e9f656092f57268951398cef52e364c405ed3112dc7c7c1"}, - {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4300e15e7d03660f04be84a125d1bdd0e6b2f674bc0723bc0fd0122f1a4585dc"}, - {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:59195dc244fc183209cf8a93406889cadde47dfd2f0a6b137783aa9c56d67c85"}, - {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fae4a01ef8c4cb2bbe92ef2063149596907dc4a881a8d26743b3f6b304713171"}, - {file = "rpds_py-0.27.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e3dc8d4ede2dbae6c0fc2b6c958bf51ce9fd7e9b40c0f5b8835c3fde44f5807d"}, - {file = "rpds_py-0.27.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3782fb753aa825b4ccabc04292e07897e2fd941448eabf666856c5530277626"}, - {file = "rpds_py-0.27.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:887ab1f12b0d227e9260558a4a2320024b20102207ada65c43e1ffc4546df72e"}, - {file = "rpds_py-0.27.0-cp311-cp311-win32.whl", hash = "sha256:5d6790ff400254137b81b8053b34417e2c46921e302d655181d55ea46df58cf7"}, - {file = "rpds_py-0.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:e24d8031a2c62f34853756d9208eeafa6b940a1efcbfe36e8f57d99d52bb7261"}, - {file = "rpds_py-0.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:08680820d23df1df0a0260f714d12966bc6c42d02e8055a91d61e03f0c47dda0"}, - {file = "rpds_py-0.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:19c990fdf5acecbf0623e906ae2e09ce1c58947197f9bced6bbd7482662231c4"}, - {file = "rpds_py-0.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6c27a7054b5224710fcfb1a626ec3ff4f28bcb89b899148c72873b18210e446b"}, - {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09965b314091829b378b60607022048953e25f0b396c2b70e7c4c81bcecf932e"}, - {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:14f028eb47f59e9169bfdf9f7ceafd29dd64902141840633683d0bad5b04ff34"}, - {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6168af0be75bba990a39f9431cdfae5f0ad501f4af32ae62e8856307200517b8"}, - {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab47fe727c13c09d0e6f508e3a49e545008e23bf762a245b020391b621f5b726"}, - {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa01b3d5e3b7d97efab65bd3d88f164e289ec323a8c033c5c38e53ee25c007e"}, - {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:6c135708e987f46053e0a1246a206f53717f9fadfba27174a9769ad4befba5c3"}, - {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc327f4497b7087d06204235199daf208fd01c82d80465dc5efa4ec9df1c5b4e"}, - {file = "rpds_py-0.27.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e57906e38583a2cba67046a09c2637e23297618dc1f3caddbc493f2be97c93f"}, - {file = "rpds_py-0.27.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f4f69d7a4300fbf91efb1fb4916421bd57804c01ab938ab50ac9c4aa2212f03"}, - {file = "rpds_py-0.27.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b4c4fbbcff474e1e5f38be1bf04511c03d492d42eec0babda5d03af3b5589374"}, - {file = "rpds_py-0.27.0-cp312-cp312-win32.whl", hash = "sha256:27bac29bbbf39601b2aab474daf99dbc8e7176ca3389237a23944b17f8913d97"}, - {file = "rpds_py-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a06aa1197ec0281eb1d7daf6073e199eb832fe591ffa329b88bae28f25f5fe5"}, - {file = "rpds_py-0.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:e14aab02258cb776a108107bd15f5b5e4a1bbaa61ef33b36693dfab6f89d54f9"}, - {file = "rpds_py-0.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:443d239d02d9ae55b74015234f2cd8eb09e59fbba30bf60baeb3123ad4c6d5ff"}, - {file = "rpds_py-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8a7acf04fda1f30f1007f3cc96d29d8cf0a53e626e4e1655fdf4eabc082d367"}, - {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0f92b78cfc3b74a42239fdd8c1266f4715b573204c234d2f9fc3fc7a24f185"}, - {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce4ed8e0c7dbc5b19352b9c2c6131dd23b95fa8698b5cdd076307a33626b72dc"}, - {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fde355b02934cc6b07200cc3b27ab0c15870a757d1a72fd401aa92e2ea3c6bfe"}, - {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13bbc4846ae4c993f07c93feb21a24d8ec637573d567a924b1001e81c8ae80f9"}, - {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0744661afbc4099fef7f4e604e7f1ea1be1dd7284f357924af12a705cc7d5c"}, - {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:069e0384a54f427bd65d7fda83b68a90606a3835901aaff42185fcd94f5a9295"}, - {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4bc262ace5a1a7dc3e2eac2fa97b8257ae795389f688b5adf22c5db1e2431c43"}, - {file = "rpds_py-0.27.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fe6e18e5c8581f0361b35ae575043c7029d0a92cb3429e6e596c2cdde251432"}, - {file = "rpds_py-0.27.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d93ebdb82363d2e7bec64eecdc3632b59e84bd270d74fe5be1659f7787052f9b"}, - {file = "rpds_py-0.27.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0954e3a92e1d62e83a54ea7b3fdc9efa5d61acef8488a8a3d31fdafbfb00460d"}, - {file = "rpds_py-0.27.0-cp313-cp313-win32.whl", hash = "sha256:2cff9bdd6c7b906cc562a505c04a57d92e82d37200027e8d362518df427f96cd"}, - {file = "rpds_py-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc79d192fb76fc0c84f2c58672c17bbbc383fd26c3cdc29daae16ce3d927e8b2"}, - {file = "rpds_py-0.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b3a5c8089eed498a3af23ce87a80805ff98f6ef8f7bdb70bd1b7dae5105f6ac"}, - {file = "rpds_py-0.27.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:90fb790138c1a89a2e58c9282fe1089638401f2f3b8dddd758499041bc6e0774"}, - {file = "rpds_py-0.27.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010c4843a3b92b54373e3d2291a7447d6c3fc29f591772cc2ea0e9f5c1da434b"}, - {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9ce7a9e967afc0a2af7caa0d15a3e9c1054815f73d6a8cb9225b61921b419bd"}, - {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa0bf113d15e8abdfee92aa4db86761b709a09954083afcb5bf0f952d6065fdb"}, - {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb91d252b35004a84670dfeafadb042528b19842a0080d8b53e5ec1128e8f433"}, - {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db8a6313dbac934193fc17fe7610f70cd8181c542a91382531bef5ed785e5615"}, - {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce96ab0bdfcef1b8c371ada2100767ace6804ea35aacce0aef3aeb4f3f499ca8"}, - {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:7451ede3560086abe1aa27dcdcf55cd15c96b56f543fb12e5826eee6f721f858"}, - {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:32196b5a99821476537b3f7732432d64d93a58d680a52c5e12a190ee0135d8b5"}, - {file = "rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a029be818059870664157194e46ce0e995082ac49926f1423c1f058534d2aaa9"}, - {file = "rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3841f66c1ffdc6cebce8aed64e36db71466f1dc23c0d9a5592e2a782a3042c79"}, - {file = "rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:42894616da0fc0dcb2ec08a77896c3f56e9cb2f4b66acd76fc8992c3557ceb1c"}, - {file = "rpds_py-0.27.0-cp313-cp313t-win32.whl", hash = "sha256:b1fef1f13c842a39a03409e30ca0bf87b39a1e2a305a9924deadb75a43105d23"}, - {file = "rpds_py-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:183f5e221ba3e283cd36fdfbe311d95cd87699a083330b4f792543987167eff1"}, - {file = "rpds_py-0.27.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f3cd110e02c5bf17d8fb562f6c9df5c20e73029d587cf8602a2da6c5ef1e32cb"}, - {file = "rpds_py-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d0e09cf4863c74106b5265c2c310f36146e2b445ff7b3018a56799f28f39f6f"}, - {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f689ab822f9b5eb6dfc69893b4b9366db1d2420f7db1f6a2adf2a9ca15ad64"}, - {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e36c80c49853b3ffda7aa1831bf175c13356b210c73128c861f3aa93c3cc4015"}, - {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6de6a7f622860af0146cb9ee148682ff4d0cea0b8fd3ad51ce4d40efb2f061d0"}, - {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4045e2fc4b37ec4b48e8907a5819bdd3380708c139d7cc358f03a3653abedb89"}, - {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da162b718b12c4219eeeeb68a5b7552fbc7aadedf2efee440f88b9c0e54b45d"}, - {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:0665be515767dc727ffa5f74bd2ef60b0ff85dad6bb8f50d91eaa6b5fb226f51"}, - {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203f581accef67300a942e49a37d74c12ceeef4514874c7cede21b012613ca2c"}, - {file = "rpds_py-0.27.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7873b65686a6471c0037139aa000d23fe94628e0daaa27b6e40607c90e3f5ec4"}, - {file = "rpds_py-0.27.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:249ab91ceaa6b41abc5f19513cb95b45c6f956f6b89f1fe3d99c81255a849f9e"}, - {file = "rpds_py-0.27.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2f184336bc1d6abfaaa1262ed42739c3789b1e3a65a29916a615307d22ffd2e"}, - {file = "rpds_py-0.27.0-cp314-cp314-win32.whl", hash = "sha256:d3c622c39f04d5751408f5b801ecb527e6e0a471b367f420a877f7a660d583f6"}, - {file = "rpds_py-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf824aceaeffff029ccfba0da637d432ca71ab21f13e7f6f5179cd88ebc77a8a"}, - {file = "rpds_py-0.27.0-cp314-cp314-win_arm64.whl", hash = "sha256:86aca1616922b40d8ac1b3073a1ead4255a2f13405e5700c01f7c8d29a03972d"}, - {file = "rpds_py-0.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:341d8acb6724c0c17bdf714319c393bb27f6d23d39bc74f94221b3e59fc31828"}, - {file = "rpds_py-0.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b96b0b784fe5fd03beffff2b1533dc0d85e92bab8d1b2c24ef3a5dc8fac5669"}, - {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c431bfb91478d7cbe368d0a699978050d3b112d7f1d440a41e90faa325557fd"}, - {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e222a44ae9f507d0f2678ee3dd0c45ec1e930f6875d99b8459631c24058aec"}, - {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:184f0d7b342967f6cda94a07d0e1fae177d11d0b8f17d73e06e36ac02889f303"}, - {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a00c91104c173c9043bc46f7b30ee5e6d2f6b1149f11f545580f5d6fdff42c0b"}, - {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a37dd208f0d658e0487522078b1ed68cd6bce20ef4b5a915d2809b9094b410"}, - {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92f3b3ec3e6008a1fe00b7c0946a170f161ac00645cde35e3c9a68c2475e8156"}, - {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b3db5fae5cbce2131b7420a3f83553d4d89514c03d67804ced36161fe8b6b2"}, - {file = "rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5355527adaa713ab693cbce7c1e0ec71682f599f61b128cf19d07e5c13c9b1f1"}, - {file = "rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fcc01c57ce6e70b728af02b2401c5bc853a9e14eb07deda30624374f0aebfe42"}, - {file = "rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3001013dae10f806380ba739d40dee11db1ecb91684febb8406a87c2ded23dae"}, - {file = "rpds_py-0.27.0-cp314-cp314t-win32.whl", hash = "sha256:0f401c369186a5743694dd9fc08cba66cf70908757552e1f714bfc5219c655b5"}, - {file = "rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391"}, - {file = "rpds_py-0.27.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e0d7151a1bd5d0a203a5008fc4ae51a159a610cb82ab0a9b2c4d80241745582e"}, - {file = "rpds_py-0.27.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42ccc57ff99166a55a59d8c7d14f1a357b7749f9ed3584df74053fd098243451"}, - {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e377e4cf8795cdbdff75b8f0223d7b6c68ff4fef36799d88ccf3a995a91c0112"}, - {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79af163a4b40bbd8cfd7ca86ec8b54b81121d3b213b4435ea27d6568bcba3e9d"}, - {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2eff8ee57c5996b0d2a07c3601fb4ce5fbc37547344a26945dd9e5cbd1ed27a"}, - {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7cf9bc4508efb18d8dff6934b602324eb9f8c6644749627ce001d6f38a490889"}, - {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05284439ebe7d9f5f5a668d4d8a0a1d851d16f7d47c78e1fab968c8ad30cab04"}, - {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:1321bce595ad70e80f97f998db37356b2e22cf98094eba6fe91782e626da2f71"}, - {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:737005088449ddd3b3df5a95476ee1c2c5c669f5c30eed909548a92939c0e12d"}, - {file = "rpds_py-0.27.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9b2a4e17bfd68536c3b801800941c95a1d4a06e3cada11c146093ba939d9638d"}, - {file = "rpds_py-0.27.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dc6b0d5a1ea0318ef2def2b6a55dccf1dcaf77d605672347271ed7b829860765"}, - {file = "rpds_py-0.27.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4c3f8a0d4802df34fcdbeb3dfe3a4d8c9a530baea8fafdf80816fcaac5379d83"}, - {file = "rpds_py-0.27.0-cp39-cp39-win32.whl", hash = "sha256:699c346abc73993962cac7bb4f02f58e438840fa5458a048d3a178a7a670ba86"}, - {file = "rpds_py-0.27.0-cp39-cp39-win_amd64.whl", hash = "sha256:be806e2961cd390a89d6c3ce8c2ae34271cfcd05660f716257838bb560f1c3b6"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:46f48482c1a4748ab2773f75fffbdd1951eb59794e32788834b945da857c47a8"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:419dd9c98bcc9fb0242be89e0c6e922df333b975d4268faa90d58499fd9c9ebe"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d42a0ef2bdf6bc81e1cc2d49d12460f63c6ae1423c4f4851b828e454ccf6f1"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e39169ac6aae06dd79c07c8a69d9da867cef6a6d7883a0186b46bb46ccfb0c3"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:935afcdea4751b0ac918047a2df3f720212892347767aea28f5b3bf7be4f27c0"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de567dec6d451649a781633d36f5c7501711adee329d76c095be2178855b042"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:555ed147cbe8c8f76e72a4c6cd3b7b761cbf9987891b9448808148204aed74a5"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:d2cc2b34f9e1d31ce255174da82902ad75bd7c0d88a33df54a77a22f2ef421ee"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb0702c12983be3b2fab98ead349ac63a98216d28dda6f518f52da5498a27a1b"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ba783541be46f27c8faea5a6645e193943c17ea2f0ffe593639d906a327a9bcc"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:2406d034635d1497c596c40c85f86ecf2bf9611c1df73d14078af8444fe48031"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dea0808153f1fbbad772669d906cddd92100277533a03845de6893cadeffc8be"}, - {file = "rpds_py-0.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d2a81bdcfde4245468f7030a75a37d50400ac2455c3a4819d9d550c937f90ab5"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e6491658dd2569f05860bad645569145c8626ac231877b0fb2d5f9bcb7054089"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:bec77545d188f8bdd29d42bccb9191682a46fb2e655e3d1fb446d47c55ac3b8d"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a4aebf8ca02bbb90a9b3e7a463bbf3bee02ab1c446840ca07b1695a68ce424"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44524b96481a4c9b8e6c46d6afe43fa1fb485c261e359fbe32b63ff60e3884d8"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45d04a73c54b6a5fd2bab91a4b5bc8b426949586e61340e212a8484919183859"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:343cf24de9ed6c728abefc5d5c851d5de06497caa7ac37e5e65dd572921ed1b5"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aed8118ae20515974650d08eb724150dc2e20c2814bcc307089569995e88a14"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:af9d4fd79ee1cc8e7caf693ee02737daabfc0fcf2773ca0a4735b356c8ad6f7c"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f0396e894bd1e66c74ecbc08b4f6a03dc331140942c4b1d345dd131b68574a60"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:59714ab0a5af25d723d8e9816638faf7f4254234decb7d212715c1aa71eee7be"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:88051c3b7d5325409f433c5a40328fcb0685fc04e5db49ff936e910901d10114"}, - {file = "rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:181bc29e59e5e5e6e9d63b143ff4d5191224d355e246b5a48c88ce6b35c4e466"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9ad08547995a57e74fea6abaf5940d399447935faebbd2612b3b0ca6f987946b"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:61490d57e82e23b45c66f96184237994bfafa914433b8cd1a9bb57fecfced59d"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7cf5e726b6fa977e428a61880fb108a62f28b6d0c7ef675b117eaff7076df49"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc662bc9375a6a394b62dfd331874c434819f10ee3902123200dbcf116963f89"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:299a245537e697f28a7511d01038c310ac74e8ea213c0019e1fc65f52c0dcb23"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be3964f7312ea05ed283b20f87cb533fdc555b2e428cc7be64612c0b2124f08c"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33ba649a6e55ae3808e4c39e01580dc9a9b0d5b02e77b66bb86ef117922b1264"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:81f81bbd7cdb4bdc418c09a73809abeda8f263a6bf8f9c7f93ed98b5597af39d"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11e8e28c0ba0373d052818b600474cfee2fafa6c9f36c8587d217b13ee28ca7d"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:e3acb9c16530362aeaef4e84d57db357002dc5cbfac9a23414c3e73c08301ab2"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:2e307cb5f66c59ede95c00e93cd84190a5b7f3533d7953690b2036780622ba81"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f09c9d4c26fa79c1bad927efb05aca2391350b8e61c38cbc0d7d3c814e463124"}, - {file = "rpds_py-0.27.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:af22763a0a1eff106426a6e1f13c4582e0d0ad89c1493ab6c058236174cd6c6a"}, - {file = "rpds_py-0.27.0.tar.gz", hash = "sha256:8b23cf252f180cda89220b378d917180f29d313cd6a07b2431c0d3b776aae86f"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] [[package]] @@ -5812,79 +8111,21 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruamel-yaml" -version = "0.18.15" +version = "0.19.1" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701"}, - {file = "ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700"}, + {file = "ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93"}, + {file = "ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993"}, ] -[package.dependencies] -"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} - [package.extras] docs = ["mercurial (>5.7)", "ryd"] jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.12" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "platform_python_implementation == \"CPython\"" -files = [ - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, - {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, -] +libyaml = ["ruamel.yaml.clibz (>=0.3.7) ; platform_python_implementation == \"CPython\""] +oldlibyaml = ["ruamel.yaml.clib ; platform_python_implementation == \"CPython\""] [[package]] name = "ruff" @@ -5916,14 +8157,14 @@ files = [ [[package]] name = "s3transfer" -version = "0.13.1" +version = "0.14.0" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"}, - {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"}, + {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, + {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, ] [package.dependencies] @@ -5934,34 +8175,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)"] @@ -5970,23 +8211,55 @@ spdx = ["spdx-tools (>=0.8.2)"] [[package]] name = "safety-schemas" -version = "0.0.5" +version = "0.0.16" description = "Schemas for Safety tools" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "safety_schemas-0.0.5-py3-none-any.whl", hash = "sha256:6ac9eb71e60f0d4e944597c01dd48d6d8cd3d467c94da4aba3702a05a3a6ab4f"}, - {file = "safety_schemas-0.0.5.tar.gz", hash = "sha256:0de5fc9a53d4423644a8ce9a17a2e474714aa27e57f3506146e95a41710ff104"}, + {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"}, + {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"}, ] [package.dependencies] -dparse = ">=0.6.4b0" +dparse = ">=0.6.4" packaging = ">=21.0" -pydantic = "*" +pydantic = ">=2.6.0" ruamel-yaml = ">=0.17.21" typing-extensions = ">=4.7.1" +[[package]] +name = "scaleway" +version = "2.10.3" +description = "Scaleway SDK for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "scaleway-2.10.3-py3-none-any.whl", hash = "sha256:dbf381440d6caf37c878cf16445a63f4969a4aac2257c9b72c744d10ff223a0c"}, + {file = "scaleway-2.10.3.tar.gz", hash = "sha256:b1f9dd1b1450767205234c6f5a345e5e25dc039c780253d698893b5c344ce594"}, +] + +[package.dependencies] +scaleway-core = "2.10.3" + +[[package]] +name = "scaleway-core" +version = "2.10.3" +description = "Scaleway SDK for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "scaleway_core-2.10.3-py3-none-any.whl", hash = "sha256:fd4112144554d6adae22ff737555eeb0e38cb1063250b3e88c9aebc1b957793b"}, + {file = "scaleway_core-2.10.3.tar.gz", hash = "sha256:56432f755d694669429de51d51c1d0b3361b28dc2f939b28e4cb954610ee76be"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.2,<3.0.0" +PyYAML = ">=6.0,<7.0" +requests = ">=2.28.1,<3.0.0" + [[package]] name = "schema" version = "0.7.5" @@ -6004,14 +8277,14 @@ contextlib2 = ">=0.5.5" [[package]] name = "sentry-sdk" -version = "2.35.0" +version = "2.51.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.35.0-py2.py3-none-any.whl", hash = "sha256:6e0c29b9a5d34de8575ffb04d289a987ff3053cf2c98ede445bea995e3830263"}, - {file = "sentry_sdk-2.35.0.tar.gz", hash = "sha256:5ea58d352779ce45d17bc2fa71ec7185205295b83a9dbb5707273deb64720092"}, + {file = "sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f"}, + {file = "sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7"}, ] [package.dependencies] @@ -6034,20 +8307,26 @@ django = ["django (>=1.8)"] falcon = ["falcon (>=1.4)"] fastapi = ["fastapi (>=0.79.0)"] flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +google-genai = ["google-genai (>=1.29.0)"] grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] http2 = ["httpcore[http2] (==1.*)"] httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] huggingface-hub = ["huggingface_hub (>=0.22)"] langchain = ["langchain (>=0.0.210)"] +langgraph = ["langgraph (>=0.6.6)"] launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] +litellm = ["litellm (>=1.77.5)"] litestar = ["litestar (>=2.0.0)"] loguru = ["loguru (>=0.5)"] +mcp = ["mcp (>=1.15.0)"] openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] openfeature = ["openfeature-sdk (>=0.7.1)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] opentelemetry-experimental = ["opentelemetry-distro"] +opentelemetry-otlp = ["opentelemetry-distro[otlp] (>=0.35b0)"] pure-eval = ["asttokens", "executing", "pure_eval"] +pydantic-ai = ["pydantic-ai (>=1.0.0)"] pymongo = ["pymongo (>=3.1)"] pyspark = ["pyspark (>=2.4.4)"] quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] @@ -6062,14 +8341,14 @@ unleash = ["UnleashClient (>=6.0.1)"] [[package]] name = "setuptools" -version = "80.9.0" +version = "80.10.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, + {file = "setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173"}, + {file = "setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70"}, ] [package.extras] @@ -6087,7 +8366,7 @@ version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -6126,18 +8405,18 @@ files = [ [[package]] name = "slack-sdk" -version = "3.34.0" +version = "3.39.0" description = "The Slack API Platform SDK for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa"}, - {file = "slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d"}, + {file = "slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8"}, + {file = "slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1"}, ] [package.extras] -optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<15)"] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"] [[package]] name = "sniffio" @@ -6153,47 +8432,56 @@ files = [ [[package]] name = "sqlparse" -version = "0.5.3" +version = "0.5.5" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, - {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, + {file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"}, + {file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"}, ] [package.extras] -dev = ["build", "hatch"] +dev = ["build"] doc = ["sphinx"] +[[package]] +name = "statsd" +version = "4.0.1" +description = "A simple statsd client." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "statsd-4.0.1-py2.py3-none-any.whl", hash = "sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093"}, + {file = "statsd-4.0.1.tar.gz", hash = "sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128"}, +] + [[package]] name = "std-uritemplate" -version = "2.0.5" +version = "2.0.8" description = "std-uritemplate implementation for Python" optional = false python-versions = "<4.0,>=3.8" groups = ["main"] files = [ - {file = "std_uritemplate-2.0.5-py3-none-any.whl", hash = "sha256:0f5184f8e6f315a01f92cfbed335f62f087e453e79cd586b67a724211e686c28"}, - {file = "std_uritemplate-2.0.5.tar.gz", hash = "sha256:7703a886cce59d155c21b5acf1ad8d48db9f3322de98fa783a8396fbf35cbc06"}, + {file = "std_uritemplate-2.0.8-py3-none-any.whl", hash = "sha256:839807a7f9d07f0bad1a88977c3428bd97b9ff0d229412a0bf36123d8c724257"}, + {file = "std_uritemplate-2.0.8.tar.gz", hash = "sha256:138ceff2c5bfef18a650372a5e8c82fe7f780c87235513de6c342fb5f7e18347"}, ] [[package]] name = "stevedore" -version = "5.4.1" +version = "5.6.0" description = "Manage dynamic plugins for Python applications" optional = false -python-versions = ">=3.9" -groups = ["dev"] +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"}, - {file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"}, + {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"}, + {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, ] -[package.dependencies] -pbr = ">=2.0.0" - [[package]] name = "tabulate" version = "0.9.0" @@ -6215,7 +8503,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"}, @@ -6227,14 +8515,14 @@ test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "tldextract" -version = "5.3.0" +version = "5.3.1" description = "Accurately separates a URL's subdomain, domain, and public suffix, using the Public Suffix List (PSL). By default, this includes the public ICANN TLDs and their exceptions. You can optionally support the Public Suffix List's private domains as well." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "tldextract-5.3.0-py3-none-any.whl", hash = "sha256:f70f31d10b55c83993f55e91ecb7c5d84532a8972f22ec578ecfbe5ea2292db2"}, - {file = "tldextract-5.3.0.tar.gz", hash = "sha256:b3d2b70a1594a0ecfa6967d57251527d58e00bb5a91a74387baa0d87a0678609"}, + {file = "tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9"}, + {file = "tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb"}, ] [package.dependencies] @@ -6249,14 +8537,14 @@ testing = ["mypy", "pytest", "pytest-gitignore", "pytest-mock", "responses", "ru [[package]] name = "tomlkit" -version = "0.13.3" +version = "0.14.0" description = "Style preserving TOML library" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, - {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, + {file = "tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680"}, + {file = "tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"}, ] [[package]] @@ -6283,14 +8571,14 @@ telegram = ["requests"] [[package]] name = "typer" -version = "0.16.1" +version = "0.21.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false -python-versions = ">=3.7" -groups = ["dev"] +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "typer-0.16.1-py3-none-any.whl", hash = "sha256:90ee01cb02d9b8395ae21ee3368421faf21fa138cb2a541ed369c08cec5237c9"}, - {file = "typer-0.16.1.tar.gz", hash = "sha256:d358c65a464a7a90f338e3bb7ff0c74ac081449e53884b12ba658cbd72990614"}, + {file = "typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01"}, + {file = "typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d"}, ] [package.dependencies] @@ -6299,28 +8587,43 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" +[[package]] +name = "types-aiobotocore-ecr" +version = "3.1.1" +description = "Type annotations for aiobotocore ECR 3.1.1 service generated with mypy-boto3-builder 8.12.0" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "types_aiobotocore_ecr-3.1.1-py3-none-any.whl", hash = "sha256:e5c02e06ff057bbe7821fb40ac7de67d2335fdc7987ea31392051efe81ceb69c"}, + {file = "types_aiobotocore_ecr-3.1.1.tar.gz", hash = "sha256:155edc63c612e1a7861fa746376a5143cc4f3ca05b60c27d68ced23e8567a344"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.12\""} + [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" 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"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] @@ -6328,14 +8631,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2025.2" +version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main", "dev"] files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, ] markers = {dev = "sys_platform == \"win32\""} @@ -6371,21 +8674,21 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uuid6" @@ -6425,47 +8728,47 @@ files = [ [[package]] name = "wcwidth" -version = "0.2.13" +version = "0.5.3" description = "Measures the displayed width of unicode strings in a terminal" optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, - {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, -] - -[[package]] -name = "websocket-client" -version = "1.8.0" -description = "WebSocket client for Python with low level API options" -optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, - {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, + {file = "wcwidth-0.5.3-py3-none-any.whl", hash = "sha256:d584eff31cd4753e1e5ff6c12e1edfdb324c995713f75d26c29807bb84bf649e"}, + {file = "wcwidth-0.5.3.tar.gz", hash = "sha256:53123b7af053c74e9fe2e92ac810301f6139e64379031f7124574212fb3b4091"}, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, + {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, ] [package.extras] -docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] optional = ["python-socks", "wsaccel"] -test = ["websockets"] +test = ["pytest", "websockets"] [[package]] name = "werkzeug" -version = "3.1.3" +version = "3.1.5" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, - {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, + {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, + {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, ] [package.dependencies] -MarkupSafe = ">=2.1.1" +markupsafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] @@ -6563,14 +8866,14 @@ files = [ [[package]] name = "xlsxwriter" -version = "3.2.5" +version = "3.2.9" description = "A Python module for creating Excel XLSX files." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "xlsxwriter-3.2.5-py3-none-any.whl", hash = "sha256:4f4824234e1eaf9d95df9a8fe974585ff91d0f5e3d3f12ace5b71e443c1c6abd"}, - {file = "xlsxwriter-3.2.5.tar.gz", hash = "sha256:7e88469d607cdc920151c0ab3ce9cf1a83992d4b7bc730c5ffdd1a12115a7dbe"}, + {file = "xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3"}, + {file = "xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c"}, ] [[package]] @@ -6644,118 +8947,159 @@ files = [ [package.dependencies] lxml = ">=3.8" +[[package]] +name = "xmltodict" +version = "1.0.2" +description = "Makes working with XML feel like you are working with JSON" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d"}, + {file = "xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649"}, +] + +[package.extras] +test = ["pytest", "pytest-cov"] + [[package]] name = "yarl" -version = "1.20.1" +version = "1.22.0" description = "Yet another URL library" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13"}, - {file = "yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8"}, - {file = "yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e"}, - {file = "yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773"}, - {file = "yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004"}, - {file = "yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5"}, - {file = "yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1"}, - {file = "yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7"}, - {file = "yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e"}, - {file = "yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d"}, - {file = "yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b"}, - {file = "yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000"}, - {file = "yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8"}, - {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d"}, - {file = "yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06"}, - {file = "yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00"}, - {file = "yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77"}, - {file = "yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, + {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, + {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, + {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, + {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, + {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, + {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, + {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, + {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, + {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, + {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, + {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, + {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, + {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, + {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, + {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, + {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, + {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, + {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, + {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, + {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, + {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8"}, + {file = "yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b"}, + {file = "yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed"}, + {file = "yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2"}, + {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, + {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, ] [package.dependencies] @@ -6783,7 +9127,243 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] +[[package]] +name = "zope-event" +version = "6.1" +description = "Very basic event publishing system" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0"}, + {file = "zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0"}, +] + +[package.extras] +docs = ["Sphinx"] +test = ["zope.testrunner (>=6.4)"] + +[[package]] +name = "zope-interface" +version = "8.2" +description = "Interfaces for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "zope_interface-8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:788c293f3165964ec6527b2d861072c68eef53425213f36d3893ebee89a89623"}, + {file = "zope_interface-8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9a4e785097e741a1c953b3970ce28f2823bd63c00adc5d276f2981dd66c96c15"}, + {file = "zope_interface-8.2-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:16c69da19a06566664ddd4785f37cad5693a51d48df1515d264c20d005d322e2"}, + {file = "zope_interface-8.2-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c31acfa3d7cde48bec45701b0e1f4698daffc378f559bfb296837d8c834732f6"}, + {file = "zope_interface-8.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0723507127f8269b8f3f22663168f717e9c9742107d1b6c9f419df561b71aa6d"}, + {file = "zope_interface-8.2-cp310-cp310-win_amd64.whl", hash = "sha256:3bf73a910bb27344def2d301a03329c559a79b308e1e584686b74171d736be4e"}, + {file = "zope_interface-8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c65ade7ea85516e428651048489f5e689e695c79188761de8c622594d1e13322"}, + {file = "zope_interface-8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef4b43659e1348f35f38e7d1a6bbc1682efde239761f335ffc7e31e798b65b"}, + {file = "zope_interface-8.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dfc4f44e8de2ff4eba20af4f0a3ca42d3c43ab24a08e49ccd8558b7a4185b466"}, + {file = "zope_interface-8.2-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8f094bfb49179ec5dc9981cb769af1275702bd64720ef94874d9e34da1390d4c"}, + {file = "zope_interface-8.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d2bb8e7364e18f083bf6744ccf30433b2a5f236c39c95df8514e3c13007098ce"}, + {file = "zope_interface-8.2-cp311-cp311-win_amd64.whl", hash = "sha256:6f4b4dfcfdfaa9177a600bb31cebf711fdb8c8e9ed84f14c61c420c6aa398489"}, + {file = "zope_interface-8.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:624b6787fc7c3e45fa401984f6add2c736b70a7506518c3b537ffaacc4b29d4c"}, + {file = "zope_interface-8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc9ded9e97a0ed17731d479596ed1071e53b18e6fdb2fc33af1e43f5fd2d3aaa"}, + {file = "zope_interface-8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:532367553e4420c80c0fc0cabcc2c74080d495573706f66723edee6eae53361d"}, + {file = "zope_interface-8.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2bf9cf275468bafa3c72688aad8cfcbe3d28ee792baf0b228a1b2d93bd1d541a"}, + {file = "zope_interface-8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0009d2d3c02ea783045d7804da4fd016245e5c5de31a86cebba66dd6914d59a2"}, + {file = "zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640"}, + {file = "zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec"}, + {file = "zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c"}, + {file = "zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664"}, + {file = "zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0"}, + {file = "zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb"}, + {file = "zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028"}, + {file = "zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb"}, + {file = "zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf"}, + {file = "zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080"}, + {file = "zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c"}, + {file = "zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c"}, + {file = "zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48"}, + {file = "zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224"}, +] + +[package.extras] +docs = ["Sphinx", "furo", "repoze.sphinx.autointerface"] +test = ["coverage[toml]", "zope.event", "zope.testing"] +testing = ["coverage[toml]", "zope.event", "zope.testing"] + +[[package]] +name = "zstd" +version = "1.5.7.3" +description = "ZSTD Bindings for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "zstd-1.5.7.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e72b353870286648a63261437b75f297e2967a26f210da4dfa4c08949935de7a"}, + {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:26aff5f24caeffde35f1b757499e935bc60a8e0d9e1ea8bde05dcf7d53df9325"}, + {file = "zstd-1.5.7.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:586a820fbd06e3d9a9d9def572e779254bf8dee7406b8c6dc44eff6807d60c6d"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:35a147b10fd16ebb3a2595e361780388feb8f336d70772a05dfb7a8348a47bfd"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:c2a80c51e2175ffcd6f08b2a4c9fbc121aad69fbbcebb3364e783a96d0488fda"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_i686.whl", hash = "sha256:5f20f74a782f3296d1585d9bbc49d422e339b154c66398c74537e433446c51ba"}, + {file = "zstd-1.5.7.3-cp27-cp27mu-manylinux_2_4_x86_64.whl", hash = "sha256:2550c2e6bfbff0904f28821005f176bfdaec1872d60053665a284fb0254a10e7"}, + {file = "zstd-1.5.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76f3535616887a1a38e8c6d0de693a23c5bb1f190651eb20d96bfc8e4ab706a0"}, + {file = "zstd-1.5.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67507937e8e4c2a8dfed8e7fa77f4043ec9e6e831a5faebf0f99138b1a25ccbd"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd0a2309c524608ce7b940abcc9f8eb5447c6ea2c834a630e0081211ab9d40ec"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2b497306580d544406b5414c8485c4037a9283ad2ca6ae4ccdf3732c9563141d"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:e9939a98ea946d1f9e8f9fecc940ae939b8e9e5ef9d71b104f7843567d764f30"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:d32c0fe8f6b805b7cbeaade462b094a843e84d893d8c6f66ab705e8777cc1850"}, + {file = "zstd-1.5.7.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8aa33b1ef24602b2ef1e8aa67ea3c8f821854a4dbf70c3c8c46b96b54b6ceb5d"}, + {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1bd69fa9c4c97fd04206c919dedbf9f75f544ebb77880db51a13c1e3802cd655"}, + {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aee96742a64ede2e35dc0316ef0cd1e50089e889ce77e82ca8edf40174a1439c"}, + {file = "zstd-1.5.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ac207573d2815a51f4f4fd4e255408396491729a01f690b9f5fb672d39e5610"}, + {file = "zstd-1.5.7.3-cp310-cp310-win32.whl", hash = "sha256:04e62e4f9eba79699d072d3c96731ed4aff99f1d334eb967489b091186a6078f"}, + {file = "zstd-1.5.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:0794b23b9950af240888087d2bd5943aa4be67273ba32cdafabdc5704778b90e"}, + {file = "zstd-1.5.7.3-cp310-cp310-win_arm64.whl", hash = "sha256:7827fd4901f3e71a7a755d26719549658f08e04fdf0870a952ed08e71b484435"}, + {file = "zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a"}, + {file = "zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7"}, + {file = "zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5"}, + {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb"}, + {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc"}, + {file = "zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e"}, + {file = "zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d"}, + {file = "zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3"}, + {file = "zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527"}, + {file = "zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203"}, + {file = "zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699"}, + {file = "zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8"}, + {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41"}, + {file = "zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328"}, + {file = "zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3"}, + {file = "zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c"}, + {file = "zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f"}, + {file = "zstd-1.5.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2455e56f1d265dacbd450510b8c2f632a5d8d92c23282e7723fb04af37001a2"}, + {file = "zstd-1.5.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3486dc4f1b4e52bb059f8eec1f31daa3e540062c0f522f221782cf132a8bc9a8"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_i686.whl", hash = "sha256:1cb47bf10ffcb6a782edacfe758da2c94879f7e89c6628feb3f1254daf8cc596"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:07b1378d1230ddeea8773f99d7518a3060e6468c76edd502057cb795fe278d7e"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ee34317f013e3405108f5baea53502159809cfc4510598d614257525500c70d"}, + {file = "zstd-1.5.7.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c19127ca2c79855376a34a2d7a6969408094b25c1f44485b0373eba4be851b98"}, + {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e79cae70dd08cb247391312463085c624c0302e8c860d13f87f4c76502d8202"}, + {file = "zstd-1.5.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e83e91e5daf89037c737f5529da0f80da80a78a6ad0b1d70a09860eb267dea4"}, + {file = "zstd-1.5.7.3-cp313-cp313-win32.whl", hash = "sha256:2283f3bb910c028e1b9fe76b834016012ab021025a0ea197e27a1333f85e3031"}, + {file = "zstd-1.5.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ad5fe4c36bab5dfa5a4b8d050bd07c50c1e69f94d381bc65337ab14cd69e5b1"}, + {file = "zstd-1.5.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e878172b0eb69ac2edc6576eb862e00747c7c25e638fb354630a1ea7cfddf49"}, + {file = "zstd-1.5.7.3-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:7e0a7e94d5b63b4cacf2396079ca9584d11f49f87cb4e5aa21f126a8f6b83446"}, + {file = "zstd-1.5.7.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5412c86c34cbaf6906433ef3f2c96c407f208782f06cd3e5f01f066788adb3b8"}, + {file = "zstd-1.5.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f94246befb1e473211a298c96e5768f3c63eaad814ac14d160d79ae9858e1d03"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31050e17a1a546fb82c90eee8ee3c30d22b9d0594b5937e69d38b7a5084af2a2"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ba8ec5dfd48c86d19f880713246f85d09ee06e8cd17141956258650878000d6"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_i686.whl", hash = "sha256:3005540ba406157f3e205c998709ab5f8e68b390c658c7c238eb8986092089d5"}, + {file = "zstd-1.5.7.3-cp314-cp314-manylinux_2_14_x86_64.whl", hash = "sha256:3934b54a3b7df039fcd4cf7b0f0a38c86ce44d26321255ffc3fac73d6cdcc59d"}, + {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e9230cd3e9153e2bed16f332558f8f3f7d869f4d15e8fa3f9c360bfa163a8b4a"}, + {file = "zstd-1.5.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bffba70af539f14f9df5367b1add9119f14d5e35b658aef7b765417ea461e0e"}, + {file = "zstd-1.5.7.3-cp314-cp314-win32.whl", hash = "sha256:a006e70c88ab67bb56989e11d820adc7601a6a7ad5558b3c6c690b19a1dadc5b"}, + {file = "zstd-1.5.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:cb4957c330c7b94b0546c7b9529723b49e865608683b9503a251fe793da9d4db"}, + {file = "zstd-1.5.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:a785426081ab7cafe4522876ac771d701766deea9a6d8352e87744da00e6637f"}, + {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_i686.whl", hash = "sha256:b52ef154793be0399befd742328ec6f5dff95154248d6d18dd65851cf22a1a5f"}, + {file = "zstd-1.5.7.3-cp314-cp314t-manylinux_2_14_x86_64.whl", hash = "sha256:8024a8ba9156b1b2e64e69d147df5ddedeaed107f9da02a3428fd7baf3e5b920"}, + {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_i686.whl", hash = "sha256:31ac7fbacca4759aad4b6abc13bbc05e68788e9e85a968255f7624b3b8db31df"}, + {file = "zstd-1.5.7.3-cp315-cp315-manylinux_2_14_x86_64.whl", hash = "sha256:d03b2927c5843ded4d1319836a33a9c21675d2f86f916a2f234a060d4c67d87c"}, + {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_i686.whl", hash = "sha256:5dfbf2564eb574fc1f45613ecf28036a82533c3dd70e7bb1c9854168c638da7a"}, + {file = "zstd-1.5.7.3-cp315-cp315t-manylinux_2_14_x86_64.whl", hash = "sha256:7f2f5776b902f41daf7b63e75a9384b0d7c855f824f14dabefc67814b8fa5611"}, + {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_i686.whl", hash = "sha256:ffbeabcabcb644d29289277f9023aa51c04de71935695f5388da9c8428c81e0f"}, + {file = "zstd-1.5.7.3-cp34-cp34m-manylinux_2_4_x86_64.whl", hash = "sha256:0b891ca9ad84562941367ab7be817b8748df75eb6b7ced23d5b082b4602c1c6e"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:925f83e2e749cd7109985bc96835cd2fd814435d74f0d9a1d7c8506166e97592"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:57d2ff6b96886aaec2aa4721f7c8e890a8b43b5c4ae4f3737a0733b55cd82daa"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:8cd516ba02e0f9e6df1b4a6dc0cd5e66ac6eeb55b15833a70d529aa32eddaa91"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_14_x86_64.whl", hash = "sha256:9f6ea980866f43ff7ef5e41eac54b94f9159b9807f32f691b02ca381b50b76af"}, + {file = "zstd-1.5.7.3-cp35-cp35m-manylinux_2_4_i686.whl", hash = "sha256:3e650ed68b655d55556099aa62f168a352396139a879a94312322a1d02502491"}, + {file = "zstd-1.5.7.3-cp35-cp35m-win32.whl", hash = "sha256:da88b288a2844f04713df89a514dd9dc0e925ee63e119c845aef14ccbcc9183e"}, + {file = "zstd-1.5.7.3-cp35-cp35m-win_amd64.whl", hash = "sha256:96c949e8508f2d4dced3444a3bfb99d51653ac6f28ef0aa1561f5758adc8afed"}, + {file = "zstd-1.5.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7509b11b5f8313e87cce16269e222f89e7e49b51f1e6a3e7454b7c7b599d3211"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fb8aafd47ba73ff50a7994668dbec5c97f26ddcd28c03242d8f8b4138d8c723c"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:586efc62d7e93d52d0b3951ef48a4b5181866152061bda1bef49f7ea85ec0d7f"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5030d51631a09a0d7b3e47f928b6234bd78ce8b897a255fc1146e8cf772a8f4d"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_14_x86_64.whl", hash = "sha256:a8d1ee9faa89b21ff03ae3fe8d969e850c60b8c3f8a1389fa585c10eddaa2bb4"}, + {file = "zstd-1.5.7.3-cp36-cp36m-manylinux_2_4_i686.whl", hash = "sha256:4504ba7a9ddd1919e919f81d3ec541313e6826f1f3cad8e3a7ebe29a3ae5cda6"}, + {file = "zstd-1.5.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:aca7d1fef13f412168ac524307586f0d57f96a89bd7e0620b2f60df3b0066c8d"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:12d2925424d02add2f835c7549106151ece9eae262e96aee34af5d84178ba824"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:30512cce4108b26ede395ac521c0997c340bd19f177a1c0260bbffcb64861d30"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_i686.whl", hash = "sha256:2e6caf5f3084e6473a6dfd15285c47122ba92f4fb97ecfca855adf415603532a"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_4_x86_64.whl", hash = "sha256:927c95b991e81f39b02e42c9b391f2b3569e6dbe29d7fc2dce6ca778475c0934"}, + {file = "zstd-1.5.7.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2174fd7f588b2eb95a402c3d40f4676370eb50292362a0995295084b8f5d521e"}, + {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:3b05817bfdfc395999b6b3c9ea4f7c05e91bceafc3fc819906d5f0445afa4335"}, + {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:c67f0fcf4348343d25ecd35a44d33b6d31814e9ab3ee8676039de809579905a4"}, + {file = "zstd-1.5.7.3-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:40195c0056841aad6553172963adecf31b6ae1fdb9778d657ce9a2493d1791ee"}, + {file = "zstd-1.5.7.3-cp37-cp37m-win32.whl", hash = "sha256:b6ac3ae562758184fc1570399ea9d269163b488dbb0c4a44701e89f61ca6d1d6"}, + {file = "zstd-1.5.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e9f059d9c9f6f13ae78bfa9778755462b3ea53e4a5185941169422dd97c9fd22"}, + {file = "zstd-1.5.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:99e92b97c97d83e403615c12b644e8616fc7e8a8b4fa0c0558bcb9980baf5c92"}, + {file = "zstd-1.5.7.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a6b4ff0d5704994eb0d7ba2ea0b25acd749bb78a1c325289a8cba7651f0cbbff"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:edf4b595ab29a980f6f60fa71c64ab029d9ced97fb9c7c9ae555fe1159d8379d"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3cd48ec1dce8a8a06a3978225b20f28b7764e4191c436277e0abc60539e040da"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_i686.whl", hash = "sha256:1380ecc510a3885fad326863a7f42b3391560b471aeea60b04f9c1ece439b198"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_4_x86_64.whl", hash = "sha256:5fdff5190698e6d48a3facb58085a6c33b62be610f40e80299d975dbc75b32c8"}, + {file = "zstd-1.5.7.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:595d6495e96744fa5c9b78f38e8379f9eebfb97ae4f7ecc2639af4fd51459e07"}, + {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9bc3d6b7f2dec391b7539a0f43deb07bca1d68867082a07a286c2237f16390fd"}, + {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b8e62d533281946100c023a1168bd8935db6452bdd0f0b776afe8e80255e74c3"}, + {file = "zstd-1.5.7.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3a5dcc7ddcd56f131bee612b5feadd9b65e3996c0f4c6a485e2b2f20e7a324de"}, + {file = "zstd-1.5.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dbb497482dd63abe72a209345dbafa52817bd484c1d08139da080c14b1dadc7b"}, + {file = "zstd-1.5.7.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a599489d4e7e794981536521ee5dcfa61b0a641996409669b9aba5400b5cff83"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4a7ec28ca27fc347d7325eeb06d66cd2649846d5bfe77b18beed38d1870dd876"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:703481b41e5b3d33cd4e6a0b7116e8bc33a712aba1526d5fcad3e4303dd70fa1"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_i686.whl", hash = "sha256:61b0707c090d59ba879eac4b475562c5b9c1b375d0419d78fb398f156037f7df"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_4_x86_64.whl", hash = "sha256:7090ac97b14dea2969ba1ed427b38efe137efcdf556dc8740d3e035b04cbc8b4"}, + {file = "zstd-1.5.7.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5204bf9f3f2936ee3a28bfe43a57b78f88439c1777197295a0661d6de38caa80"}, + {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:431d4fecf764c305f29c1b9117d0d2ec5eb5523fc81516f1ee82509cb3b8e088"}, + {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2f213a32ab5e90bf165717f05fc1e3c214eeca7b6a33311e2397d89879c2f87"}, + {file = "zstd-1.5.7.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f87d617dac84b571bb74dc9d6905c66906dca982143adbe8e497ba2ce888cca"}, + {file = "zstd-1.5.7.3-cp39-cp39-win32.whl", hash = "sha256:9511957b5b8b5c0d4e737dff3a330a445a44005e09278bb8c799a76eb7f99d90"}, + {file = "zstd-1.5.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:9389848cc8297199b0fe2cd2985e5944f611ed518aa508136065ea0159051904"}, + {file = "zstd-1.5.7.3-cp39-cp39-win_arm64.whl", hash = "sha256:0cdf00f53cd38ce1f9edc79f68727150b9e65f4b33a3e8b59d94d0886cf43dbf"}, + {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:c5ac39836233356d32d0fe3d2f9525373c47c19f75fde68c16cf2293b7648b86"}, + {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:62fe5b560f389fdb40384a1711b7737bd9e27861f248cb89f19fed90a4cf0830"}, + {file = "zstd-1.5.7.3-pp27-pypy_73-manylinux_2_14_x86_64.whl", hash = "sha256:55fb8ac423800811f8b0c896b9617ecc91a1d4da15f66fb42ba162bfa5aa5a2d"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b9ec4d5ba8c170d3fdf21ae5da3c15eaea2beef9c419a5f3274a6f9e03c412a"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7ab69fc4d90eeb64b98a567751f8e48373f4bcf301597fca344b8e8342e1d5e"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da70f0918bf739bc75d7770410c9b94ea0dcb6f02d7ef70598b464bd5fcb193a"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dd5c069d0409284f1963b0b6b119f21b1da9e22a503e88933eb0696249d87d3"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46ca4a075f36f118e2ce07ba07d9ece7aeda193cea6f50b82aaee635df7b5fc2"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:4a521cb7615fc61bfe9514bea182e224894b5987fc7843b6d6da20a61206ef24"}, + {file = "zstd-1.5.7.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71ea22c953a164f34eb4b8c2c3b97eaa22da6a75296ea80b3ba4473187f15046"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079"}, + {file = "zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:781ec2644a3ce84c1cc19b0e057e1e8ea45260a8871eb6524614be75c9b432b9"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:ab74f37f2832d4a7c89d877ed9a70b1ef988fc2353678a122427039eb1dc6e36"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:521a3072fedcce025515d99242e346318d1815789033b7c0108796e151c42deb"}, + {file = "zstd-1.5.7.3-pp36-pypy36_pp73-win32.whl", hash = "sha256:94d404fd56765ff2952053cb2f6f980b88e3384a71af147c3ede9f6c6bea32d6"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:33f7e24d626938234c3c33df1988b79846628cf08dfab216bb19f85e7fcad65b"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c0c84fd4a87f28b8bed01cbaf128d33dfa209f03df2890dbc8c01e17a109c2d4"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0e334e45becf5a4844c8d64593eb358585e1553a7355f2172c865efc639ac051"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:15523e289509d7792418edb8c255cc1dacc65cda000428424c988208a682b8be"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2924befc3cb1a2310e1c03bd93469a2de8f0703e8805fe1f40367fbc2cece472"}, + {file = "zstd-1.5.7.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:173680156dbe959c80d72a1f15ef2034fd414b9d1ee507df152e416bc37665ef"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31d66b73a9861ee61bc6486fb9d1d33eabc86e506e49a210f30a91a241b8e643"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:a820a67491c1cf7a66698478a28b7d2517b0ae2e2775d834ca4f2624ba859e72"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:c385f92c37f4275d477388e46af8941580d7eeaad4c524c8f9aa50d016acbc7e"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:cecce78a3d639a3c439b1e355791e0f1ddbe8ed63d94f34c7973e92d384e6fc0"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e769fc830f5e2079612a27d6540e4147cd8dc8beacfaf73a48152f30a191e979"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:37a6750c25b561b05110313fdde4acd51246075a317e1c7a2491c96d2d863282"}, + {file = "zstd-1.5.7.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:0e95265e22f07cea6675baab762c9c4577a40d47824b01e0dcdf1a18b46aa041"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:878d859a7e1ebc078e0a575c05bcf3b0682b77cabd65bdbdd5e93c137ff1799b"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7efcf83189be9d842b9392ffd821b317cbd9447a49c590659abd3311e82c1676"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:a75dfdbca7dc01e7b35ca9b22e5b9792037b1515857e67b34bd737b213e49432"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5235dde49df717e5ca58f689e110bf1c4ed578170ab59e77f8a7a5055e4d8c07"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f876acad51d2184269ee6fd7e4c4aad9b7a0eca174d7d8db981ea079b57cbaf4"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2920e90ef200c7b2cbc73b4271c2271abf6195877b813ede0b5b76289e32fc8e"}, + {file = "zstd-1.5.7.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1f6dd0f2845a9817f0d0920eb0efd2d8a0168b71b8d8c85d2655d9d997f127ba"}, + {file = "zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495"}, +] + [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "3c9164d668d37d6373eb5200bbe768232ead934d9312b9c68046b1df922789f3" +content-hash = "c575bc849038db5b5d0882bec441529bf474a42b28c96718372ad4ceb388432c" diff --git a/api/pyproject.toml b/api/pyproject.toml index 22ae14e6a1..e0f577e076 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -5,10 +5,10 @@ requires = ["poetry-core"] [project] authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] dependencies = [ - "celery[pytest] (>=5.4.0,<6.0.0)", + "celery (>=5.4.0,<6.0.0)", "dj-rest-auth[with_social,jwt] (==7.0.1)", - "django (==5.1.13)", - "django-allauth[saml] (>=65.8.0,<66.0.0)", + "django (==5.1.15)", + "django-allauth[saml] (>=65.13.0,<66.0.0)", "django-celery-beat (>=2.7.0,<3.0.0)", "django-celery-results (>=2.5.1,<3.0.0)", "django-cors-headers==4.4.0", @@ -35,7 +35,13 @@ dependencies = [ "markdown (>=3.9,<4.0)", "drf-simple-apikey (==2.2.1)", "matplotlib (>=3.10.6,<4.0.0)", - "reportlab (>=4.4.4,<5.0.0)" + "reportlab (>=4.4.4,<5.0.0)", + "neo4j (<6.0.0)", + "cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1", + "gevent (>=25.9.1,<26.0.0)", + "werkzeug (>=3.1.4)", + "sqlparse (>=0.5.4)", + "fonttools (>=4.60.2)" ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" @@ -43,7 +49,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.15.0" +version = "1.20.0" [project.scripts] celery = "src.backend.config.settings.celery" @@ -53,6 +59,7 @@ bandit = "1.7.9" coverage = "7.5.4" django-silk = "5.3.2" docker = "7.1.0" +filelock = "3.20.3" freezegun = "1.5.1" marshmallow = ">=3.15.0,<4.0.0" mypy = "1.10.1" @@ -64,6 +71,6 @@ pytest-env = "1.1.3" pytest-randomly = "3.15.0" pytest-xdist = "3.6.1" ruff = "0.5.0" -safety = "3.2.9" +safety = "3.7.0" tqdm = "4.67.1" vulture = "2.14" diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index add97cf376..543c10ab88 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -30,16 +30,48 @@ class ApiConfig(AppConfig): def ready(self): from api import schema_extensions # noqa: F401 from api import signals # noqa: F401 - from api.compliance import load_prowler_compliance + from api.attack_paths import database as graph_database # Generate required cryptographic keys if not present, but only if: - # `"manage.py" not in sys.argv`: If an external server (e.g., Gunicorn) is running the app + # `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app # `os.environ.get("RUN_MAIN")`: If it's not a Django command or using `runserver`, # only the main process will do it - if "manage.py" not in sys.argv or os.environ.get("RUN_MAIN"): + if (len(sys.argv) >= 1 and "manage.py" not in sys.argv[0]) or os.environ.get( + "RUN_MAIN" + ): self._ensure_crypto_keys() - load_prowler_compliance() + # Commands that don't need Neo4j + SKIP_NEO4J_DJANGO_COMMANDS = [ + "makemigrations", + "migrate", + "pgpartition", + "check", + "help", + "showmigrations", + "check_and_fix_socialaccount_sites_migration", + ] + + # Skip Neo4j initialization during tests, some Django commands, and Celery + if getattr(settings, "TESTING", False) or ( + len(sys.argv) > 1 + and ( + ( + "manage.py" in sys.argv[0] + and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS + ) + or "celery" in sys.argv[0] + ) + ): + logger.info( + "Skipping Neo4j initialization because tests, some Django commands or Celery" + ) + + else: + graph_database.init_driver() + + # Neo4j driver is initialized at API startup (see api.attack_paths.database) + # It remains lazy for Celery workers and selected Django commands def _ensure_crypto_keys(self): """ @@ -54,7 +86,7 @@ class ApiConfig(AppConfig): global _keys_initialized # Skip key generation if running tests - if hasattr(settings, "TESTING") and settings.TESTING: + if getattr(settings, "TESTING", False): return # Skip if already initialized in this process diff --git a/api/src/backend/api/attack_paths/__init__.py b/api/src/backend/api/attack_paths/__init__.py new file mode 100644 index 0000000000..b2917e1d86 --- /dev/null +++ b/api/src/backend/api/attack_paths/__init__.py @@ -0,0 +1,14 @@ +from api.attack_paths.queries import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, + get_queries_for_provider, + get_query_by_id, +) + + +__all__ = [ + "AttackPathsQueryDefinition", + "AttackPathsQueryParameterDefinition", + "get_queries_for_provider", + "get_query_by_id", +] diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py new file mode 100644 index 0000000000..49c3b9615e --- /dev/null +++ b/api/src/backend/api/attack_paths/database.py @@ -0,0 +1,181 @@ +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 +from tasks.jobs.attack_paths.config import BATCH_SIZE, PROVIDER_RESOURCE_LABEL + +# Without this Celery goes crazy with Neo4j logging +logging.getLogger("neo4j").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +SERVICE_UNAVAILABLE_MAX_RETRIES = 3 + +# Module-level process-wide driver singleton +_driver: neo4j.Driver | None = None +_lock = threading.Lock() + +# Base Neo4j functions + + +def get_uri() -> str: + host = settings.DATABASES["neo4j"]["HOST"] + port = settings.DATABASES["neo4j"]["PORT"] + return f"bolt://{host}:{port}" + + +def init_driver() -> neo4j.Driver: + global _driver + if _driver is not None: + return _driver + + with _lock: + if _driver is None: + uri = get_uri() + config = settings.DATABASES["neo4j"] + + _driver = neo4j.GraphDatabase.driver( + uri, + auth=(config["USER"], config["PASSWORD"]), + keep_alive=True, + max_connection_lifetime=7200, + connection_acquisition_timeout=120, + max_connection_pool_size=50, + ) + _driver.verify_connectivity() + + # Register cleanup handler (only runs once since we're inside the _driver is None block) + atexit.register(close_driver) + + return _driver + + +def get_driver() -> neo4j.Driver: + return init_driver() + + +def close_driver() -> None: # TODO: Use it + global _driver + with _lock: + if _driver is not None: + try: + _driver.close() + + finally: + _driver = None + + +@contextmanager +def get_session(database: str | None = None) -> Iterator[RetryableSession]: + session_wrapper: RetryableSession | None = None + + try: + session_wrapper = RetryableSession( + session_factory=lambda: get_driver().session(database=database), + max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + ) + yield session_wrapper + + except neo4j.exceptions.Neo4jError as exc: + message = exc.message if exc.message is not None else str(exc) + raise GraphDatabaseQueryException(message=message, code=exc.code) + + finally: + if session_wrapper is not None: + session_wrapper.close() + + +def create_database(database: str) -> None: + query = "CREATE DATABASE $database IF NOT EXISTS" + parameters = {"database": database} + + with get_session() as session: + session.run(query, parameters) + + +def drop_database(database: str) -> None: + query = f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA" + + with get_session() as session: + session.run(query) + + +def drop_subgraph(database: str, provider_id: str) -> int: + """ + Delete all nodes for a provider from the tenant database. + + Uses batched deletion to avoid memory issues with large graphs. + Silently returns 0 if the database doesn't exist. + """ + deleted_nodes = 0 + parameters = { + "provider_id": provider_id, + "batch_size": BATCH_SIZE, + } + + try: + with get_session(database) as session: + deleted_count = 1 + while deleted_count > 0: + result = session.run( + f""" + MATCH (n:{PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}}) + WITH n LIMIT $batch_size + DETACH DELETE n + RETURN COUNT(n) AS deleted_nodes_count + """, + parameters, + ) + deleted_count = result.single().get("deleted_nodes_count", 0) + deleted_nodes += deleted_count + + except GraphDatabaseQueryException as exc: + if exc.code == "Neo.ClientError.Database.DatabaseNotFound": + return 0 + raise + + return deleted_nodes + + +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 + + +def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str: + prefix = "tmp-scan" if temporary else "tenant" + return f"db-{prefix}-{str(entity_id).lower()}" + + +# Exceptions + + +class GraphDatabaseQueryException(Exception): + def __init__(self, message: str, code: str | None = None) -> None: + super().__init__(message) + self.message = message + self.code = code + + def __str__(self) -> str: + if self.code: + return f"{self.code}: {self.message}" + + return self.message diff --git a/api/src/backend/api/attack_paths/queries/__init__.py b/api/src/backend/api/attack_paths/queries/__init__.py new file mode 100644 index 0000000000..c5e6ab0393 --- /dev/null +++ b/api/src/backend/api/attack_paths/queries/__init__.py @@ -0,0 +1,16 @@ +from api.attack_paths.queries.types import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) +from api.attack_paths.queries.registry import ( + get_queries_for_provider, + get_query_by_id, +) + + +__all__ = [ + "AttackPathsQueryDefinition", + "AttackPathsQueryParameterDefinition", + "get_queries_for_provider", + "get_query_by_id", +] diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py new file mode 100644 index 0000000000..39e5e5716f --- /dev/null +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -0,0 +1,3459 @@ +from api.attack_paths.queries.types import ( + AttackPathsQueryAttribution, + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL + + +# Custom Attack Path Queries +# -------------------------- + +AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( + id="aws-internet-exposed-ec2-sensitive-s3-access", + name="Internet-Exposed EC2 with Sensitive S3 Access", + short_description="Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets.", + description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.", + provider="aws", + cypher=f""" + CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}}) + YIELD node AS internet + + MATCH path_s3 = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)--(t:AWSTag) + WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value) + + MATCH path_ec2 = (aws)--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound) + WHERE ec2.exposed_internet = true + AND ipi.toport = 22 + + MATCH path_role = (r:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE ANY(x IN stmt.resource WHERE x CONTAINS s3.name) + AND ANY(x IN stmt.action WHERE toLower(x) =~ 's3:(listbucket|getobject).*') + + MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2) + YIELD rel AS can_access + + UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="tag_key", + label="Tag key", + description="Tag key to filter the S3 bucket, e.g. DataClassification.", + placeholder="DataClassification", + ), + AttackPathsQueryParameterDefinition( + name="tag_value", + label="Tag value", + description="Tag value to filter the S3 bucket, e.g. Sensitive.", + placeholder="Sensitive", + ), + ], +) + + +# Basic Resource Queries +# ---------------------- + +AWS_RDS_INSTANCES = AttackPathsQueryDefinition( + id="aws-rds-instances", + name="RDS Instances Inventory", + short_description="List all provisioned RDS database instances in the account.", + description="List the selected AWS account alongside the RDS instances it owns.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( + id="aws-rds-unencrypted-storage", + name="Unencrypted RDS Instances", + short_description="Find RDS instances with storage encryption disabled.", + description="Find RDS instances with storage encryption disabled within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) + WHERE rds.storage_encrypted = false + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( + id="aws-s3-anonymous-access-buckets", + name="S3 Buckets with Anonymous Access", + short_description="Find S3 buckets that allow anonymous access.", + description="Find S3 buckets that allow anonymous access within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket) + WHERE s3.anonymous_access = true + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-all-actions", + name="IAM Statements Allowing All Actions", + short_description="Find IAM policy statements that allow all actions via wildcard (*).", + description="Find IAM policy statements that allow all actions via '*' within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = '*') + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-delete-policy", + name="IAM Statements Allowing Policy Deletion", + short_description="Find IAM policy statements that allow iam:DeletePolicy.", + description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( + id="aws-iam-statements-allow-create-actions", + name="IAM Statements Allowing Create Actions", + short_description="Find IAM policy statements that allow any create action.", + description="Find IAM policy statements that allow actions containing 'create' within the selected account.", + provider="aws", + cypher=f""" + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = "Allow" + AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + + +# Network Exposure Queries +# ------------------------ + +AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-ec2-instances-internet-exposed", + name="Internet-Exposed EC2 Instances", + short_description="Find EC2 instances flagged as exposed to the internet.", + description="Find EC2 instances flagged as exposed to the internet within the selected account.", + provider="aws", + cypher=f""" + CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}}) + YIELD node AS internet + + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance) + WHERE ec2.exposed_internet = true + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( + id="aws-security-groups-open-internet-facing", + name="Open Security Groups on Internet-Facing Resources", + short_description="Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0.", + description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.", + provider="aws", + cypher=f""" + CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}}) + YIELD node AS internet + + // Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0) + MATCH path_ec2 = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange) + WHERE ec2.exposed_internet = true + AND ir.range = "0.0.0.0/0" + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, ec2) + YIELD rel AS can_access + + UNWIND nodes(path_ec2) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-classic-elb-internet-exposed", + name="Internet-Exposed Classic Load Balancers", + short_description="Find Classic Load Balancers exposed to the internet with their listeners.", + description="Find Classic Load Balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=f""" + CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}}) + YIELD node AS internet + + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener) + WHERE elb.exposed_internet = true + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, elb) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( + id="aws-elbv2-internet-exposed", + name="Internet-Exposed ALB/NLB Load Balancers", + short_description="Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners.", + description="Find ELBv2 load balancers exposed to the internet along with their listeners.", + provider="aws", + cypher=f""" + CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}}) + YIELD node AS internet + + MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener) + WHERE elbv2.exposed_internet = true + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, elbv2) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[], +) + +AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( + id="aws-public-ip-resource-lookup", + name="Resource Lookup by Public IP", + short_description="Find the AWS resource associated with a given public IP address.", + description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.", + provider="aws", + cypher=f""" + CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet'}}) + YIELD node AS internet + + CALL () {{ + MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2PrivateIp)-[q]-(y) + WHERE x.public_ip = $ip + RETURN path, x + + UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2Instance)-[q]-(y) + WHERE x.publicipaddress = $ip + RETURN path, x + + UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:NetworkInterface)-[q]-(y) + WHERE x.public_ip = $ip + RETURN path, x + + UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:ElasticIPAddress)-[q]-(y) + WHERE x.public_ip = $ip + RETURN path, x + }} + + WITH path, x, internet + + CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{}}, x) + YIELD rel AS can_access + + UNWIND nodes(path) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + """, + parameters=[ + AttackPathsQueryParameterDefinition( + name="ip", + label="IP address", + description="Public IP address, e.g. 192.0.2.0.", + placeholder="192.0.2.0", + ), + ], +) + +# Privilege Escalation Queries (based on pathfinding.cloud research) +# https://github.com/DataDog/pathfinding.cloud +# ------------------------------------------------------------------- + +# APPRUNNER-001 +AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( + id="aws-apprunner-privesc-passrole-create-service", + name="App Runner Service Creation with Privileged Role (APPRUNNER-001)", + short_description="Create an App Runner service with a privileged IAM role to gain its permissions.", + description="Detect principals who can pass IAM roles and create App Runner services. This allows creating a service with a privileged role attached, gaining that role's permissions via StartCommand execution, a container web shell, or a malicious apprunner.yaml configuration.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - APPRUNNER-001 - iam:PassRole + apprunner:CreateService", + link="https://pathfinding.cloud/paths/apprunner-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find apprunner:CreateService permission + MATCH (principal)--(apprunner_policy:AWSPolicy)--(stmt_apprunner:AWSPolicyStatement) + WHERE stmt_apprunner.effect = 'Allow' + AND any(action IN stmt_apprunner.action WHERE + toLower(action) = 'apprunner:createservice' + OR toLower(action) = 'apprunner:*' + OR action = '*' + ) + + // Find roles that trust App Runner tasks service (can be passed to App Runner) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# APPRUNNER-002 +AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( + id="aws-apprunner-privesc-update-service", + name="App Runner Service Update for Role Access (APPRUNNER-002)", + short_description="Update an existing App Runner service to leverage its already-attached privileged role.", + description="Detect principals who can update existing App Runner services. This allows modifying a service's configuration to execute arbitrary code with the service's already-attached IAM role, without requiring iam:PassRole. Exploitation methods include injecting a malicious StartCommand, updating to a container image with a web shell, or pointing to a repository with a malicious apprunner.yaml file.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - APPRUNNER-002 - apprunner:UpdateService", + link="https://pathfinding.cloud/paths/apprunner-002", + ), + provider="aws", + cypher=f""" + // Find principals with apprunner:UpdateService permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) + WHERE stmt_update.effect = 'Allow' + AND any(action IN stmt_update.action WHERE + toLower(action) = 'apprunner:updateservice' + OR toLower(action) = 'apprunner:*' + OR action = '*' + ) + + // Find existing App Runner services with roles attached (potential targets) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# BEDROCK-001 +AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( + id="aws-bedrock-privesc-passrole-code-interpreter", + name="Bedrock Code Interpreter with Privileged Role (BEDROCK-001)", + short_description="Create a Bedrock AgentCore Code Interpreter with a privileged role attached.", + description="Detect principals who can pass IAM roles and create Bedrock AgentCore Code Interpreters. This allows creating a code interpreter with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BEDROCK-001 - iam:PassRole + bedrock-agentcore:CreateCodeInterpreter + bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter", + link="https://pathfinding.cloud/paths/bedrock-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find bedrock-agentcore:CreateCodeInterpreter permission + MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement) + WHERE stmt_bedrock.effect = 'Allow' + AND any(action IN stmt_bedrock.action WHERE + toLower(action) = 'bedrock-agentcore:createcodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:StartCodeInterpreterSession permission + MATCH (principal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) + WHERE stmt_session.effect = 'Allow' + AND any(action IN stmt_session.action WHERE + toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:InvokeCodeInterpreter permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find roles that trust Bedrock service (can be passed to Bedrock) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# BEDROCK-002 +AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( + id="aws-bedrock-privesc-invoke-code-interpreter", + name="Bedrock Code Interpreter Session Hijacking (BEDROCK-002)", + short_description="Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials.", + description="Detect principals who can start sessions and invoke code on existing Bedrock AgentCore code interpreters. This allows executing arbitrary Python code within an interpreter that has a privileged role attached, gaining that role's credentials via the MicroVM Metadata Service without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - BEDROCK-002 - bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter", + link="https://pathfinding.cloud/paths/bedrock-002", + ), + provider="aws", + cypher=f""" + // Find principals with bedrock-agentcore:StartCodeInterpreterSession permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement) + WHERE stmt_session.effect = 'Allow' + AND any(action IN stmt_session.action WHERE + toLower(action) = 'bedrock-agentcore:startcodeinterpretersession' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find bedrock-agentcore:InvokeCodeInterpreter permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'bedrock-agentcore:invokecodeinterpreter' + OR toLower(action) = 'bedrock-agentcore:*' + OR action = '*' + ) + + // Find roles that trust Bedrock service (already attached to existing code interpreters) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-001 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-create-stack", + name="CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)", + short_description="Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources.", + description="Detect principals who can pass IAM roles and create CloudFormation stacks. This allows launching a stack with a malicious template that executes with the passed role's permissions, enabling creation of resources like IAM users, Lambda functions, or EC2 instances controlled by the attacker.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-001 - iam:PassRole + cloudformation:CreateStack", + link="https://pathfinding.cloud/paths/cloudformation-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:CreateStack permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:createstack' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed to CloudFormation) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-002 +AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-update-stack", + name="CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)", + short_description="Update an existing CloudFormation stack to leverage its already-attached privileged service role.", + description="Detect principals who can update existing CloudFormation stacks. This allows modifying a stack's template to add new resources (such as IAM roles with admin access) that are created with the stack's already-attached service role permissions, without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-002 - cloudformation:UpdateStack", + link="https://pathfinding.cloud/paths/cloudformation-002", + ), + provider="aws", + cypher=f""" + // Find principals with cloudformation:UpdateStack permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement) + WHERE stmt_update.effect = 'Allow' + AND any(action IN stmt_update.action WHERE + toLower(action) = 'cloudformation:updatestack' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (already attached to existing stacks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-003 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-create-stackset", + name="CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)", + short_description="Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts.", + description="Detect principals who can pass IAM roles, create CloudFormation StackSets, and deploy stack instances. This allows creating a StackSet with a malicious template and a privileged execution role, then deploying instances that create resources (such as IAM roles with admin access) using that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-003 - iam:PassRole + cloudformation:CreateStackSet + cloudformation:CreateStackInstances", + link="https://pathfinding.cloud/paths/cloudformation-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:CreateStackSet permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:createstackset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find cloudformation:CreateStackInstances permission + MATCH (principal)--(cfn_instances_policy:AWSPolicy)--(stmt_cfn_instances:AWSPolicyStatement) + WHERE stmt_cfn_instances.effect = 'Allow' + AND any(action IN stmt_cfn_instances.action WHERE + toLower(action) = 'cloudformation:createstackinstances' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed as execution role) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-004 +AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-passrole-update-stackset", + name="CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)", + short_description="Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role.", + description="Detect principals who can pass IAM roles and update CloudFormation StackSets. This allows modifying an existing StackSet's template to add resources (such as IAM roles with admin access) that are provisioned by the StackSet's privileged execution role across target accounts.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-004 - iam:PassRole + cloudformation:UpdateStackSet", + link="https://pathfinding.cloud/paths/cloudformation-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find cloudformation:UpdateStackSet permission + MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement) + WHERE stmt_cfn.effect = 'Allow' + AND any(action IN stmt_cfn.action WHERE + toLower(action) = 'cloudformation:updatestackset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (can be passed as execution role) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CLOUDFORMATION-005 +AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition( + id="aws-cloudformation-privesc-changeset", + name="CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)", + short_description="Create and execute a change set on an existing stack to leverage its privileged service role.", + description="Detect principals who can create and execute CloudFormation change sets. This allows modifying an existing stack's template through a staged change set, inheriting the stack's already-attached service role permissions to provision arbitrary resources without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CLOUDFORMATION-005 - cloudformation:CreateChangeSet + cloudformation:ExecuteChangeSet", + link="https://pathfinding.cloud/paths/cloudformation-005", + ), + provider="aws", + cypher=f""" + // Find principals with cloudformation:CreateChangeSet permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'cloudformation:createchangeset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find cloudformation:ExecuteChangeSet permission + MATCH (principal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) + WHERE stmt_exec.effect = 'Allow' + AND any(action IN stmt_exec.action WHERE + toLower(action) = 'cloudformation:executechangeset' + OR toLower(action) = 'cloudformation:*' + OR action = '*' + ) + + // Find roles that trust CloudFormation service (already attached to existing stacks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-001 +AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-passrole-create-project", + name="CodeBuild Project Creation with Privileged Role (CODEBUILD-001)", + short_description="Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec.", + description="Detect principals who can pass IAM roles, create CodeBuild projects, and start builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious buildspec, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-001 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuild", + link="https://pathfinding.cloud/paths/codebuild-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find codebuild:CreateProject permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'codebuild:createproject' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find codebuild:StartBuild permission + MATCH (principal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuild' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (can be passed to CodeBuild) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-002 +AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-start-build", + name="CodeBuild Buildspec Override for Role Access (CODEBUILD-002)", + short_description="Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.", + description="Detect principals who can start builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-002 - codebuild:StartBuild", + link="https://pathfinding.cloud/paths/codebuild-002", + ), + provider="aws", + cypher=f""" + // Find principals with codebuild:StartBuild permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuild' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (already attached to existing projects) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-003 +AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-start-build-batch", + name="CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)", + short_description="Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.", + description="Detect principals who can start batch builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-003 - codebuild:StartBuildBatch", + link="https://pathfinding.cloud/paths/codebuild-003", + ), + provider="aws", + cypher=f""" + // Find principals with codebuild:StartBuildBatch permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement) + WHERE stmt_build.effect = 'Allow' + AND any(action IN stmt_build.action WHERE + toLower(action) = 'codebuild:startbuildbatch' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (already attached to existing projects) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# CODEBUILD-004 +AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition( + id="aws-codebuild-privesc-passrole-create-project-batch", + name="CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)", + short_description="Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec.", + description="Detect principals who can pass IAM roles, create CodeBuild projects, and start batch builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious batch buildspec, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - CODEBUILD-004 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuildBatch", + link="https://pathfinding.cloud/paths/codebuild-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find codebuild:CreateProject permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'codebuild:createproject' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find codebuild:StartBuildBatch permission + MATCH (principal)--(batch_policy:AWSPolicy)--(stmt_batch:AWSPolicyStatement) + WHERE stmt_batch.effect = 'Allow' + AND any(action IN stmt_batch.action WHERE + toLower(action) = 'codebuild:startbuildbatch' + OR toLower(action) = 'codebuild:*' + OR action = '*' + ) + + // Find roles that trust CodeBuild service (can be passed to CodeBuild) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# DATAPIPELINE-001 +AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( + id="aws-datapipeline-privesc-passrole-create-pipeline", + name="Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)", + short_description="Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure.", + description="Detect principals who can pass IAM roles, create Data Pipelines, define pipeline objects, and activate them. This allows creating a pipeline with a privileged role attached and executing arbitrary commands on the provisioned EC2 instances or EMR clusters, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - DATAPIPELINE-001 - iam:PassRole + datapipeline:CreatePipeline + datapipeline:PutPipelineDefinition + datapipeline:ActivatePipeline", + link="https://pathfinding.cloud/paths/datapipeline-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find datapipeline:CreatePipeline permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'datapipeline:createpipeline' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find datapipeline:PutPipelineDefinition permission + MATCH (principal)--(put_policy:AWSPolicy)--(stmt_put:AWSPolicyStatement) + WHERE stmt_put.effect = 'Allow' + AND any(action IN stmt_put.action WHERE + toLower(action) = 'datapipeline:putpipelinedefinition' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find datapipeline:ActivatePipeline permission + MATCH (principal)--(activate_policy:AWSPolicy)--(stmt_activate:AWSPolicyStatement) + WHERE stmt_activate.effect = 'Allow' + AND any(action IN stmt_activate.action WHERE + toLower(action) = 'datapipeline:activatepipeline' + OR toLower(action) = 'datapipeline:*' + OR action = '*' + ) + + // Find roles that trust Data Pipeline or EMR service (can be passed to DataPipeline) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted_principal:AWSPrincipal) + WHERE trusted_principal.arn IN ['datapipeline.amazonaws.com', 'elasticmapreduce.amazonaws.com'] + AND any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-001 +AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-iam", + name="EC2 Instance Launch with Privileged Role (EC2-001)", + short_description="Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS.", + description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances", + link="https://pathfinding.cloud/paths/ec2-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ec2:RunInstances permission + MATCH (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement) + WHERE stmt_ec2.effect = 'Allow' + AND any(action IN stmt_ec2.action WHERE + toLower(action) = 'ec2:runinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-002 +AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-modify-instance-attribute", + name="EC2 Role Hijacking via UserData Injection (EC2-002)", + short_description="Inject malicious scripts into EC2 instance userData to gain the attached role's permissions.", + description="Detect principals who can modify EC2 instance userData, stop, and start instances. This allows injecting malicious scripts that execute on instance restart, gaining the permissions of the instance's attached IAM role.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-002 - ec2:ModifyInstanceAttribute + ec2:StopInstances + ec2:StartInstances", + link="https://pathfinding.cloud/paths/ec2-002", + ), + provider="aws", + cypher=f""" + // Find principals with ec2:ModifyInstanceAttribute permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifyinstanceattribute' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:StopInstances permission (can be same or different policy) + MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement) + WHERE stmt_stop.effect = 'Allow' + AND any(action IN stmt_stop.action WHERE + toLower(action) = 'ec2:stopinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:StartInstances permission (can be same or different policy) + MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement) + WHERE stmt_start.effect = 'Allow' + AND any(action IN stmt_start.action WHERE + toLower(action) = 'ec2:startinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find EC2 instances with instance profiles (potential targets) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-003 +AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( + id="aws-ec2-privesc-passrole-spot-instances", + name="Spot Instance Launch with Privileged Role (EC2-003)", + short_description="Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS.", + description="Detect principals who can pass IAM roles and request EC2 Spot Instances. This allows launching a spot instance with a privileged role attached, gaining that role's permissions via the instance metadata service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-003 - iam:PassRole + ec2:RequestSpotInstances", + link="https://pathfinding.cloud/paths/ec2-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ec2:RequestSpotInstances permission + MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement) + WHERE stmt_spot.effect = 'Allow' + AND any(action IN stmt_spot.action WHERE + toLower(action) = 'ec2:requestspotinstances' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find roles that trust EC2 service (can be passed to EC2 spot instances) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2-004 +AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( + id="aws-ec2-privesc-launch-template", + name="Launch Template Poisoning for Role Access (EC2-004)", + short_description="Inject malicious userData into launch templates that reference privileged roles, no PassRole needed.", + description="Detect principals who can create new launch template versions and modify launch templates. This allows injecting malicious user data into existing templates that already reference privileged IAM roles, without requiring iam:PassRole permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2-004 - ec2:CreateLaunchTemplateVersion + ec2:ModifyLaunchTemplate", + link="https://pathfinding.cloud/paths/ec2-004", + ), + provider="aws", + cypher=f""" + // Find principals with ec2:CreateLaunchTemplateVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'ec2:createlaunchtemplateversion' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find ec2:ModifyLaunchTemplate permission + MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement) + WHERE stmt_modify.effect = 'Allow' + AND any(action IN stmt_modify.action WHERE + toLower(action) = 'ec2:modifylaunchtemplate' + OR toLower(action) = 'ec2:*' + OR action = '*' + ) + + // Find launch templates in the account (potential targets) + MATCH path_target = (aws)--(template:LaunchTemplate) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# EC2INSTANCECONNECT-003 +AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition( + id="aws-ec2instanceconnect-privesc-send-ssh-public-key", + name="EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)", + short_description="Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS.", + description="Detect principals who can send SSH public keys via EC2 Instance Connect. This allows establishing an SSH session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - EC2INSTANCECONNECT-003 - ec2-instance-connect:SendSSHPublicKey", + link="https://pathfinding.cloud/paths/ec2instanceconnect-003", + ), + provider="aws", + cypher=f""" + // Find principals with ec2-instance-connect:SendSSHPublicKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(connect_policy:AWSPolicy)--(stmt_connect:AWSPolicyStatement) + WHERE stmt_connect.effect = 'Allow' + AND any(action IN stmt_connect.action WHERE + toLower(action) = 'ec2-instance-connect:sendsshpublickey' + OR toLower(action) = 'ec2-instance-connect:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-001 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service", + name="ECS Service Creation with Privileged Role (ECS-001 - New Cluster)", + short_description="Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and create services. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container.", + provider="aws", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-001 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-001", + ), + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-002 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task", + name="ECS Task Execution with Privileged Role (ECS-002 - New Cluster)", + short_description="Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code.", + description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and run tasks. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container. Unlike ecs:CreateService, ecs:RunTask executes the task once without creating a persistent service.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-002 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:CreateCluster permission + MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement) + WHERE stmt_cluster.effect = 'Allow' + AND any(action IN stmt_cluster.action WHERE + toLower(action) = 'ecs:createcluster' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-003 +AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-create-service-existing-cluster", + name="ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)", + short_description="Deploy a Fargate service with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and create services on existing clusters. Unlike ECS-001, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and launches it as a Fargate service, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-003 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:CreateService", + link="https://pathfinding.cloud/paths/ecs-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:CreateService permission + MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement) + WHERE stmt_service.effect = 'Allow' + AND any(action IN stmt_service.action WHERE + toLower(action) = 'ecs:createservice' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-004 +AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-run-task-existing-cluster", + name="ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)", + short_description="Run a one-off Fargate task with a privileged role on an existing ECS cluster.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and run tasks on existing clusters. Unlike ECS-002, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and runs it as a one-off Fargate task, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-004 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:RunTask", + link="https://pathfinding.cloud/paths/ecs-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:RunTask permission + MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement) + WHERE stmt_runtask.effect = 'Allow' + AND any(action IN stmt_runtask.action WHERE + toLower(action) = 'ecs:runtask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-005 +AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( + id="aws-ecs-privesc-passrole-start-task-existing-cluster", + name="ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)", + short_description="Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code.", + description="Detect principals who can pass IAM roles, register ECS task definitions, and start tasks on existing EC2 container instances. Unlike ecs:RunTask which works with both EC2 and Fargate, ecs:StartTask is specific to EC2 launch types and requires specifying an existing container instance ARN. The attacker registers a task definition with a privileged role and starts it on a container instance, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-005 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:StartTask", + link="https://pathfinding.cloud/paths/ecs-005", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find ecs:RegisterTaskDefinition permission + MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement) + WHERE stmt_taskdef.effect = 'Allow' + AND any(action IN stmt_taskdef.action WHERE + toLower(action) = 'ecs:registertaskdefinition' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:StartTask permission + MATCH (principal)--(starttask_policy:AWSPolicy)--(stmt_starttask:AWSPolicyStatement) + WHERE stmt_starttask.effect = 'Allow' + AND any(action IN stmt_starttask.action WHERE + toLower(action) = 'ecs:starttask' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (can be passed to ECS tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# ECS-006 +AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( + id="aws-ecs-privesc-execute-command", + name="ECS Exec Container Hijacking for Role Credentials (ECS-006)", + short_description="Shell into a running ECS container via ECS Exec to steal the attached task role's credentials.", + description="Detect principals who can execute commands in running ECS containers and describe tasks. This allows establishing an interactive shell session in a container where ECS Exec is enabled, then retrieving the task role's temporary credentials from the container metadata service, without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - ECS-006 - ecs:ExecuteCommand + ecs:DescribeTasks", + link="https://pathfinding.cloud/paths/ecs-006", + ), + provider="aws", + cypher=f""" + // Find principals with ecs:ExecuteCommand permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement) + WHERE stmt_exec.effect = 'Allow' + AND any(action IN stmt_exec.action WHERE + toLower(action) = 'ecs:executecommand' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find ecs:DescribeTasks permission (required by AWS CLI to get container runtime ID) + MATCH (principal)--(describe_policy:AWSPolicy)--(stmt_describe:AWSPolicyStatement) + WHERE stmt_describe.effect = 'Allow' + AND any(action IN stmt_describe.action WHERE + toLower(action) = 'ecs:describetasks' + OR toLower(action) = 'ecs:*' + OR action = '*' + ) + + // Find roles that trust ECS tasks service (already attached to running tasks) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-001 +AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-dev-endpoint", + name="Glue Dev Endpoint with Privileged Role (GLUE-001)", + short_description="Create a Glue development endpoint with a privileged role attached to gain its permissions.", + description="Detect principals who can pass IAM roles and create Glue development endpoints. This allows creating a dev endpoint with a privileged role attached, gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-001 - iam:PassRole + glue:CreateDevEndpoint", + link="https://pathfinding.cloud/paths/glue-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateDevEndpoint permission + MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement) + WHERE stmt_glue.effect = 'Allow' + AND any(action IN stmt_glue.action WHERE + toLower(action) = 'glue:createdevendpoint' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-002 +AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition( + id="aws-glue-privesc-update-dev-endpoint", + name="Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)", + short_description="Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials.", + description="Detect principals who can update Glue development endpoints. This allows adding an attacker-controlled SSH public key to an existing dev endpoint that already has a privileged role attached, then SSHing into it to steal the role's temporary credentials without requiring iam:PassRole.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-002 - glue:UpdateDevEndpoint", + link="https://pathfinding.cloud/paths/glue-002", + ), + provider="aws", + cypher=f""" + // Find principals with glue:UpdateDevEndpoint permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'glue:updatedevendpoint' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (already attached to existing dev endpoints) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-003 +AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-create-job", + name="Glue Job Creation with Privileged Role (GLUE-003)", + short_description="Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions.", + description="Detect principals who can pass IAM roles, create Glue jobs, and start job runs. This allows creating a Python shell job with a privileged role attached and executing arbitrary code that modifies IAM permissions, a cost-effective alternative to Glue development endpoints.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-003 - iam:PassRole + glue:CreateJob + glue:StartJobRun", + link="https://pathfinding.cloud/paths/glue-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateJob permission + MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) + WHERE stmt_createjob.effect = 'Allow' + AND any(action IN stmt_createjob.action WHERE + toLower(action) = 'glue:createjob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:StartJobRun permission + MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) + WHERE stmt_startjob.effect = 'Allow' + AND any(action IN stmt_startjob.action WHERE + toLower(action) = 'glue:startjobrun' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-004 +AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-create-job-trigger", + name="Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)", + short_description="Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code.", + description="Detect principals who can pass IAM roles, create Glue jobs, and create triggers with automatic activation. Unlike manual execution via StartJobRun, this creates a persistent attack by scheduling the job to run repeatedly, making it harder to detect and remediate.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-004 - iam:PassRole + glue:CreateJob + glue:CreateTrigger", + link="https://pathfinding.cloud/paths/glue-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:CreateJob permission + MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement) + WHERE stmt_createjob.effect = 'Allow' + AND any(action IN stmt_createjob.action WHERE + toLower(action) = 'glue:createjob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:CreateTrigger permission + MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) + WHERE stmt_trigger.effect = 'Allow' + AND any(action IN stmt_trigger.action WHERE + toLower(action) = 'glue:createtrigger' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-005 +AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-update-job", + name="Glue Job Hijacking via Update with Privileged Role (GLUE-005)", + short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions.", + description="Detect principals who can pass IAM roles, update existing Glue jobs, and start job runs. This allows modifying an existing job's role and script to execute arbitrary code with elevated privileges, a stealthier variant of job creation since it reuses existing infrastructure rather than creating new resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-005 - iam:PassRole + glue:UpdateJob + glue:StartJobRun", + link="https://pathfinding.cloud/paths/glue-005", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:UpdateJob permission + MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) + WHERE stmt_updatejob.effect = 'Allow' + AND any(action IN stmt_updatejob.action WHERE + toLower(action) = 'glue:updatejob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:StartJobRun permission + MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement) + WHERE stmt_startjob.effect = 'Allow' + AND any(action IN stmt_startjob.action WHERE + toLower(action) = 'glue:startjobrun' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# GLUE-006 +AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( + id="aws-glue-privesc-passrole-update-job-trigger", + name="Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)", + short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution.", + description="Detect principals who can pass IAM roles, update existing Glue jobs, and create triggers with automatic activation. This combines the stealth of modifying existing infrastructure with the persistence of scheduled automation, creating a recurring backdoor that re-executes even after remediation attempts.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - GLUE-006 - iam:PassRole + glue:UpdateJob + glue:CreateTrigger", + link="https://pathfinding.cloud/paths/glue-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find glue:UpdateJob permission + MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement) + WHERE stmt_updatejob.effect = 'Allow' + AND any(action IN stmt_updatejob.action WHERE + toLower(action) = 'glue:updatejob' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find glue:CreateTrigger permission + MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement) + WHERE stmt_trigger.effect = 'Allow' + AND any(action IN stmt_trigger.action WHERE + toLower(action) = 'glue:createtrigger' + OR toLower(action) = 'glue:*' + OR action = '*' + ) + + // Find roles that trust Glue service (can be passed to Glue jobs) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-001 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version", + name="Policy Version Override for Self-Escalation (IAM-001)", + short_description="Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges.", + description="Detect principals who can create new policy versions. If a customer-managed policy is already attached to a principal and that principal has iam:CreatePolicyVersion on that policy, they can replace its contents with a fully permissive policy and set it as the default, gaining immediate administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-001 - iam:CreatePolicyVersion", + link="https://pathfinding.cloud/paths/iam-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find customer-managed policies attached to the same principal that can be overwritten + MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-002 +AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-access-key", + name="Access Key Creation for Lateral Movement (IAM-002)", + short_description="Create access keys for other IAM users to gain their permissions and move laterally across the account.", + description="Detect principals who can create access keys for other IAM users. This allows generating new credentials for any target user within the resource scope, immediately gaining that user's permissions without needing their password or existing keys.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-002 - iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateAccessKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can create access keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-003 +AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-delete-create-access-key", + name="Access Key Rotation Attack for Lateral Movement (IAM-003)", + short_description="Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions.", + description="Detect principals who can both delete and create access keys for other IAM users. This variation of IAM-002 handles the scenario where a target user already has the maximum of two access keys by first deleting one, then creating a replacement under the attacker's control.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-003 - iam:CreateAccessKey + iam:DeleteAccessKey", + link="https://pathfinding.cloud/paths/iam-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateAccessKey permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:DeleteAccessKey permission + MATCH (principal)--(delete_policy:AWSPolicy)--(stmt_delete:AWSPolicyStatement) + WHERE stmt_delete.effect = 'Allow' + AND any(action IN stmt_delete.action WHERE + toLower(action) = 'iam:deleteaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can rotate access keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt_delete.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-004 +AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-login-profile", + name="Console Login Profile Creation for Lateral Movement (IAM-004)", + short_description="Create console login profiles for other IAM users to access the AWS Console with their permissions.", + description="Detect principals who can create console login profiles for other IAM users. By setting a known password on a target user that lacks a login profile, the attacker gains AWS Console access with that user's permissions without needing their existing credentials.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-004 - iam:CreateLoginProfile", + link="https://pathfinding.cloud/paths/iam-004", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreateLoginProfile permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createloginprofile' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can create login profiles for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-005 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy", + name="Inline Policy Injection for Self-Escalation (IAM-005)", + short_description="Attach an inline policy with administrative permissions to your own role, instantly escalating privileges.", + description="Detect roles that can use iam:PutRolePolicy on themselves. A role with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-005 - iam:PutRolePolicy", + link="https://pathfinding.cloud/paths/iam-005", + ), + provider="aws", + cypher=f""" + // Find roles with iam:PutRolePolicy permission scoped to themselves + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR role.arn CONTAINS resource + OR resource CONTAINS role.name + ) + + UNWIND nodes(path_principal) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-006 +AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( + id="aws-iam-privesc-update-login-profile", + name="Console Password Override for Lateral Movement (IAM-006)", + short_description="Change the console password of other IAM users to log in as them and gain their permissions.", + description="Detect principals who can update console login profiles for other IAM users. By resetting a target user's password, the attacker gains AWS Console access with that user's permissions. Unlike IAM-004, this targets users who already have a login profile configured.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-006 - iam:UpdateLoginProfile", + link="https://pathfinding.cloud/paths/iam-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:UpdateLoginProfile permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:updateloginprofile' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users that the principal can update login profiles for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-007 +AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-user-policy", + name="Inline Policy Injection on User for Self-Escalation (IAM-007)", + short_description="Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges.", + description="Detect IAM users that can use iam:PutUserPolicy on themselves. A user with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource. This is the user equivalent of IAM-005 (PutRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-007 - iam:PutUserPolicy", + link="https://pathfinding.cloud/paths/iam-007", + ), + provider="aws", + cypher=f""" + // Find users with iam:PutUserPolicy permission scoped to themselves + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR user.arn CONTAINS resource + OR resource CONTAINS user.name + ) + + UNWIND nodes(path_principal) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-008 +AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-user-policy", + name="Managed Policy Attachment on User for Self-Escalation (IAM-008)", + short_description="Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges.", + description="Detect IAM users that can use iam:AttachUserPolicy on themselves. A user with this permission can attach any existing managed policy, including AdministratorAccess, to themselves without needing to modify or assume any other resource. Unlike IAM-007 (PutUserPolicy), this requires an existing managed policy with elevated permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-008 - iam:AttachUserPolicy", + link="https://pathfinding.cloud/paths/iam-008", + ), + provider="aws", + cypher=f""" + // Find users with iam:AttachUserPolicy permission scoped to themselves + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR user.arn CONTAINS resource + OR resource CONTAINS user.name + ) + + UNWIND nodes(path_principal) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-009 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy", + name="Managed Policy Attachment on Role for Self-Escalation (IAM-009)", + short_description="Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges.", + description="Detect IAM roles that can use iam:AttachRolePolicy on themselves. A role with this permission can attach any existing managed policy, including AdministratorAccess, to itself without needing to modify or assume any other resource. Unlike IAM-005 (PutRolePolicy), this requires an existing managed policy with elevated permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-009 - iam:AttachRolePolicy", + link="https://pathfinding.cloud/paths/iam-009", + ), + provider="aws", + cypher=f""" + // Find roles with iam:AttachRolePolicy permission scoped to themselves + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + AND any(resource IN stmt.resource WHERE + resource = '*' + OR role.arn CONTAINS resource + OR resource CONTAINS role.name + ) + + UNWIND nodes(path_principal) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-010 +AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-group-policy", + name="Managed Policy Attachment on Group for Self-Escalation (IAM-010)", + short_description="Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members.", + description="Detect IAM users that can use iam:AttachGroupPolicy on a group they are a member of. A user with this permission can attach any existing managed policy, including AdministratorAccess, to a group they belong to, immediately escalating privileges for all group members.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-010 - iam:AttachGroupPolicy", + link="https://pathfinding.cloud/paths/iam-010", + ), + provider="aws", + cypher=f""" + // Find users with iam:AttachGroupPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachgrouppolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find groups the user is a member of and can attach policies to + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-011 +AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-group-policy", + name="Inline Policy Injection on Group for Self-Escalation (IAM-011)", + short_description="Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members.", + description="Detect IAM users that can use iam:PutGroupPolicy on a group they are a member of. A user with this permission can attach an inline policy granting any permissions to a group they belong to, immediately escalating privileges for all group members. Unlike IAM-010, this does not require an existing managed policy.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-011 - iam:PutGroupPolicy", + link="https://pathfinding.cloud/paths/iam-011", + ), + provider="aws", + cypher=f""" + // Find users with iam:PutGroupPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putgrouppolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find groups the user is a member of and can put policies on + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-012 +AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( + id="aws-iam-privesc-update-assume-role-policy", + name="Trust Policy Hijacking for Role Assumption (IAM-012)", + short_description="Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions.", + description="Detect principals who can update the assume role policy (trust policy) of other IAM roles. By modifying a target role's trust policy to trust the attacker's principal, the attacker can then assume the role and gain all its permissions, including potential administrative access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-012 - iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-012", + ), + provider="aws", + cypher=f""" + // Find principals with iam:UpdateAssumeRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles whose trust policy can be modified + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-013 +AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( + id="aws-iam-privesc-add-user-to-group", + name="Group Membership Hijacking for Privilege Escalation (IAM-013)", + short_description="Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group.", + description="Detect principals who can add users to IAM groups. By adding themselves to a group with elevated permissions such as AdministratorAccess, the attacker immediately inherits all policies attached to that group. The level of access gained depends on the permissions of the target group.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-013 - iam:AddUserToGroup", + link="https://pathfinding.cloud/paths/iam-013", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AddUserToGroup permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:addusertogroup' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target groups the principal can add users to + MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_group.arn CONTAINS resource + OR resource CONTAINS target_group.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-014 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-assume-role", + name="Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)", + short_description="Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges.", + description="Detect principals who can attach managed policies to a different IAM role and also assume that role. By attaching AdministratorAccess to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-009 for lateral movement where the principal targets another assumable role instead of their own.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-014 - iam:AttachRolePolicy + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-014", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume and attach policies to + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-015 +AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-user-policy-create-access-key", + name="Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)", + short_description="Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges.", + description="Detect principals who can attach managed policies to another IAM user and also create access keys for that user. By attaching AdministratorAccess to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-008 (AttachUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-015 - iam:AttachUserPolicy + iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-015", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachUserPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:CreateAccessKey permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users the principal can attach policies to and create keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-016 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version-assume-role", + name="Policy Version Override with Role Assumption for Lateral Movement (IAM-016)", + short_description="Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access.", + description="Detect principals who can create new versions of customer-managed policies attached to other roles and also assume those roles. By creating a new policy version with administrative permissions on a policy attached to a target role, then assuming that role, the attacker gains full administrative access. This is a variation of IAM-001 for lateral movement where the modified policy is attached to an assumable role rather than the attacker's own principal.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-016 - iam:CreatePolicyVersion + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-016", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume that have customer-managed policies the principal can modify + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + MATCH (target_role)--(target_policy:AWSPolicy) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-017 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy-assume-role", + name="Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)", + short_description="Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges.", + description="Detect principals who can add inline policies to a different IAM role and also assume that role. By adding an inline policy granting administrative permissions to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-005 for lateral movement where the principal targets another assumable role instead of their own.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-017 - iam:PutRolePolicy + sts:AssumeRole", + link="https://pathfinding.cloud/paths/iam-017", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can assume and put inline policies on + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-018 +AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-user-policy-create-access-key", + name="Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)", + short_description="Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges.", + description="Detect principals who can add inline policies to another IAM user and also create access keys for that user. By adding an administrative inline policy to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-007 (PutUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-018 - iam:PutUserPolicy + iam:CreateAccessKey", + link="https://pathfinding.cloud/paths/iam-018", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutUserPolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putuserpolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:CreateAccessKey permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:createaccesskey' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target users the principal can put policies on and create keys for + MATCH path_target = (aws)--(target_user:AWSUser) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_user.arn CONTAINS resource + OR resource CONTAINS target_user.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-019 +AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-attach-role-policy-update-assume-role", + name="Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)", + short_description="Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.", + description="Detect principals who can attach managed policies to an IAM role and also update that role's trust policy. By attaching AdministratorAccess and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-009 (AttachRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-019 - iam:AttachRolePolicy + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-019", + ), + provider="aws", + cypher=f""" + // Find principals with iam:AttachRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:attachrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can attach policies to and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-020 +AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-create-policy-version-update-assume-role", + name="Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)", + short_description="Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access.", + description="Detect principals who can create new versions of customer-managed policies attached to roles and also update those roles' trust policies. By creating an administrative policy version and modifying the trust policy to allow the attacker, the principal can assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-001 (CreatePolicyVersion) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-020 - iam:CreatePolicyVersion + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-020", + ), + provider="aws", + cypher=f""" + // Find principals with iam:CreatePolicyVersion permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:createpolicyversion' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles with customer-managed policies the principal can modify and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + MATCH (target_role)--(target_policy:AWSPolicy) + WHERE target_policy.arn CONTAINS $provider_uid + AND any(resource IN stmt.resource WHERE + resource = '*' + OR target_policy.arn CONTAINS resource + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# IAM-021 +AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-iam-privesc-put-role-policy-update-assume-role", + name="Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)", + short_description="Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.", + description="Detect principals who can add inline policies to an IAM role and also update that role's trust policy. By adding an administrative inline policy and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-005 (PutRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - IAM-021 - iam:PutRolePolicy + iam:UpdateAssumeRolePolicy", + link="https://pathfinding.cloud/paths/iam-021", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PutRolePolicy permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:putrolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find iam:UpdateAssumeRolePolicy permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'iam:updateassumerolepolicy' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find target roles the principal can put inline policies on and update trust policy for + MATCH path_target = (aws)--(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-001 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function", + name="Lambda Function Creation with Privileged Role (LAMBDA-001)", + short_description="Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and invoke them. By passing a privileged role to a new Lambda function and invoking it, the attacker executes code with the role's permissions, gaining access to any resources the role can access.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-001 - iam:PassRole + lambda:CreateFunction + lambda:InvokeFunction", + link="https://pathfinding.cloud/paths/lambda-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:InvokeFunction permission + MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement) + WHERE stmt_invoke.effect = 'Allow' + AND any(action IN stmt_invoke.action WHERE + toLower(action) = 'lambda:invokefunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-002 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function-event-source", + name="Lambda Function Creation with Event Source Trigger (LAMBDA-002)", + short_description="Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and configure event source mappings to trigger them. By passing a privileged role to a new Lambda function and creating an event source mapping (DynamoDB stream, Kinesis, SQS), the attacker executes code with elevated privileges without needing to invoke the function directly.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-002 - iam:PassRole + lambda:CreateFunction + lambda:CreateEventSourceMapping", + link="https://pathfinding.cloud/paths/lambda-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:CreateEventSourceMapping permission + MATCH (principal)--(event_policy:AWSPolicy)--(stmt_event:AWSPolicyStatement) + WHERE stmt_event.effect = 'Allow' + AND any(action IN stmt_event.action WHERE + toLower(action) = 'lambda:createeventsourcemapping' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-003 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code", + name="Lambda Function Code Injection (LAMBDA-003)", + short_description="Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions.", + description="Detect principals who can update the code of existing Lambda functions. By replacing a Lambda function's code with malicious code, the attacker executes arbitrary commands with the privileges of the function's execution role when it is next invoked, either manually or via automatic triggers.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-003 - lambda:UpdateFunctionCode", + link="https://pathfinding.cloud/paths/lambda-003", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-004 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code-invoke", + name="Lambda Function Code Injection with Direct Invocation (LAMBDA-004)", + short_description="Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions.", + description="Detect principals who can update the code of existing Lambda functions and invoke them. By replacing a Lambda function's code with malicious code and invoking it directly, the attacker executes arbitrary commands with the privileges of the function's execution role immediately, without waiting for automatic triggers.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-004 - lambda:UpdateFunctionCode + lambda:InvokeFunction", + link="https://pathfinding.cloud/paths/lambda-004", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:InvokeFunction permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'lambda:invokefunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-005 +AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-update-function-code-add-permission", + name="Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)", + short_description="Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role.", + description="Detect principals who can update the code of existing Lambda functions and add permissions to their resource-based policies. By replacing a Lambda function's code and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with the function's execution role without needing lambda:InvokeFunction as an IAM permission.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-005 - lambda:UpdateFunctionCode + lambda:AddPermission", + link="https://pathfinding.cloud/paths/lambda-005", + ), + provider="aws", + cypher=f""" + // Find principals with lambda:UpdateFunctionCode permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'lambda:updatefunctioncode' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:AddPermission permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'lambda:addpermission' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find existing Lambda functions with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + AND any(resource IN stmt2.resource WHERE + resource = '*' + OR lambda_fn.arn CONTAINS resource + OR resource CONTAINS lambda_fn.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# LAMBDA-006 +AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDefinition( + id="aws-lambda-privesc-passrole-create-function-add-permission", + name="Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)", + short_description="Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions.", + description="Detect principals who can create Lambda functions with privileged IAM roles and add permissions to their resource-based policies. By passing a privileged role to a new Lambda function and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with elevated privileges without needing lambda:InvokeFunction as an IAM permission.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - LAMBDA-006 - iam:PassRole + lambda:CreateFunction + lambda:AddPermission", + link="https://pathfinding.cloud/paths/lambda-006", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find lambda:CreateFunction permission + MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement) + WHERE stmt_create.effect = 'Allow' + AND any(action IN stmt_create.action WHERE + toLower(action) = 'lambda:createfunction' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find lambda:AddPermission permission + MATCH (principal)--(perm_policy:AWSPolicy)--(stmt_perm:AWSPolicyStatement) + WHERE stmt_perm.effect = 'Allow' + AND any(action IN stmt_perm.action WHERE + toLower(action) = 'lambda:addpermission' + OR toLower(action) = 'lambda:*' + OR action = '*' + ) + + // Find roles that trust Lambda service (can be passed to Lambda) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-001 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-notebook", + name="SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)", + short_description="Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment.", + description="Detect principals who can create SageMaker notebook instances with privileged IAM roles. By passing a privileged role to a new notebook instance, the attacker gains shell access through the Jupyter environment and can execute arbitrary commands with the role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-001 - iam:PassRole + sagemaker:CreateNotebookInstance", + link="https://pathfinding.cloud/paths/sagemaker-001", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateNotebookInstance permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-002 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-training-job", + name="SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)", + short_description="Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions.", + description="Detect principals who can create SageMaker training jobs with privileged IAM roles. By passing a privileged role to a new training job with a malicious training script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-002 - iam:PassRole + sagemaker:CreateTrainingJob", + link="https://pathfinding.cloud/paths/sagemaker-002", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateTrainingJob permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createtrainingjob' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-003 +AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-passrole-create-processing-job", + name="SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)", + short_description="Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions.", + description="Detect principals who can create SageMaker processing jobs with privileged IAM roles. By passing a privileged role to a new processing job with a malicious script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-003 - iam:PassRole + sagemaker:CreateProcessingJob", + link="https://pathfinding.cloud/paths/sagemaker-003", + ), + provider="aws", + cypher=f""" + // Find principals with iam:PassRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement) + WHERE stmt_passrole.effect = 'Allow' + AND any(action IN stmt_passrole.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) + + // Find sagemaker:CreateProcessingJob permission + MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement) + WHERE stmt_sm.effect = 'Allow' + AND any(action IN stmt_sm.action WHERE + toLower(action) = 'sagemaker:createprocessingjob' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find roles that trust SageMaker service (can be passed to SageMaker) + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}}) + WHERE any(resource IN stmt_passrole.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-004 +AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-presigned-notebook-url", + name="SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)", + short_description="Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions.", + description="Detect principals who can generate presigned URLs to access existing SageMaker notebook instances. By accessing the Jupyter environment via a presigned URL, the attacker can execute arbitrary code with the permissions of the notebook's execution role without creating any new resources.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-004 - sagemaker:CreatePresignedNotebookInstanceUrl", + link="https://pathfinding.cloud/paths/sagemaker-004", + ), + provider="aws", + cypher=f""" + // Find principals with sagemaker:CreatePresignedNotebookInstanceUrl permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sagemaker:createpresignednotebookinstanceurl' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find existing SageMaker notebook instances with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR notebook.arn CONTAINS resource + OR resource CONTAINS notebook.notebook_instance_name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SAGEMAKER-005 +AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( + id="aws-sagemaker-privesc-lifecycle-config-notebook", + name="SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)", + short_description="Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup.", + description="Detect principals who can inject malicious lifecycle configurations into existing SageMaker notebook instances. By stopping a notebook, attaching a malicious lifecycle config, and restarting it, the attacker executes arbitrary code with the notebook's execution role permissions during startup.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SAGEMAKER-005 - sagemaker:CreateNotebookInstanceLifecycleConfig + sagemaker:StopNotebookInstance + sagemaker:UpdateNotebookInstance + sagemaker:StartNotebookInstance", + link="https://pathfinding.cloud/paths/sagemaker-005", + ), + provider="aws", + cypher=f""" + // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sagemaker:createnotebookinstancelifecycleconfig' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:UpdateNotebookInstance permission + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = 'sagemaker:updatenotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:StopNotebookInstance permission + MATCH (principal)--(policy3:AWSPolicy)--(stmt3:AWSPolicyStatement) + WHERE stmt3.effect = 'Allow' + AND any(action IN stmt3.action WHERE + toLower(action) = 'sagemaker:stopnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find sagemaker:StartNotebookInstance permission + MATCH (principal)--(policy4:AWSPolicy)--(stmt4:AWSPolicyStatement) + WHERE stmt4.effect = 'Allow' + AND any(action IN stmt4.action WHERE + toLower(action) = 'sagemaker:startnotebookinstance' + OR toLower(action) = 'sagemaker:*' + OR action = '*' + ) + + // Find existing SageMaker notebook instances with execution roles + MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole) + WHERE any(resource IN stmt2.resource WHERE + resource = '*' + OR notebook.arn CONTAINS resource + OR resource CONTAINS notebook.notebook_instance_name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSM-001 +AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( + id="aws-ssm-privesc-start-session", + name="SSM Session Access for EC2 Role Credentials (SSM-001)", + short_description="Start an SSM session on an EC2 instance to access its attached role credentials through IMDS.", + description="Detect principals who can start SSM sessions on EC2 instances. This allows establishing a shell session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSM-001 - ssm:StartSession", + link="https://pathfinding.cloud/paths/ssm-001", + ), + provider="aws", + cypher=f""" + // Find principals with ssm:StartSession permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'ssm:startsession' + OR toLower(action) = 'ssm:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# SSM-002 +AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( + id="aws-ssm-privesc-send-command", + name="SSM Send Command for EC2 Role Credentials (SSM-002)", + short_description="Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS.", + description="Detect principals who can send SSM commands to EC2 instances. This allows executing arbitrary commands on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - SSM-002 - ssm:SendCommand", + link="https://pathfinding.cloud/paths/ssm-002", + ), + provider="aws", + cypher=f""" + // Find principals with ssm:SendCommand permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'ssm:sendcommand' + OR toLower(action) = 'ssm:*' + OR action = '*' + ) + + // Find EC2 instances with attached roles (targets for credential theft via IMDS) + MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# STS-001 +AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( + id="aws-sts-privesc-assume-role", + name="Role Assumption for Privilege Escalation (STS-001)", + short_description="Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role.", + description="Detect principals who can assume other IAM roles via sts:AssumeRole. When a principal has sts:AssumeRole permission and the target role's trust policy allows the principal to assume it (bidirectional trust), the attacker gains all permissions of the target role. This enables privilege escalation when the target role has higher privileges than the starting principal.", + attribution=AttackPathsQueryAttribution( + text="pathfinding.cloud - STS-001 - sts:AssumeRole", + link="https://pathfinding.cloud/paths/sts-001", + ), + provider="aws", + cypher=f""" + // Find principals with sts:AssumeRole permission + MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'sts:assumerole' + OR toLower(action) = 'sts:*' + OR action = '*' + ) + + // Find target roles the principal can assume (bidirectional trust via Cartography) + MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) + WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name + ) + + UNWIND nodes(path_principal) + nodes(path_target) as n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + + RETURN path_principal, path_target, + collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + """, + parameters=[], +) + +# AWS Queries List +# ---------------- + +AWS_QUERIES: list[AttackPathsQueryDefinition] = [ + AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS, + AWS_RDS_INSTANCES, + AWS_RDS_UNENCRYPTED_STORAGE, + AWS_S3_ANONYMOUS_ACCESS_BUCKETS, + AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS, + AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY, + AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS, + AWS_EC2_INSTANCES_INTERNET_EXPOSED, + AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING, + AWS_CLASSIC_ELB_INTERNET_EXPOSED, + AWS_ELBV2_INTERNET_EXPOSED, + AWS_PUBLIC_IP_RESOURCE_LOOKUP, + AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE, + AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE, + AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER, + AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK, + AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET, + AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET, + AWS_CLOUDFORMATION_PRIVESC_CHANGESET, + AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT, + AWS_CODEBUILD_PRIVESC_START_BUILD, + AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH, + AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH, + AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE, + AWS_EC2_PRIVESC_PASSROLE_IAM, + AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE, + AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES, + AWS_EC2_PRIVESC_LAUNCH_TEMPLATE, + AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK, + AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER, + AWS_ECS_PRIVESC_EXECUTE_COMMAND, + AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT, + AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT, + AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB, + AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER, + AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB, + AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION, + AWS_IAM_PRIVESC_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY, + AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE, + AWS_IAM_PRIVESC_PUT_USER_POLICY, + AWS_IAM_PRIVESC_ATTACH_USER_POLICY, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY, + AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY, + AWS_IAM_PRIVESC_PUT_GROUP_POLICY, + AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY, + AWS_IAM_PRIVESC_ADD_USER_TO_GROUP, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE, + AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY, + AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE, + AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE, + AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE, + AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION, + AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB, + AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB, + AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL, + AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK, + AWS_SSM_PRIVESC_START_SESSION, + AWS_SSM_PRIVESC_SEND_COMMAND, + AWS_STS_PRIVESC_ASSUME_ROLE, +] diff --git a/api/src/backend/api/attack_paths/queries/registry.py b/api/src/backend/api/attack_paths/queries/registry.py new file mode 100644 index 0000000000..c683b2cb80 --- /dev/null +++ b/api/src/backend/api/attack_paths/queries/registry.py @@ -0,0 +1,25 @@ +from api.attack_paths.queries.types import AttackPathsQueryDefinition +from api.attack_paths.queries.aws import AWS_QUERIES + + +# Query definitions organized by provider +_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { + "aws": AWS_QUERIES, +} + +# Flat lookup by query ID for O(1) access +_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = { + definition.id: definition + for definitions in _QUERY_DEFINITIONS.values() + for definition in definitions +} + + +def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]: + """Get all attack path queries for a specific provider.""" + return _QUERY_DEFINITIONS.get(provider, []) + + +def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None: + """Get a specific attack path query by its ID.""" + return _QUERIES_BY_ID.get(query_id) diff --git a/api/src/backend/api/attack_paths/queries/types.py b/api/src/backend/api/attack_paths/queries/types.py new file mode 100644 index 0000000000..3a70805cd7 --- /dev/null +++ b/api/src/backend/api/attack_paths/queries/types.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field + + +@dataclass +class AttackPathsQueryAttribution: + """Source attribution for an Attack Path query.""" + + text: str + link: str + + +@dataclass +class AttackPathsQueryParameterDefinition: + """ + Metadata describing a parameter that must be provided to an Attack Paths query. + """ + + name: str + label: str + data_type: str = "string" + cast: type = str + description: str | None = None + placeholder: str | None = None + + +@dataclass +class AttackPathsQueryDefinition: + """ + Immutable representation of an Attack Path query. + """ + + id: str + name: str + short_description: str + description: str + provider: str + cypher: str + attribution: AttackPathsQueryAttribution | None = None + parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list) diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py new file mode 100644 index 0000000000..2c70bc6a8e --- /dev/null +++ b/api/src/backend/api/attack_paths/retryable_session.py @@ -0,0 +1,92 @@ +import logging + +from collections.abc import Callable +from typing import Any + +import neo4j +import neo4j.exceptions + +logger = logging.getLogger(__name__) + + +class RetryableSession: + """ + Wrapper around `neo4j.Session` that retries `neo4j.exceptions.ServiceUnavailable` errors. + """ + + def __init__( + self, + session_factory: Callable[[], neo4j.Session], + max_retries: int, + ) -> None: + self._session_factory = session_factory + self._max_retries = max(0, max_retries) + self._session = self._session_factory() + + def close(self) -> None: + if self._session is not None: + self._session.close() + self._session = None + + def __enter__(self) -> "RetryableSession": + return self + + def __exit__( + self, _: Any, __: Any, ___: Any + ) -> None: # Unused args: exc_type, exc, exc_tb + self.close() + + def run(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("run", *args, **kwargs) + + def write_transaction(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("write_transaction", *args, **kwargs) + + def read_transaction(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("read_transaction", *args, **kwargs) + + def execute_write(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("execute_write", *args, **kwargs) + + def execute_read(self, *args: Any, **kwargs: Any) -> Any: + return self._call_with_retry("execute_read", *args, **kwargs) + + def __getattr__(self, item: str) -> Any: + return getattr(self._session, item) + + def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any: + attempt = 0 + last_exc: Exception | None = None + + while attempt <= self._max_retries: + try: + method = getattr(self._session, method_name) + return method(*args, **kwargs) + + except ( + BrokenPipeError, + ConnectionResetError, + neo4j.exceptions.ServiceUnavailable, + ) as exc: # pragma: no cover - depends on infra + last_exc = exc + attempt += 1 + + if attempt > self._max_retries: + raise + + logger.warning( + f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..." + ) + self._refresh_session() + + raise last_exc if last_exc else RuntimeError("Unexpected retry loop exit") + + def _refresh_session(self) -> None: + if self._session is not None: + try: + self._session.close() + except Exception: + # Best-effort close; failures just mean we open a new session below + pass + + self._session = self._session_factory() diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py new file mode 100644 index 0000000000..cb14c7f44b --- /dev/null +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -0,0 +1,148 @@ +import logging + +from typing import Any, Iterable + +from rest_framework.exceptions import APIException, ValidationError + +from api.attack_paths import database as graph_database, AttackPathsQueryDefinition +from api.models import AttackPathsScan +from config.custom_logging import BackendLogger +from tasks.jobs.attack_paths.config import INTERNAL_LABELS + +logger = logging.getLogger(BackendLogger.API) + + +def normalize_run_payload(raw_data): + if not isinstance(raw_data, dict): # Let the serializer handle this + return raw_data + + if "data" in raw_data and isinstance(raw_data.get("data"), dict): + data_section = raw_data.get("data") or {} + attributes = data_section.get("attributes") or {} + payload = { + "id": attributes.get("id", data_section.get("id")), + "parameters": attributes.get("parameters"), + } + + # Remove `None` parameters to allow defaults downstream + if payload.get("parameters") is None: + payload.pop("parameters") + return payload + + return raw_data + + +def prepare_query_parameters( + definition: AttackPathsQueryDefinition, + provided_parameters: dict[str, Any], + provider_uid: str, +) -> dict[str, Any]: + parameters = dict(provided_parameters or {}) + expected_names = {parameter.name for parameter in definition.parameters} + provided_names = set(parameters.keys()) + + unexpected = provided_names - expected_names + if unexpected: + raise ValidationError( + {"parameters": f"Unknown parameter(s): {', '.join(sorted(unexpected))}"} + ) + + missing = expected_names - provided_names + if missing: + raise ValidationError( + { + "parameters": f"Missing required parameter(s): {', '.join(sorted(missing))}" + } + ) + + clean_parameters = { + "provider_uid": str(provider_uid), + } + + for definition_parameter in definition.parameters: + raw_value = provided_parameters[definition_parameter.name] + + try: + casted_value = definition_parameter.cast(raw_value) + + except (ValueError, TypeError) as exc: + raise ValidationError( + { + "parameters": ( + f"Invalid value for parameter `{definition_parameter.name}`: {str(exc)}" + ) + } + ) + + clean_parameters[definition_parameter.name] = casted_value + + return clean_parameters + + +def execute_attack_paths_query( + attack_paths_scan: AttackPathsScan, + definition: AttackPathsQueryDefinition, + parameters: dict[str, Any], +) -> dict[str, Any]: + try: + with graph_database.get_session(attack_paths_scan.graph_database) as session: + result = session.run(definition.cypher, parameters) + return _serialize_graph(result.graph()) + + except graph_database.GraphDatabaseQueryException as exc: + logger.error(f"Query failed for Attack Paths query `{definition.id}`: {exc}") + raise APIException( + "Attack Paths query execution failed due to a database error" + ) + + +def _serialize_graph(graph): + nodes = [] + for node in graph.nodes: + nodes.append( + { + "id": node.element_id, + "labels": _filter_labels(node.labels), + "properties": _serialize_properties(node._properties), + }, + ) + + relationships = [] + for relationship in graph.relationships: + relationships.append( + { + "id": relationship.element_id, + "label": relationship.type, + "source": relationship.start_node.element_id, + "target": relationship.end_node.element_id, + "properties": _serialize_properties(relationship._properties), + }, + ) + + return { + "nodes": nodes, + "relationships": relationships, + } + + +def _filter_labels(labels: Iterable[str]) -> list[str]: + return [label for label in labels if label not in INTERNAL_LABELS] + + +def _serialize_properties(properties: dict[str, Any]) -> dict[str, Any]: + """Convert Neo4j property values into JSON-serializable primitives.""" + + def _serialize_value(value: Any) -> Any: + # Neo4j temporal and spatial values expose `to_native` returning Python primitives + if hasattr(value, "to_native") and callable(value.to_native): + return _serialize_value(value.to_native()) + + if isinstance(value, (list, tuple)): + return [_serialize_value(item) for item in value] + + if isinstance(value, dict): + return {key: _serialize_value(val) for key, val in value.items()} + + return value + + return {key: _serialize_value(val) for key, val in properties.items()} diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 7b644f8c30..1705ed2e8f 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,32 +1,97 @@ -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 = {} -# Map API provider names to Prowler directory names -# This is needed because the OCI provider directory is 'oraclecloud' but the provider type is 'oci' -PROVIDER_NAME_MAPPING = { - "oci": "oraclecloud", -} + +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}])" -def get_prowler_provider_name(provider_type: str) -> str: - """ - Map API provider type to Prowler provider directory name. +class LazyChecksMapping(Mapping): + """Lazy-load checks mapping per provider on first access.""" - Args: - provider_type: The provider type from the API (e.g., 'oci', 'aws', 'azure') + 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] = {} - Returns: - The provider name used in Prowler's directory structure (e.g., 'oraclecloud', 'aws', 'azure') - """ - return PROVIDER_NAME_MAPPING.get(provider_type, provider_type) + 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]: @@ -47,9 +112,8 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s """ global AVAILABLE_COMPLIANCE_FRAMEWORKS if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS: - prowler_provider_name = get_prowler_provider_name(provider_type) AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = ( - get_available_compliance_frameworks(prowler_provider_name) + get_available_compliance_frameworks(provider_type) ) return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] @@ -69,8 +133,7 @@ def get_prowler_provider_checks(provider_type: Provider.ProviderChoices): Returns: Iterable[str]: An iterable of check IDs associated with the specified provider type. """ - prowler_provider_name = get_prowler_provider_name(provider_type) - return CheckMetadata.get_bulk(prowler_provider_name).keys() + return CheckMetadata.get_bulk(provider_type).keys() def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> dict: @@ -88,32 +151,38 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> dict: A dictionary mapping compliance framework names to their respective Compliance objects for the specified provider. """ - prowler_provider_name = get_prowler_provider_name(provider_type) - return Compliance.get_bulk(prowler_provider_name) + 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. @@ -122,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: @@ -166,6 +239,7 @@ def generate_scan_compliance( Returns: None: This function modifies the compliance_overview in place. """ + for compliance_id in PROWLER_CHECKS[provider_type][check_id]: for requirement in compliance_overview[compliance_id]["requirements"].values(): if check_id in requirement["checks"]: @@ -184,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. @@ -194,17 +270,21 @@ def generate_compliance_overview_template(prowler_compliance: dict): counts for requirements status. Args: - prowler_compliance (dict): The compliance data for all provider types, + prowler_compliance (dict): The compliance data for provider types, as returned by `get_prowler_provider_compliance`. + provider_types (Iterable[str] | None): Optional subset of provider types to + process. Defaults to all providers. Returns: dict: A nested dictionary representing the compliance overview template, structured by provider type and compliance framework. """ template = {} - for provider_type in Provider.ProviderChoices.values: + if provider_types is None: + provider_types = Provider.ProviderChoices.values + for provider_type in provider_types: provider_compliance = template.setdefault(provider_type, {}) - compliance_data_dict = prowler_compliance[provider_type] + compliance_data_dict = prowler_compliance.get(provider_type, {}) for compliance_name, compliance_data in compliance_data_dict.items(): compliance_requirements = {} diff --git a/api/src/backend/api/db_router.py b/api/src/backend/api/db_router.py index 76b2ff7452..4ae36cc1a5 100644 --- a/api/src/backend/api/db_router.py +++ b/api/src/backend/api/db_router.py @@ -26,6 +26,7 @@ class MainRouter: default_db = "default" admin_db = "admin" replica_db = "replica" + admin_replica_db = "admin_replica" def db_for_read(self, model, **hints): # noqa: F841 model_table_name = model._meta.db_table @@ -48,8 +49,14 @@ class MainRouter: return db == self.admin_db def allow_relation(self, obj1, obj2, **hints): # noqa: F841 - # Allow relations if both objects are in either "default" or "admin" db connectors - if {obj1._state.db, obj2._state.db} <= {self.default_db, self.admin_db}: + # Allow relations when both objects originate from allowed connectors + allowed_dbs = { + self.default_db, + self.admin_db, + self.replica_db, + self.admin_replica_db, + } + if {obj1._state.db, obj2._state.db} <= allowed_dbs: return True return None diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index 0ef3891728..b719d4b736 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -1,18 +1,34 @@ import re import secrets +import time import uuid from contextlib import contextmanager from datetime import datetime, timedelta, timezone +from celery.utils.log import get_task_logger +from config.env import env from django.conf import settings from django.contrib.auth.models import BaseUserManager -from django.db import DEFAULT_DB_ALIAS, connection, connections, models, transaction +from django.db import ( + DEFAULT_DB_ALIAS, + OperationalError, + connections, + models, + transaction, +) from django_celery_beat.models import PeriodicTask from psycopg2 import connect as psycopg2_connect from psycopg2.extensions import AsIs, new_type, register_adapter, register_type from rest_framework_json_api.serializers import ValidationError -from api.db_router import get_read_db_alias, reset_read_db_alias, set_read_db_alias +from api.db_router import ( + READ_REPLICA_ALIAS, + get_read_db_alias, + reset_read_db_alias, + set_read_db_alias, +) + +logger = get_task_logger(__name__) DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test" DB_PASSWORD = ( @@ -28,6 +44,9 @@ TASK_RUNNER_DB_TABLE = "django_celery_results_taskresult" POSTGRES_TENANT_VAR = "api.tenant_id" POSTGRES_USER_VAR = "api.user_id" +REPLICA_MAX_ATTEMPTS = env.int("POSTGRES_REPLICA_MAX_ATTEMPTS", default=3) +REPLICA_RETRY_BASE_DELAY = env.float("POSTGRES_REPLICA_RETRY_BASE_DELAY", default=0.5) + SET_CONFIG_QUERY = "SELECT set_config(%s, %s::text, TRUE);" @@ -71,24 +90,51 @@ def rls_transaction( if db_alias not in connections: db_alias = DEFAULT_DB_ALIAS - router_token = None - try: - if db_alias != DEFAULT_DB_ALIAS: - router_token = set_read_db_alias(db_alias) + alias = db_alias + is_replica = READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS + max_attempts = REPLICA_MAX_ATTEMPTS if is_replica else 1 - with transaction.atomic(using=db_alias): - conn = connections[db_alias] - with conn.cursor() as cursor: - try: - # just in case the value is a UUID object - uuid.UUID(str(value)) - except ValueError: - raise ValidationError("Must be a valid UUID") - cursor.execute(SET_CONFIG_QUERY, [parameter, value]) - yield cursor - finally: - if router_token is not None: - reset_read_db_alias(router_token) + for attempt in range(1, max_attempts + 1): + router_token = None + + # On final attempt, fallback to primary + if attempt == max_attempts and is_replica: + logger.warning( + f"RLS transaction failed after {attempt - 1} attempts on replica, " + f"falling back to primary DB" + ) + alias = DEFAULT_DB_ALIAS + + conn = connections[alias] + try: + if alias != DEFAULT_DB_ALIAS: + router_token = set_read_db_alias(alias) + + with transaction.atomic(using=alias): + with conn.cursor() as cursor: + try: + # just in case the value is a UUID object + uuid.UUID(str(value)) + except ValueError: + raise ValidationError("Must be a valid UUID") + cursor.execute(SET_CONFIG_QUERY, [parameter, value]) + yield cursor + return + except OperationalError as e: + # If on primary or max attempts reached, raise + if not is_replica or attempt == max_attempts: + raise + + # Retry with exponential backoff + delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1)) + logger.info( + f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), " + f"retrying in {delay}s. Error: {e}" + ) + time.sleep(delay) + finally: + if router_token is not None: + reset_read_db_alias(router_token) class CustomUserManager(BaseUserManager): @@ -403,7 +449,7 @@ def create_index_on_partitions( all_partitions=True ) """ - with connection.cursor() as cursor: + with schema_editor.connection.cursor() as cursor: cursor.execute( """ SELECT inhrelid::regclass::text @@ -415,6 +461,7 @@ def create_index_on_partitions( partitions = [row[0] for row in cursor.fetchall()] where_sql = f" WHERE {where}" if where else "" + conn = schema_editor.connection for partition in partitions: if _should_create_index_on_partition(partition, all_partitions): idx_name = f"{partition.replace('.', '_')}_{index_name}" @@ -423,7 +470,12 @@ def create_index_on_partitions( f"ON {partition} USING {method} ({columns})" f"{where_sql};" ) - schema_editor.execute(sql) + old_autocommit = conn.connection.autocommit + conn.connection.autocommit = True + try: + schema_editor.execute(sql) + finally: + conn.connection.autocommit = old_autocommit def drop_index_on_partitions( @@ -439,7 +491,8 @@ def drop_index_on_partitions( parent_table: The name of the root table (e.g. "findings"). index_name: The same short name used when creating them. """ - with connection.cursor() as cursor: + conn = schema_editor.connection + with conn.cursor() as cursor: cursor.execute( """ SELECT inhrelid::regclass::text @@ -453,7 +506,12 @@ def drop_index_on_partitions( for partition in partitions: idx_name = f"{partition.replace('.', '_')}_{index_name}" sql = f"DROP INDEX CONCURRENTLY IF EXISTS {idx_name};" - schema_editor.execute(sql) + old_autocommit = conn.connection.autocommit + conn.connection.autocommit = True + try: + schema_editor.execute(sql) + finally: + conn.connection.autocommit = old_autocommit def generate_api_key_prefix(): diff --git a/api/src/backend/api/decorators.py b/api/src/backend/api/decorators.py index b74d12df5d..d2330a6a06 100644 --- a/api/src/backend/api/decorators.py +++ b/api/src/backend/api/decorators.py @@ -1,10 +1,14 @@ import uuid from functools import wraps -from django.db import connection, transaction +from django.core.exceptions import ObjectDoesNotExist +from django.db import IntegrityError, connection, transaction from rest_framework_json_api.serializers import ValidationError -from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction +from api.exceptions import ProviderDeletedException +from api.models import Provider, Scan def set_tenant(func=None, *, keep_tenant=False): @@ -66,3 +70,49 @@ def set_tenant(func=None, *, keep_tenant=False): return decorator else: return decorator(func) + + +def handle_provider_deletion(func): + """ + Decorator that raises ProviderDeletedException if provider was deleted during execution. + + Catches ObjectDoesNotExist and IntegrityError, checks if provider still exists, + and raises ProviderDeletedException if not. Otherwise, re-raises original exception. + + Requires tenant_id and provider_id in kwargs. + + Example: + @shared_task + @handle_provider_deletion + def scan_task(scan_id, tenant_id, provider_id): + ... + """ + + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (ObjectDoesNotExist, IntegrityError): + tenant_id = kwargs.get("tenant_id") + provider_id = kwargs.get("provider_id") + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + if provider_id is None: + scan_id = kwargs.get("scan_id") + if scan_id is None: + raise AssertionError( + "This task does not have provider or scan in the kwargs" + ) + scan = Scan.objects.filter(pk=scan_id).first() + if scan is None: + raise ProviderDeletedException( + f"Provider for scan '{scan_id}' was deleted during the scan" + ) from None + provider_id = str(scan.provider_id) + if not Provider.objects.filter(pk=provider_id).exists(): + raise ProviderDeletedException( + f"Provider '{provider_id}' was deleted during the scan" + ) from None + raise + + return wrapper diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index d1cf7d78ce..78f8c64c7d 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -66,6 +66,10 @@ class ProviderConnectionError(Exception): """Base exception for provider connection errors.""" +class ProviderDeletedException(Exception): + """Raised when a provider has been deleted during scan/task execution.""" + + def custom_exception_handler(exc, context): if isinstance(exc, django_validation_error): if hasattr(exc, "error_dict"): @@ -103,3 +107,105 @@ class ConflictException(APIException): error_detail["source"] = {"pointer": pointer} super().__init__(detail=[error_detail]) + + +# Upstream Provider Errors (for external API calls like CloudTrail) +# These indicate issues with the provider, not with the user's API authentication + + +class UpstreamAuthenticationError(APIException): + """Provider credentials are invalid or expired (502 Bad Gateway). + + Used when AWS/Azure/GCP credentials fail to authenticate with the upstream + provider. This is NOT the user's API authentication failing. + """ + + status_code = status.HTTP_502_BAD_GATEWAY + default_detail = ( + "Provider credentials are invalid or expired. Please reconnect the provider." + ) + default_code = "upstream_auth_failed" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + +class UpstreamAccessDeniedError(APIException): + """Provider credentials lack required permissions (502 Bad Gateway). + + Used when credentials are valid but don't have the IAM permissions + needed for the requested operation (e.g., cloudtrail:LookupEvents). + This is 502 (not 403) because it's an upstream/gateway error - the USER + authenticated fine, but the PROVIDER's credentials are misconfigured. + """ + + status_code = status.HTTP_502_BAD_GATEWAY + default_detail = ( + "Access denied. The provider credentials do not have the required permissions." + ) + default_code = "upstream_access_denied" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + +class UpstreamServiceUnavailableError(APIException): + """Provider service is unavailable (503 Service Unavailable). + + Used when the upstream provider API returns an error or is unreachable. + """ + + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = "Unable to communicate with the provider. Please try again later." + default_code = "service_unavailable" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + +class UpstreamInternalError(APIException): + """Unexpected error communicating with provider (500 Internal Server Error). + + Used as a catch-all for unexpected errors during provider communication. + """ + + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = ( + "An unexpected error occurred while communicating with the provider." + ) + default_code = "internal_error" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index ef1387f0bd..bf34950156 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -23,29 +23,37 @@ from api.db_utils import ( StatusEnumField, ) from api.models import ( + AttackSurfaceOverview, ComplianceRequirementOverview, + DailySeveritySummary, Finding, Integration, Invitation, + AttackPathsScan, LighthouseProviderConfiguration, LighthouseProviderModels, Membership, + MuteRule, OverviewStatusChoices, PermissionChoices, Processor, Provider, + ProviderComplianceScore, ProviderGroup, ProviderSecret, Resource, ResourceTag, Role, Scan, + ScanCategorySummary, + ScanGroupSummary, ScanSummary, SeverityChoices, StateChoices, StatusChoices, Task, TenantAPIKey, + ThreatScoreSnapshot, User, ) from api.rls import Tenant @@ -87,10 +95,62 @@ class ChoiceInFilter(BaseInFilter, ChoiceFilter): pass +class BaseProviderFilter(FilterSet): + """ + Abstract base filter for models with direct FK to Provider. + + Provides standard provider_id and provider_type filters. + Subclasses must define Meta.model. + """ + + 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 + ) + provider_type__in = ChoiceInFilter( + field_name="provider__provider", + choices=Provider.ProviderChoices.choices, + lookup_expr="in", + ) + + class Meta: + abstract = True + fields = {} + + +class BaseScanProviderFilter(FilterSet): + """ + Abstract base filter for models with FK to Scan (and Scan has FK to Provider). + + Provides standard provider_id and provider_type filters via scan relationship. + Subclasses must define Meta.model. + """ + + provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in") + provider_type = ChoiceFilter( + field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices + ) + provider_type__in = ChoiceInFilter( + field_name="scan__provider__provider", + choices=Provider.ProviderChoices.choices, + lookup_expr="in", + ) + + class Meta: + abstract = True + fields = {} + + class CommonFindingFilters(FilterSet): # We filter providers from the scan in findings + # Both 'provider' and 'provider_id' parameters are supported for API consistency + # Frontend uses 'provider_id' uniformly across all endpoints provider = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") provider__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in") + provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in") provider_type = ChoiceFilter( choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider" ) @@ -153,6 +213,12 @@ class CommonFindingFilters(FilterSet): field_name="resources__type", lookup_expr="icontains" ) + category = CharFilter(method="filter_category") + category__in = CharInFilter(field_name="categories", lookup_expr="overlap") + + resource_groups = CharFilter(field_name="resource_groups", lookup_expr="exact") + resource_groups__in = CharInFilter(field_name="resource_groups", lookup_expr="in") + # Temporarily disabled until we implement tag filtering in the UI # resource_tag_key = CharFilter(field_name="resources__tags__key") # resource_tag_key__in = CharInFilter( @@ -184,6 +250,9 @@ class CommonFindingFilters(FilterSet): def filter_resource_type(self, queryset, name, value): return queryset.filter(resource_types__contains=[value]) + def filter_category(self, queryset, name, value): + return queryset.filter(categories__contains=[value]) + def filter_resource_tag(self, queryset, name, value): overall_query = Q() for key_value_pair in value: @@ -328,6 +397,23 @@ class ScanFilter(ProviderRelationshipFilterSet): } +class AttackPathsScanFilter(ProviderRelationshipFilterSet): + inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") + completed_at = DateFilter(field_name="completed_at", lookup_expr="date") + started_at = DateFilter(field_name="started_at", lookup_expr="date") + state = ChoiceFilter(choices=StateChoices.choices) + state__in = ChoiceInFilter( + field_name="state", choices=StateChoices.choices, lookup_expr="in" + ) + + class Meta: + model = AttackPathsScan + fields = { + "provider": ["exact", "in"], + "scan": ["exact", "in"], + } + + class TaskFilter(FilterSet): name = CharFilter(field_name="task_runner_task__task_name", lookup_expr="exact") name__icontains = CharFilter( @@ -367,6 +453,8 @@ class ResourceTagFilter(FilterSet): class ResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") @@ -375,6 +463,8 @@ class ResourceFilter(ProviderRelationshipFilterSet): updated_at = DateFilter(field_name="updated_at", lookup_expr="date") scan = UUIDFilter(field_name="provider__scan", lookup_expr="exact") scan__in = UUIDInFilter(field_name="provider__scan", lookup_expr="in") + groups = CharFilter(method="filter_groups") + groups__in = CharInFilter(field_name="groups", lookup_expr="overlap") class Meta: model = Resource @@ -389,6 +479,9 @@ class ResourceFilter(ProviderRelationshipFilterSet): "updated_at": ["gte", "lte"], } + def filter_groups(self, queryset, name, value): + return queryset.filter(groups__contains=[value]) + def filter_queryset(self, queryset): if not (self.data.get("scan") or self.data.get("scan__in")) and not ( self.data.get("updated_at") @@ -449,10 +542,14 @@ class ResourceFilter(ProviderRelationshipFilterSet): class LatestResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") tags = CharFilter(method="filter_tag") + groups = CharFilter(method="filter_groups") + groups__in = CharInFilter(field_name="groups", lookup_expr="overlap") class Meta: model = Resource @@ -465,6 +562,9 @@ class LatestResourceFilter(ProviderRelationshipFilterSet): "type": ["exact", "icontains", "in"], } + def filter_groups(self, queryset, name, value): + return queryset.filter(groups__contains=[value]) + def filter_tag_key(self, queryset, name, value): return queryset.filter(Q(tags__key=value) | Q(tags__key__icontains=value)) @@ -758,7 +858,7 @@ class RoleFilter(FilterSet): class ComplianceOverviewFilter(FilterSet): inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - scan_id = UUIDFilter(field_name="scan_id") + scan_id = UUIDFilter(field_name="scan_id", required=True) region = CharFilter(field_name="region") class Meta: @@ -792,6 +892,68 @@ class ScanSummaryFilter(FilterSet): } +class DailySeveritySummaryFilter(FilterSet): + """Filter for findings_severity/timeseries endpoint.""" + + MAX_DATE_RANGE_DAYS = 365 + + 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 + ) + provider_type__in = ChoiceInFilter( + field_name="provider__provider", choices=Provider.ProviderChoices.choices + ) + date_from = DateFilter(method="filter_noop") + date_to = DateFilter(method="filter_noop") + + class Meta: + model = DailySeveritySummary + fields = ["provider_id"] + + def filter_noop(self, queryset, name, value): + return queryset + + def filter_queryset(self, queryset): + if not self.data.get("date_from"): + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": "400", + "source": {"pointer": "filter[date_from]"}, + "code": "required", + } + ] + ) + + today = date.today() + date_from = self.form.cleaned_data.get("date_from") + date_to = min(self.form.cleaned_data.get("date_to") or today, today) + + if (date_to - date_from).days > self.MAX_DATE_RANGE_DAYS: + raise ValidationError( + [ + { + "detail": f"Date range cannot exceed {self.MAX_DATE_RANGE_DAYS} days.", + "status": "400", + "source": {"pointer": "filter[date_from]"}, + "code": "invalid", + } + ] + ) + + # View access + self.request._date_from = date_from + self.request._date_to = date_to + + # Apply date filter (only lte for fill-forward logic) + queryset = queryset.filter(date__lte=date_to) + + return super().filter_queryset(queryset) + + class ScanSummarySeverityFilter(ScanSummaryFilter): """Filter for findings_severity ScanSummary endpoint - includes status filters""" @@ -810,7 +972,8 @@ class ScanSummarySeverityFilter(ScanSummaryFilter): elif value == OverviewStatusChoices.PASS: return queryset.annotate(status_count=F("_pass")) else: - return queryset.annotate(status_count=F("total")) + # Exclude muted findings by default + return queryset.annotate(status_count=F("_pass") + F("fail")) def filter_status_in(self, queryset, name, value): # Validate the status values @@ -819,7 +982,7 @@ class ScanSummarySeverityFilter(ScanSummaryFilter): if status_val not in valid_statuses: raise ValidationError(f"Invalid status value: {status_val}") - # If all statuses or no valid statuses, use total + # If all statuses or no valid statuses, exclude muted findings (pass + fail) if ( set(value) >= { @@ -828,7 +991,7 @@ class ScanSummarySeverityFilter(ScanSummaryFilter): } or not value ): - return queryset.annotate(status_count=F("total")) + return queryset.annotate(status_count=F("_pass") + F("fail")) # Build the sum expression based on status values sum_expression = None @@ -846,7 +1009,7 @@ class ScanSummarySeverityFilter(ScanSummaryFilter): sum_expression = sum_expression + field_expr if sum_expression is None: - return queryset.annotate(status_count=F("total")) + return queryset.annotate(status_count=F("_pass") + F("fail")) return queryset.annotate(status_count=sum_expression) @@ -858,26 +1021,6 @@ class ScanSummarySeverityFilter(ScanSummaryFilter): } -class ServiceOverviewFilter(ScanSummaryFilter): - def is_valid(self): - # Check if at least one of the inserted_at filters is present - inserted_at_filters = [ - self.data.get("inserted_at"), - self.data.get("inserted_at__gte"), - self.data.get("inserted_at__lte"), - ] - if not any(inserted_at_filters): - raise ValidationError( - { - "inserted_at": [ - "At least one of filter[inserted_at], filter[inserted_at__gte], or " - "filter[inserted_at__lte] is required." - ] - } - ) - return super().is_valid() - - class IntegrationFilter(FilterSet): inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") integration_type = ChoiceFilter(choices=Integration.IntegrationChoices.choices) @@ -980,3 +1123,97 @@ class LighthouseProviderModelsFilter(FilterSet): fields = { "model_id": ["exact", "icontains", "in"], } + + +class MuteRuleFilter(FilterSet): + inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") + updated_at = DateFilter(field_name="updated_at", lookup_expr="date") + created_by = UUIDFilter(field_name="created_by__id", lookup_expr="exact") + + class Meta: + model = MuteRule + fields = { + "id": ["exact", "in"], + "name": ["exact", "icontains"], + "reason": ["icontains"], + "enabled": ["exact"], + "inserted_at": ["gte", "lte"], + "updated_at": ["gte", "lte"], + } + + +class ThreatScoreSnapshotFilter(FilterSet): + """ + Filter for ThreatScore snapshots. + Allows filtering by scan, provider, compliance_id, and date ranges. + """ + + inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") + scan_id = UUIDFilter(field_name="scan__id", lookup_expr="exact") + scan_id__in = UUIDInFilter(field_name="scan__id", lookup_expr="in") + 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 + ) + provider_type__in = ChoiceInFilter( + field_name="provider__provider", + choices=Provider.ProviderChoices.choices, + lookup_expr="in", + ) + compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact") + compliance_id__in = CharInFilter(field_name="compliance_id", lookup_expr="in") + + class Meta: + model = ThreatScoreSnapshot + fields = { + "scan": ["exact", "in"], + "provider": ["exact", "in"], + "compliance_id": ["exact", "in"], + "inserted_at": ["date", "gte", "lte"], + "overall_score": ["exact", "gte", "lte"], + } + + +class AttackSurfaceOverviewFilter(BaseScanProviderFilter): + """Filter for attack surface overview aggregations by provider.""" + + class Meta(BaseScanProviderFilter.Meta): + model = AttackSurfaceOverview + + +class CategoryOverviewFilter(BaseScanProviderFilter): + """Filter for category overview aggregations by provider.""" + + category = CharFilter(field_name="category", lookup_expr="exact") + category__in = CharInFilter(field_name="category", lookup_expr="in") + + class Meta(BaseScanProviderFilter.Meta): + model = ScanCategorySummary + fields = {} + + +class ResourceGroupOverviewFilter(FilterSet): + provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in") + provider_type = ChoiceFilter( + field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices + ) + provider_type__in = ChoiceInFilter( + field_name="scan__provider__provider", + choices=Provider.ProviderChoices.choices, + lookup_expr="in", + ) + resource_group = CharFilter(field_name="resource_group", lookup_expr="exact") + resource_group__in = CharInFilter(field_name="resource_group", lookup_expr="in") + + class Meta: + model = ScanGroupSummary + fields = {} + + +class ComplianceWatchlistFilter(BaseProviderFilter): + """Filter for compliance watchlist overview by provider.""" + + class Meta(BaseProviderFilter.Meta): + model = ProviderComplianceScore diff --git a/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json b/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json new file mode 100644 index 0000000000..fdf310458a --- /dev/null +++ b/api/src/backend/api/fixtures/dev/8_dev_attack_paths_scans.json @@ -0,0 +1,41 @@ +[ + { + "model": "api.attackpathsscan", + "pk": "a7f0f6de-6f8e-4b3a-8cbe-3f6dd9012345", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "provider": "b85601a8-4b45-4194-8135-03fb980ef428", + "scan": "01920573-aa9c-73c9-bcda-f2e35c9b19d2", + "state": "completed", + "progress": 100, + "update_tag": 1693586667, + "graph_database": "db-a7f0f6de-6f8e-4b3a-8cbe-3f6dd9012345", + "is_graph_database_deleted": false, + "task": null, + "inserted_at": "2024-09-01T17:24:37Z", + "updated_at": "2024-09-01T17:44:37Z", + "started_at": "2024-09-01T17:34:37Z", + "completed_at": "2024-09-01T17:44:37Z", + "duration": 269, + "ingestion_exceptions": {} + } + }, + { + "model": "api.attackpathsscan", + "pk": "4a2fb2af-8a60-4d7d-9cae-4ca65e098765", + "fields": { + "tenant": "12646005-9067-4d2a-a098-8bb378604362", + "provider": "15fce1fa-ecaa-433f-a9dc-62553f3a2555", + "scan": "01929f3b-ed2e-7623-ad63-7c37cd37828f", + "state": "executing", + "progress": 48, + "update_tag": 1697625000, + "graph_database": "db-4a2fb2af-8a60-4d7d-9cae-4ca65e098765", + "is_graph_database_deleted": false, + "task": null, + "inserted_at": "2024-10-18T10:55:57Z", + "updated_at": "2024-10-18T10:56:15Z", + "started_at": "2024-10-18T10:56:05Z" + } + } +] diff --git a/api/src/backend/api/migrations/0051_oraclecloud_provider.py b/api/src/backend/api/migrations/0051_oraclecloud_provider.py index fd96dfbcd5..022c022ea6 100644 --- a/api/src/backend/api/migrations/0051_oraclecloud_provider.py +++ b/api/src/backend/api/migrations/0051_oraclecloud_provider.py @@ -22,13 +22,13 @@ class Migration(migrations.Migration): ("kubernetes", "Kubernetes"), ("m365", "M365"), ("github", "GitHub"), - ("oci", "Oracle Cloud Infrastructure"), + ("oraclecloud", "Oracle Cloud Infrastructure"), ], default="aws", ), ), migrations.RunSQL( - "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'oci';", + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'oraclecloud';", reverse_sql=migrations.RunSQL.noop, ), ] diff --git a/api/src/backend/api/migrations/0052_mute_rules.py b/api/src/backend/api/migrations/0052_mute_rules.py new file mode 100644 index 0000000000..56a3ff516f --- /dev/null +++ b/api/src/backend/api/migrations/0052_mute_rules.py @@ -0,0 +1,117 @@ +# Generated by Django 5.1.13 on 2025-10-22 11:56 + +import uuid + +import django.contrib.postgres.fields +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0051_oraclecloud_provider"), + ] + + operations = [ + migrations.CreateModel( + name="MuteRule", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "name", + models.CharField( + help_text="Human-readable name for this rule", + max_length=100, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ( + "reason", + models.TextField( + help_text="Reason for muting", + max_length=500, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ( + "enabled", + models.BooleanField( + default=True, help_text="Whether this rule is currently enabled" + ), + ), + ( + "finding_uids", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=255), + help_text="List of finding UIDs to mute", + size=None, + ), + ), + ], + options={ + "db_table": "mute_rules", + "abstract": False, + }, + ), + migrations.AddField( + model_name="finding", + name="muted_at", + field=models.DateTimeField( + blank=True, help_text="Timestamp when this finding was muted", null=True + ), + ), + migrations.AlterField( + model_name="tenantapikey", + name="name", + field=models.CharField( + max_length=100, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + migrations.AddField( + model_name="muterule", + name="created_by", + field=models.ForeignKey( + help_text="User who created this rule", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_mute_rules", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="muterule", + name="tenant", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + migrations.AddConstraint( + model_name="muterule", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_muterule", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + migrations.AddConstraint( + model_name="muterule", + constraint=models.UniqueConstraint( + fields=("tenant_id", "name"), name="unique_mute_rule_name_per_tenant" + ), + ), + ] diff --git a/api/src/backend/api/migrations/0053_lighthouse_bedrock_openai_compatible.py b/api/src/backend/api/migrations/0053_lighthouse_bedrock_openai_compatible.py new file mode 100644 index 0000000000..d7054d6443 --- /dev/null +++ b/api/src/backend/api/migrations/0053_lighthouse_bedrock_openai_compatible.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.12 on 2025-10-14 11:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0052_mute_rules"), + ] + + operations = [ + migrations.AlterField( + model_name="lighthouseproviderconfiguration", + name="provider_type", + field=models.CharField( + choices=[ + ("openai", "OpenAI"), + ("bedrock", "AWS Bedrock"), + ("openai_compatible", "OpenAI Compatible"), + ], + help_text="LLM provider name", + max_length=50, + ), + ) + ] diff --git a/api/src/backend/api/migrations/0054_iac_provider.py b/api/src/backend/api/migrations/0054_iac_provider.py new file mode 100644 index 0000000000..03c29e33b6 --- /dev/null +++ b/api/src/backend/api/migrations/0054_iac_provider.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.10 on 2025-09-09 09:25 + +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0053_lighthouse_bedrock_openai_compatible"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("iac", "IaC"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'iac';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0055_mongodbatlas_provider.py b/api/src/backend/api/migrations/0055_mongodbatlas_provider.py new file mode 100644 index 0000000000..d250a0bffd --- /dev/null +++ b/api/src/backend/api/migrations/0055_mongodbatlas_provider.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.13 on 2025-11-05 08:37 + +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0054_iac_provider"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'mongodbatlas';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0056_remove_provider_unique_provider_uids_and_more.py b/api/src/backend/api/migrations/0056_remove_provider_unique_provider_uids_and_more.py new file mode 100644 index 0000000000..14cbc4fa47 --- /dev/null +++ b/api/src/backend/api/migrations/0056_remove_provider_unique_provider_uids_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.13 on 2025-11-06 09:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0055_mongodbatlas_provider"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="provider", + name="unique_provider_uids", + ), + migrations.AddConstraint( + model_name="provider", + constraint=models.UniqueConstraint( + condition=models.Q(("is_deleted", False)), + fields=("tenant_id", "provider", "uid"), + name="unique_provider_uids", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0057_threatscoresnapshot.py b/api/src/backend/api/migrations/0057_threatscoresnapshot.py new file mode 100644 index 0000000000..ee3530a5b6 --- /dev/null +++ b/api/src/backend/api/migrations/0057_threatscoresnapshot.py @@ -0,0 +1,170 @@ +# Generated by Django 5.1.13 on 2025-10-31 09:04 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0056_remove_provider_unique_provider_uids_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ThreatScoreSnapshot", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ( + "compliance_id", + models.CharField( + help_text="Compliance framework ID (e.g., 'prowler_threatscore_aws')", + max_length=100, + ), + ), + ( + "overall_score", + models.DecimalField( + decimal_places=2, + help_text="Overall ThreatScore percentage (0-100)", + max_digits=5, + ), + ), + ( + "score_delta", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Score change compared to previous snapshot (positive = improvement)", + max_digits=5, + null=True, + ), + ), + ( + "section_scores", + models.JSONField( + blank=True, + default=dict, + help_text="ThreatScore breakdown by section", + ), + ), + ( + "critical_requirements", + models.JSONField( + blank=True, + default=list, + help_text="List of critical failed requirements (risk >= 4)", + ), + ), + ( + "total_requirements", + models.IntegerField( + default=0, help_text="Total number of requirements evaluated" + ), + ), + ( + "passed_requirements", + models.IntegerField( + default=0, help_text="Number of requirements with PASS status" + ), + ), + ( + "failed_requirements", + models.IntegerField( + default=0, help_text="Number of requirements with FAIL status" + ), + ), + ( + "manual_requirements", + models.IntegerField( + default=0, help_text="Number of requirements with MANUAL status" + ), + ), + ( + "total_findings", + models.IntegerField( + default=0, + help_text="Total number of findings across all requirements", + ), + ), + ( + "passed_findings", + models.IntegerField( + default=0, help_text="Number of findings with PASS status" + ), + ), + ( + "failed_findings", + models.IntegerField( + default=0, help_text="Number of findings with FAIL status" + ), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="threatscore_snapshots", + related_query_name="threatscore_snapshot", + to="api.provider", + ), + ), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="threatscore_snapshots", + related_query_name="threatscore_snapshot", + to="api.scan", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "threatscore_snapshots", + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="threatscoresnapshot", + index=models.Index( + fields=["tenant_id", "scan_id"], name="threatscore_snap_t_scan_idx" + ), + ), + migrations.AddIndex( + model_name="threatscoresnapshot", + index=models.Index( + fields=["tenant_id", "provider_id"], name="threatscore_snap_t_prov_idx" + ), + ), + migrations.AddIndex( + model_name="threatscoresnapshot", + index=models.Index( + fields=["tenant_id", "inserted_at"], name="threatscore_snap_t_time_idx" + ), + ), + migrations.AddConstraint( + model_name="threatscoresnapshot", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_threatscoresnapshot", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0058_drop_redundant_compliance_requirement_indexes.py b/api/src/backend/api/migrations/0058_drop_redundant_compliance_requirement_indexes.py new file mode 100644 index 0000000000..b981d66ba8 --- /dev/null +++ b/api/src/backend/api/migrations/0058_drop_redundant_compliance_requirement_indexes.py @@ -0,0 +1,29 @@ +from django.contrib.postgres.operations import RemoveIndexConcurrently +from django.db import migrations + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0057_threatscoresnapshot"), + ] + + operations = [ + RemoveIndexConcurrently( + model_name="compliancerequirementoverview", + name="cro_tenant_scan_idx", + ), + RemoveIndexConcurrently( + model_name="compliancerequirementoverview", + name="cro_scan_comp_idx", + ), + RemoveIndexConcurrently( + model_name="compliancerequirementoverview", + name="cro_scan_comp_req_idx", + ), + RemoveIndexConcurrently( + model_name="compliancerequirementoverview", + name="cro_scan_comp_req_reg_idx", + ), + ] diff --git a/api/src/backend/api/migrations/0059_compliance_overview_summary.py b/api/src/backend/api/migrations/0059_compliance_overview_summary.py new file mode 100644 index 0000000000..d2d57a34ae --- /dev/null +++ b/api/src/backend/api/migrations/0059_compliance_overview_summary.py @@ -0,0 +1,75 @@ +# Generated by Django 5.1.13 on 2025-10-30 15:23 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0058_drop_redundant_compliance_requirement_indexes"), + ] + + operations = [ + migrations.CreateModel( + name="ComplianceOverviewSummary", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("compliance_id", models.TextField()), + ("requirements_passed", models.IntegerField(default=0)), + ("requirements_failed", models.IntegerField(default=0)), + ("requirements_manual", models.IntegerField(default=0)), + ("total_requirements", models.IntegerField(default=0)), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="compliance_summaries", + related_query_name="compliance_summary", + to="api.scan", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "compliance_overview_summaries", + "abstract": False, + "indexes": [ + models.Index( + fields=["tenant_id", "scan_id"], name="cos_tenant_scan_idx" + ) + ], + "constraints": [ + models.UniqueConstraint( + fields=("tenant_id", "scan_id", "compliance_id"), + name="unique_compliance_summary_per_scan", + ) + ], + }, + ), + migrations.AddConstraint( + model_name="complianceoverviewsummary", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_complianceoverviewsummary", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0060_attack_surface_overview.py b/api/src/backend/api/migrations/0060_attack_surface_overview.py new file mode 100644 index 0000000000..8007d49a70 --- /dev/null +++ b/api/src/backend/api/migrations/0060_attack_surface_overview.py @@ -0,0 +1,89 @@ +# Generated by Django 5.1.14 on 2025-11-19 13:03 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0059_compliance_overview_summary"), + ] + + operations = [ + migrations.CreateModel( + name="AttackSurfaceOverview", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ( + "attack_surface_type", + models.CharField( + choices=[ + ("internet-exposed", "Internet Exposed"), + ("secrets", "Exposed Secrets"), + ("privilege-escalation", "Privilege Escalation"), + ("ec2-imdsv1", "EC2 IMDSv1 Enabled"), + ], + max_length=50, + ), + ), + ("total_findings", models.IntegerField(default=0)), + ("failed_findings", models.IntegerField(default=0)), + ("muted_failed_findings", models.IntegerField(default=0)), + ], + options={ + "db_table": "attack_surface_overviews", + "abstract": False, + }, + ), + migrations.AddField( + model_name="attacksurfaceoverview", + name="scan", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attack_surface_overviews", + related_query_name="attack_surface_overview", + to="api.scan", + ), + ), + migrations.AddField( + model_name="attacksurfaceoverview", + name="tenant", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + migrations.AddIndex( + model_name="attacksurfaceoverview", + index=models.Index( + fields=["tenant_id", "scan_id"], name="attack_surf_tenant_scan_idx" + ), + ), + migrations.AddConstraint( + model_name="attacksurfaceoverview", + constraint=models.UniqueConstraint( + fields=("tenant_id", "scan_id", "attack_surface_type"), + name="unique_attack_surface_per_scan", + ), + ), + migrations.AddConstraint( + model_name="attacksurfaceoverview", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_attacksurfaceoverview", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0061_daily_severity_summary.py b/api/src/backend/api/migrations/0061_daily_severity_summary.py new file mode 100644 index 0000000000..7e4074cf7f --- /dev/null +++ b/api/src/backend/api/migrations/0061_daily_severity_summary.py @@ -0,0 +1,96 @@ +# Generated by Django 5.1.14 on 2025-12-03 13:38 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0060_attack_surface_overview"), + ] + + operations = [ + migrations.CreateModel( + name="DailySeveritySummary", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("date", models.DateField()), + ("critical", models.IntegerField(default=0)), + ("high", models.IntegerField(default=0)), + ("medium", models.IntegerField(default=0)), + ("low", models.IntegerField(default=0)), + ("informational", models.IntegerField(default=0)), + ("muted", models.IntegerField(default=0)), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="daily_severity_summaries", + related_query_name="daily_severity_summary", + to="api.provider", + ), + ), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="daily_severity_summaries", + related_query_name="daily_severity_summary", + to="api.scan", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.tenant", + ), + ), + ], + options={ + "db_table": "daily_severity_summaries", + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="dailyseveritysummary", + index=models.Index( + fields=["tenant_id", "id"], + name="dss_tenant_id_idx", + ), + ), + migrations.AddIndex( + model_name="dailyseveritysummary", + index=models.Index( + fields=["tenant_id", "provider_id"], + name="dss_tenant_provider_idx", + ), + ), + migrations.AddConstraint( + model_name="dailyseveritysummary", + constraint=models.UniqueConstraint( + fields=("tenant_id", "provider", "date"), + name="unique_daily_severity_summary", + ), + ), + migrations.AddConstraint( + model_name="dailyseveritysummary", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_dailyseveritysummary", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py b/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py new file mode 100644 index 0000000000..f893ac4305 --- /dev/null +++ b/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.14 on 2025-12-10 + +from django.db import migrations +from tasks.tasks import backfill_daily_severity_summaries_task + +from api.db_router import MainRouter +from api.rls import Tenant + + +def trigger_backfill_task(apps, schema_editor): + """ + Trigger the backfill task for all tenants. + + This dispatches backfill_daily_severity_summaries_task for each tenant + in the system to populate DailySeveritySummary records from historical scans. + """ + tenant_ids = Tenant.objects.using(MainRouter.admin_db).values_list("id", flat=True) + + for tenant_id in tenant_ids: + backfill_daily_severity_summaries_task.delay(tenant_id=str(tenant_id), days=90) + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0061_daily_severity_summary"), + ] + + operations = [ + migrations.RunPython(trigger_backfill_task, migrations.RunPython.noop), + ] diff --git a/api/src/backend/api/migrations/0063_scan_category_summary.py b/api/src/backend/api/migrations/0063_scan_category_summary.py new file mode 100644 index 0000000000..6ee67bf4db --- /dev/null +++ b/api/src/backend/api/migrations/0063_scan_category_summary.py @@ -0,0 +1,111 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.db_utils +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0062_backfill_daily_severity_summaries"), + ] + + operations = [ + migrations.CreateModel( + name="ScanCategorySummary", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.tenant", + ), + ), + ( + "inserted_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="category_summaries", + related_query_name="category_summary", + to="api.scan", + ), + ), + ( + "category", + models.CharField(max_length=100), + ), + ( + "severity", + api.db_utils.SeverityEnumField( + choices=[ + ("critical", "Critical"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("informational", "Informational"), + ], + ), + ), + ( + "total_findings", + models.IntegerField( + default=0, help_text="Non-muted findings (PASS + FAIL)" + ), + ), + ( + "failed_findings", + models.IntegerField( + default=0, + help_text="Non-muted FAIL findings (subset of total_findings)", + ), + ), + ( + "new_failed_findings", + models.IntegerField( + default=0, + help_text="Non-muted FAIL with delta='new' (subset of failed_findings)", + ), + ), + ], + options={ + "db_table": "scan_category_summaries", + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="scancategorysummary", + index=models.Index( + fields=["tenant_id", "scan"], name="scs_tenant_scan_idx" + ), + ), + migrations.AddConstraint( + model_name="scancategorysummary", + constraint=models.UniqueConstraint( + fields=("tenant_id", "scan_id", "category", "severity"), + name="unique_category_severity_per_scan", + ), + ), + migrations.AddConstraint( + model_name="scancategorysummary", + constraint=api.rls.RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_scancategorysummary", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0064_finding_categories.py b/api/src/backend/api/migrations/0064_finding_categories.py new file mode 100644 index 0000000000..8a0fc1df3a --- /dev/null +++ b/api/src/backend/api/migrations/0064_finding_categories.py @@ -0,0 +1,22 @@ +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0063_scan_category_summary"), + ] + + operations = [ + migrations.AddField( + model_name="finding", + name="categories", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=100), + blank=True, + null=True, + size=None, + help_text="Categories from check metadata for efficient filtering", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0065_alibabacloud_provider.py b/api/src/backend/api/migrations/0065_alibabacloud_provider.py new file mode 100644 index 0000000000..6ad542b643 --- /dev/null +++ b/api/src/backend/api/migrations/0065_alibabacloud_provider.py @@ -0,0 +1,37 @@ +# Generated by Django migration for Alibaba Cloud provider support + +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0064_finding_categories"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'alibabacloud';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0066_provider_compliance_score.py b/api/src/backend/api/migrations/0066_provider_compliance_score.py new file mode 100644 index 0000000000..f9a6483e4f --- /dev/null +++ b/api/src/backend/api/migrations/0066_provider_compliance_score.py @@ -0,0 +1,94 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.db_utils +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0065_alibabacloud_provider"), + ] + + operations = [ + migrations.CreateModel( + name="ProviderComplianceScore", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("compliance_id", models.TextField()), + ("requirement_id", models.TextField()), + ( + "requirement_status", + api.db_utils.StatusEnumField( + choices=[ + ("FAIL", "Fail"), + ("PASS", "Pass"), + ("MANUAL", "Manual"), + ] + ), + ), + ("scan_completed_at", models.DateTimeField()), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="compliance_scores", + related_query_name="compliance_score", + to="api.provider", + ), + ), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="compliance_scores", + related_query_name="compliance_score", + to="api.scan", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.tenant", + ), + ), + ], + options={ + "db_table": "provider_compliance_scores", + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="providercompliancescore", + constraint=models.UniqueConstraint( + fields=("tenant_id", "provider_id", "compliance_id", "requirement_id"), + name="unique_provider_compliance_req", + ), + ), + migrations.AddConstraint( + model_name="providercompliancescore", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_providercompliancescore", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + migrations.AddIndex( + model_name="providercompliancescore", + index=models.Index( + fields=["tenant_id", "provider_id", "compliance_id"], + name="pcs_tenant_prov_comp_idx", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0067_tenant_compliance_summary.py b/api/src/backend/api/migrations/0067_tenant_compliance_summary.py new file mode 100644 index 0000000000..bd753ca575 --- /dev/null +++ b/api/src/backend/api/migrations/0067_tenant_compliance_summary.py @@ -0,0 +1,61 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0066_provider_compliance_score"), + ] + + operations = [ + migrations.CreateModel( + name="TenantComplianceSummary", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("compliance_id", models.TextField()), + ("requirements_passed", models.IntegerField(default=0)), + ("requirements_failed", models.IntegerField(default=0)), + ("requirements_manual", models.IntegerField(default=0)), + ("total_requirements", models.IntegerField(default=0)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.tenant", + ), + ), + ], + options={ + "db_table": "tenant_compliance_summaries", + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="tenantcompliancesummary", + constraint=models.UniqueConstraint( + fields=("tenant_id", "compliance_id"), + name="unique_tenant_compliance_summary", + ), + ), + migrations.AddConstraint( + model_name="tenantcompliancesummary", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_tenantcompliancesummary", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py new file mode 100644 index 0000000000..932a2a6c85 --- /dev/null +++ b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py @@ -0,0 +1,126 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.db_utils +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0067_tenant_compliance_summary"), + ] + + operations = [ + migrations.AddField( + model_name="finding", + name="resource_groups", + field=models.TextField( + blank=True, + help_text="Resource group from check metadata for efficient filtering", + null=True, + ), + ), + migrations.CreateModel( + name="ScanGroupSummary", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="api.tenant", + ), + ), + ( + "inserted_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="resource_group_summaries", + related_query_name="resource_group_summary", + to="api.scan", + ), + ), + ( + "resource_group", + models.CharField(max_length=50), + ), + ( + "severity", + api.db_utils.SeverityEnumField( + choices=[ + ("critical", "Critical"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("informational", "Informational"), + ], + ), + ), + ( + "total_findings", + models.IntegerField( + default=0, help_text="Non-muted findings (PASS + FAIL)" + ), + ), + ( + "failed_findings", + models.IntegerField( + default=0, + help_text="Non-muted FAIL findings (subset of total_findings)", + ), + ), + ( + "new_failed_findings", + models.IntegerField( + default=0, + help_text="Non-muted FAIL with delta='new' (subset of failed_findings)", + ), + ), + ( + "resources_count", + models.IntegerField( + default=0, help_text="Count of distinct resource_uid values" + ), + ), + ], + options={ + "db_table": "scan_resource_group_summaries", + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="scangroupsummary", + index=models.Index( + fields=["tenant_id", "scan"], name="srgs_tenant_scan_idx" + ), + ), + migrations.AddConstraint( + model_name="scangroupsummary", + constraint=models.UniqueConstraint( + fields=("tenant_id", "scan_id", "resource_group", "severity"), + name="unique_resource_group_severity_per_scan", + ), + ), + migrations.AddConstraint( + model_name="scangroupsummary", + constraint=api.rls.RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_scangroupsummary", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0069_resource_resource_group.py b/api/src/backend/api/migrations/0069_resource_resource_group.py new file mode 100644 index 0000000000..14a26995c2 --- /dev/null +++ b/api/src/backend/api/migrations/0069_resource_resource_group.py @@ -0,0 +1,21 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0068_finding_resource_group_scangroupsummary"), + ] + + operations = [ + migrations.AddField( + model_name="resource", + name="groups", + field=ArrayField( + models.CharField(max_length=100), + blank=True, + help_text="Groups for categorization (e.g., compute, storage, IAM)", + null=True, + ), + ), + ] diff --git a/api/src/backend/api/migrations/0070_attack_paths_scan.py b/api/src/backend/api/migrations/0070_attack_paths_scan.py new file mode 100644 index 0000000000..3e63d3353b --- /dev/null +++ b/api/src/backend/api/migrations/0070_attack_paths_scan.py @@ -0,0 +1,154 @@ +# Generated by Django 5.1.13 on 2025-11-06 16:20 + +import django.db.models.deletion + +from django.db import migrations, models +from uuid6 import uuid7 + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0069_resource_resource_group"), + ] + + operations = [ + migrations.CreateModel( + name="AttackPathsScan", + fields=[ + ( + "id", + models.UUIDField( + default=uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "state", + api.db_utils.StateEnumField( + choices=[ + ("available", "Available"), + ("scheduled", "Scheduled"), + ("executing", "Executing"), + ("completed", "Completed"), + ("failed", "Failed"), + ("cancelled", "Cancelled"), + ], + default="available", + ), + ), + ("progress", models.IntegerField(default=0)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "duration", + models.IntegerField( + blank=True, help_text="Duration in seconds", null=True + ), + ), + ( + "update_tag", + models.BigIntegerField( + blank=True, + help_text="Cartography update tag (epoch)", + null=True, + ), + ), + ( + "graph_database", + models.CharField(blank=True, max_length=63, null=True), + ), + ( + "is_graph_database_deleted", + models.BooleanField(default=False), + ), + ( + "ingestion_exceptions", + models.JSONField(blank=True, default=dict, null=True), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + to="api.provider", + ), + ), + ( + "scan", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + to="api.scan", + ), + ), + ( + "task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + to="api.task", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "attack_paths_scans", + "abstract": False, + "indexes": [ + models.Index( + fields=["tenant_id", "provider_id", "-inserted_at"], + name="aps_prov_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "state", "-inserted_at"], + name="aps_state_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "scan_id"], + name="aps_scan_lookup_idx", + ), + models.Index( + fields=["tenant_id", "provider_id"], + name="aps_active_graph_idx", + include=["graph_database", "id"], + condition=models.Q(("is_graph_database_deleted", False)), + ), + models.Index( + fields=["tenant_id", "provider_id", "-completed_at"], + name="aps_completed_graph_idx", + include=["graph_database", "id"], + condition=models.Q( + ("state", "completed"), + ("is_graph_database_deleted", False), + ), + ), + ], + }, + ), + migrations.AddConstraint( + model_name="attackpathsscan", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_attackpathsscan", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py b/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py new file mode 100644 index 0000000000..e1b1e192ad --- /dev/null +++ b/api/src/backend/api/migrations/0071_drop_partitioned_indexes.py @@ -0,0 +1,41 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + """ + Drop unused indexes on partitioned tables (findings, resource_finding_mappings). + + NOTE: RemoveIndexConcurrently cannot be used on partitioned tables in PostgreSQL. + Standard RemoveIndex drops the parent index, which cascades to all partitions. + """ + + dependencies = [ + ("api", "0070_attack_paths_scan"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="finding", + name="gin_findings_search_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="gin_find_service_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="gin_find_region_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="gin_find_rtype_idx", + ), + migrations.RemoveIndex( + model_name="finding", + name="find_delta_new_idx", + ), + migrations.RemoveIndex( + model_name="resourcefindingmapping", + name="rfm_tenant_finding_idx", + ), + ] diff --git a/api/src/backend/api/migrations/0072_drop_unused_indexes.py b/api/src/backend/api/migrations/0072_drop_unused_indexes.py new file mode 100644 index 0000000000..81f1f69c0d --- /dev/null +++ b/api/src/backend/api/migrations/0072_drop_unused_indexes.py @@ -0,0 +1,91 @@ +""" +Drop unused indexes on non-partitioned tables. + +These tables are not partitioned, so RemoveIndexConcurrently can be used safely. +""" + +from uuid import uuid4 + +from django.contrib.postgres.operations import RemoveIndexConcurrently +from django.db import migrations, models + + +def drop_resource_scan_summary_resource_id_index(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + SELECT idx_ns.nspname, idx.relname + FROM pg_class tbl + JOIN pg_namespace tbl_ns ON tbl_ns.oid = tbl.relnamespace + JOIN pg_index i ON i.indrelid = tbl.oid + JOIN pg_class idx ON idx.oid = i.indexrelid + JOIN pg_namespace idx_ns ON idx_ns.oid = idx.relnamespace + JOIN pg_attribute a + ON a.attrelid = tbl.oid + AND a.attnum = (i.indkey::int[])[0] + WHERE tbl_ns.nspname = ANY (current_schemas(false)) + AND tbl.relname = %s + AND i.indnatts = 1 + AND a.attname = %s + """, + ["resource_scan_summaries", "resource_id"], + ) + row = cursor.fetchone() + + if not row: + return + + schema_name, index_name = row + quote_name = schema_editor.connection.ops.quote_name + qualified_name = f"{quote_name(schema_name)}.{quote_name(index_name)}" + schema_editor.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified_name};") + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0071_drop_partitioned_indexes"), + ] + + operations = [ + RemoveIndexConcurrently( + model_name="resource", + name="gin_resources_search_idx", + ), + RemoveIndexConcurrently( + model_name="resourcetag", + name="gin_resource_tags_search_idx", + ), + RemoveIndexConcurrently( + model_name="scansummary", + name="ss_tenant_scan_service_idx", + ), + RemoveIndexConcurrently( + model_name="complianceoverview", + name="comp_ov_cp_id_idx", + ), + RemoveIndexConcurrently( + model_name="complianceoverview", + name="comp_ov_req_fail_idx", + ), + RemoveIndexConcurrently( + model_name="complianceoverview", + name="comp_ov_cp_id_req_fail_idx", + ), + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython( + drop_resource_scan_summary_resource_id_index, + reverse_code=migrations.RunPython.noop, + ), + ], + state_operations=[ + migrations.AlterField( + model_name="resourcescansummary", + name="resource_id", + field=models.UUIDField(default=uuid4), + ), + ], + ), + ] diff --git a/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py new file mode 100644 index 0000000000..671fdf5ef6 --- /dev/null +++ b/api/src/backend/api/migrations/0073_findings_fail_new_index_partitions.py @@ -0,0 +1,31 @@ +from functools import partial + +from django.db import migrations + +from api.db_utils import create_index_on_partitions, drop_index_on_partitions + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0072_drop_unused_indexes"), + ] + + operations = [ + migrations.RunPython( + partial( + create_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_fail_new_idx", + columns="tenant_id, scan_id", + where="status = 'FAIL' AND delta = 'new'", + all_partitions=True, + ), + reverse_code=partial( + drop_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_fail_new_idx", + ), + ) + ] diff --git a/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py b/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py new file mode 100644 index 0000000000..a889ba0ed4 --- /dev/null +++ b/api/src/backend/api/migrations/0074_findings_fail_new_index_parent.py @@ -0,0 +1,54 @@ +from django.db import migrations, models + +INDEX_NAME = "find_tenant_scan_fail_new_idx" +PARENT_TABLE = "findings" + + +def create_parent_and_attach(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + f"CREATE INDEX {INDEX_NAME} ON ONLY {PARENT_TABLE} " + f"USING btree (tenant_id, scan_id) " + f"WHERE status = 'FAIL' AND delta = 'new'" + ) + cursor.execute( + "SELECT inhrelid::regclass::text " + "FROM pg_inherits " + "WHERE inhparent = %s::regclass", + [PARENT_TABLE], + ) + for (partition,) in cursor.fetchall(): + child_idx = f"{partition.replace('.', '_')}_{INDEX_NAME}" + cursor.execute(f"ALTER INDEX {INDEX_NAME} ATTACH PARTITION {child_idx}") + + +def drop_parent_index(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute(f"DROP INDEX IF EXISTS {INDEX_NAME}") + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0073_findings_fail_new_index_partitions"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AddIndex( + model_name="finding", + index=models.Index( + condition=models.Q(status="FAIL", delta="new"), + fields=["tenant_id", "scan_id"], + name=INDEX_NAME, + ), + ), + ], + database_operations=[ + migrations.RunPython( + create_parent_and_attach, + reverse_code=drop_parent_index, + ), + ], + ), + ] diff --git a/api/src/backend/api/migrations/0075_cloudflare_provider.py b/api/src/backend/api/migrations/0075_cloudflare_provider.py new file mode 100644 index 0000000000..28fdbdb2a9 --- /dev/null +++ b/api/src/backend/api/migrations/0075_cloudflare_provider.py @@ -0,0 +1,38 @@ +# Generated by Django migration for Cloudflare provider support + +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0074_findings_fail_new_index_parent"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'cloudflare';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0076_openstack_provider.py b/api/src/backend/api/migrations/0076_openstack_provider.py new file mode 100644 index 0000000000..9cc80707ea --- /dev/null +++ b/api/src/backend/api/migrations/0076_openstack_provider.py @@ -0,0 +1,39 @@ +# Generated by Django migration for OpenStack provider support + +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0075_cloudflare_provider"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ("openstack", "OpenStack"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'openstack';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 20de59d07b..eb09bba683 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -12,7 +12,6 @@ from cryptography.fernet import Fernet, InvalidToken from django.conf import settings from django.contrib.auth.models import AbstractBaseUser from django.contrib.postgres.fields import ArrayField -from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVector, SearchVectorField from django.contrib.sites.models import Site from django.core.exceptions import ValidationError @@ -284,7 +283,12 @@ class Provider(RowLevelSecurityProtectedModel): KUBERNETES = "kubernetes", _("Kubernetes") M365 = "m365", _("M365") GITHUB = "github", _("GitHub") - OCI = "oci", _("Oracle Cloud Infrastructure") + MONGODBATLAS = "mongodbatlas", _("MongoDB Atlas") + IAC = "iac", _("IaC") + ORACLECLOUD = "oraclecloud", _("Oracle Cloud Infrastructure") + ALIBABACLOUD = "alibabacloud", _("Alibaba Cloud") + CLOUDFLARE = "cloudflare", _("Cloudflare") + OPENSTACK = "openstack", _("OpenStack") @staticmethod def validate_aws_uid(value): @@ -356,14 +360,63 @@ class Provider(RowLevelSecurityProtectedModel): ) @staticmethod - def validate_oci_uid(value): + def validate_iac_uid(value): + # Validate that it's a valid repository URL (git URL format) + if not re.match( + r"^(https?://|git@|ssh://)[^\s/]+[^\s]*\.git$|^(https?://)[^\s/]+[^\s]*$", + value, + ): + raise ModelValidationError( + detail="IaC provider ID must be a valid repository URL (e.g., https://github.com/user/repo or https://github.com/user/repo.git).", + code="iac-uid", + pointer="/data/attributes/uid", + ) + + @staticmethod + def validate_oraclecloud_uid(value): if not re.match( r"^ocid1\.([a-z0-9_-]+)\.([a-z0-9_-]+)\.([a-z0-9_-]*)\.([a-z0-9]+)$", value ): raise ModelValidationError( detail="Oracle Cloud Infrastructure provider ID must be a valid tenancy OCID in the format: " "ocid1....", - code="oci-uid", + code="oraclecloud-uid", + pointer="/data/attributes/uid", + ) + + @staticmethod + def validate_mongodbatlas_uid(value): + if not re.match(r"^[0-9a-fA-F]{24}$", value): + raise ModelValidationError( + detail="MongoDB Atlas organization ID must be a 24-character hexadecimal string.", + code="mongodbatlas-uid", + pointer="/data/attributes/uid", + ) + + @staticmethod + def validate_alibabacloud_uid(value): + if not re.match(r"^\d{16}$", value): + raise ModelValidationError( + detail="Alibaba Cloud account ID must be exactly 16 digits.", + code="alibabacloud-uid", + pointer="/data/attributes/uid", + ) + + @staticmethod + def validate_cloudflare_uid(value): + if not re.match(r"^[a-f0-9]{32}$", value): + raise ModelValidationError( + detail="Cloudflare Account ID must be a 32-character hexadecimal string.", + code="cloudflare-uid", + pointer="/data/attributes/uid", + ) + + @staticmethod + def validate_openstack_uid(value): + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$", value): + raise ModelValidationError( + detail="OpenStack provider ID must be a valid project ID (UUID or project name).", + code="openstack-uid", pointer="/data/attributes/uid", ) @@ -401,7 +454,8 @@ class Provider(RowLevelSecurityProtectedModel): constraints = [ models.UniqueConstraint( - fields=("tenant_id", "provider", "uid", "is_deleted"), + fields=("tenant_id", "provider", "uid"), + condition=Q(is_deleted=False), name="unique_provider_uids", ), RowLevelSecurityConstraint( @@ -591,6 +645,101 @@ class Scan(RowLevelSecurityProtectedModel): resource_name = "scans" +class AttackPathsScan(RowLevelSecurityProtectedModel): + objects = ActiveProviderManager() + all_objects = models.Manager() + + id = models.UUIDField(primary_key=True, default=uuid7, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE) + progress = models.IntegerField(default=0) + + # Timing + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + duration = models.IntegerField( + null=True, blank=True, help_text="Duration in seconds" + ) + + # Relationship to the provider and optional prowler Scan and celery Task + provider = models.ForeignKey( + "Provider", + on_delete=models.CASCADE, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + ) + scan = models.ForeignKey( + "Scan", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + ) + task = models.ForeignKey( + "Task", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="attack_paths_scans", + related_query_name="attack_paths_scan", + ) + + # Cartography specific metadata + update_tag = models.BigIntegerField( + null=True, blank=True, help_text="Cartography update tag (epoch)" + ) + graph_database = models.CharField(max_length=63, null=True, blank=True) + is_graph_database_deleted = models.BooleanField(default=False) + ingestion_exceptions = models.JSONField(default=dict, null=True, blank=True) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "attack_paths_scans" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "provider_id", "-inserted_at"], + name="aps_prov_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "state", "-inserted_at"], + name="aps_state_ins_desc_idx", + ), + models.Index( + fields=["tenant_id", "scan_id"], + name="aps_scan_lookup_idx", + ), + models.Index( + fields=["tenant_id", "provider_id"], + name="aps_active_graph_idx", + include=["graph_database", "id"], + condition=Q(is_graph_database_deleted=False), + ), + models.Index( + fields=["tenant_id", "provider_id", "-completed_at"], + name="aps_completed_graph_idx", + include=["graph_database", "id"], + condition=Q( + state=StateChoices.COMPLETED, + is_graph_database_deleted=False, + ), + ), + ] + + class JSONAPIMeta: + resource_name = "attack-paths-scans" + + class ResourceTag(RowLevelSecurityProtectedModel): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) @@ -611,10 +760,6 @@ class ResourceTag(RowLevelSecurityProtectedModel): class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "resource_tags" - indexes = [ - GinIndex(fields=["text_search"], name="gin_resource_tags_search_idx"), - ] - constraints = [ models.UniqueConstraint( fields=("tenant_id", "key", "value"), @@ -669,6 +814,12 @@ class Resource(RowLevelSecurityProtectedModel): metadata = models.TextField(blank=True, null=True) details = models.TextField(blank=True, null=True) partition = models.TextField(blank=True, null=True) + groups = ArrayField( + models.CharField(max_length=100), + blank=True, + null=True, + help_text="Groups for categorization (e.g., compute, storage, IAM)", + ) failed_findings_count = models.IntegerField(default=0) @@ -691,14 +842,19 @@ class Resource(RowLevelSecurityProtectedModel): self.clear_tags() return - # Add new relationships with the tenant_id field + # Add new relationships with the tenant_id field; avoid touching the + # Resource row unless a mapping is actually created to prevent noisy + # updates during scans. + mapping_created = False for tag in tags: - ResourceTagMapping.objects.update_or_create( + _, created = ResourceTagMapping.objects.update_or_create( tag=tag, resource=self, tenant_id=self.tenant_id ) + mapping_created = mapping_created or created - # Save the instance - self.save() + if mapping_created: + # Only bump updated_at when the tag set truly changed + self.save(update_fields=["updated_at"]) class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "resources" @@ -712,7 +868,6 @@ class Resource(RowLevelSecurityProtectedModel): fields=["tenant_id", "service", "region", "type"], name="resource_tenant_metadata_idx", ), - GinIndex(fields=["text_search"], name="gin_resources_search_idx"), models.Index(fields=["tenant_id", "id"], name="resources_tenant_id_idx"), models.Index( fields=["tenant_id", "provider_id"], @@ -823,6 +978,9 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): muted_reason = models.TextField( blank=True, null=True, validators=[MinLengthValidator(3)], max_length=500 ) + muted_at = models.DateTimeField( + null=True, blank=True, help_text="Timestamp when this finding was muted" + ) compliance = models.JSONField(default=dict, null=True, blank=True) # Denormalize resource data for performance @@ -840,6 +998,19 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): null=True, ) + # Check metadata denormalization + categories = ArrayField( + models.CharField(max_length=100), + blank=True, + null=True, + help_text="Categories from check metadata for efficient filtering", + ) + resource_groups = models.TextField( + blank=True, + null=True, + help_text="Resource group from check metadata for efficient filtering", + ) + # Relationships scan = models.ForeignKey(to=Scan, related_name="findings", on_delete=models.CASCADE) @@ -881,23 +1052,19 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): indexes = [ models.Index(fields=["tenant_id", "id"], name="findings_tenant_and_id_idx"), - GinIndex(fields=["text_search"], name="gin_findings_search_idx"), models.Index(fields=["tenant_id", "scan_id"], name="find_tenant_scan_idx"), models.Index( fields=["tenant_id", "scan_id", "id"], name="find_tenant_scan_id_idx" ), models.Index( - fields=["tenant_id", "id"], - condition=Q(delta="new"), - name="find_delta_new_idx", + condition=models.Q(status=StatusChoices.FAIL, delta="new"), + fields=["tenant_id", "scan_id"], + name="find_tenant_scan_fail_new_idx", ), models.Index( fields=["tenant_id", "uid", "-inserted_at"], name="find_tenant_uid_inserted_idx", ), - GinIndex(fields=["resource_services"], name="gin_find_service_idx"), - GinIndex(fields=["resource_regions"], name="gin_find_region_idx"), - GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"), models.Index( fields=["tenant_id", "scan_id", "check_id"], name="find_tenant_scan_check_idx", @@ -965,10 +1132,6 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected # - id indexes = [ - models.Index( - fields=["tenant_id", "finding_id"], - name="rfm_tenant_finding_idx", - ), models.Index( fields=["tenant_id", "resource_id"], name="rfm_tenant_resource_idx", @@ -1285,14 +1448,6 @@ class ComplianceOverview(RowLevelSecurityProtectedModel): statements=["SELECT", "INSERT", "DELETE"], ), ] - indexes = [ - models.Index(fields=["compliance_id"], name="comp_ov_cp_id_idx"), - models.Index(fields=["requirements_failed"], name="comp_ov_req_fail_idx"), - models.Index( - fields=["compliance_id", "requirements_failed"], - name="comp_ov_cp_id_req_fail_idx", - ), - ] class JSONAPIMeta: resource_name = "compliance-overviews" @@ -1343,35 +1498,70 @@ class ComplianceRequirementOverview(RowLevelSecurityProtectedModel): ), ] indexes = [ - models.Index(fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx"), - models.Index( - fields=["tenant_id", "scan_id", "compliance_id"], - name="cro_scan_comp_idx", - ), models.Index( fields=["tenant_id", "scan_id", "compliance_id", "region"], name="cro_scan_comp_reg_idx", ), - models.Index( - fields=["tenant_id", "scan_id", "compliance_id", "requirement_id"], - name="cro_scan_comp_req_idx", - ), - models.Index( - fields=[ - "tenant_id", - "scan_id", - "compliance_id", - "requirement_id", - "region", - ], - name="cro_scan_comp_req_reg_idx", - ), ] class JSONAPIMeta: resource_name = "compliance-requirements-overviews" +class ComplianceOverviewSummary(RowLevelSecurityProtectedModel): + """ + Pre-aggregated compliance overview aggregated across ALL regions. + One row per (scan_id, compliance_id) combination. + + This table optimizes the common case where users view overall compliance + without filtering by region. For region-specific views, the detailed + ComplianceRequirementOverview table is used instead. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="compliance_summaries", + related_query_name="compliance_summary", + ) + + compliance_id = models.TextField(blank=False) + + # Pre-aggregated scores (computed across ALL regions) + requirements_passed = models.IntegerField(default=0) + requirements_failed = models.IntegerField(default=0) + requirements_manual = models.IntegerField(default=0) + total_requirements = models.IntegerField(default=0) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "compliance_overview_summaries" + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "scan_id", "compliance_id"), + name="unique_compliance_summary_per_scan", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "DELETE"], + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "scan_id"], + name="cos_tenant_scan_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "compliance-overview-summaries" + + class ScanSummary(RowLevelSecurityProtectedModel): objects = ActiveProviderManager() all_objects = models.Manager() @@ -1423,10 +1613,6 @@ class ScanSummary(RowLevelSecurityProtectedModel): fields=["tenant_id", "scan_id"], name="scan_summaries_tenant_scan_idx", ), - models.Index( - fields=["tenant_id", "scan_id", "service"], - name="ss_tenant_scan_service_idx", - ), models.Index( fields=["tenant_id", "scan_id", "severity"], name="ss_tenant_scan_severity_idx", @@ -1437,6 +1623,65 @@ class ScanSummary(RowLevelSecurityProtectedModel): resource_name = "scan-summaries" +class DailySeveritySummary(RowLevelSecurityProtectedModel): + """ + Pre-aggregated daily severity counts per provider. + Used by findings_severity/timeseries endpoint for efficient queries. + """ + + objects = ActiveProviderManager() + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + date = models.DateField() + + provider = models.ForeignKey( + Provider, + on_delete=models.CASCADE, + related_name="daily_severity_summaries", + related_query_name="daily_severity_summary", + ) + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="daily_severity_summaries", + related_query_name="daily_severity_summary", + ) + + # Aggregated fail counts by severity + critical = models.IntegerField(default=0) + high = models.IntegerField(default=0) + medium = models.IntegerField(default=0) + low = models.IntegerField(default=0) + informational = models.IntegerField(default=0) + muted = models.IntegerField(default=0) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "daily_severity_summaries" + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "provider", "date"), + name="unique_daily_severity_summary", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "id"], + name="dss_tenant_id_idx", + ), + models.Index( + fields=["tenant_id", "provider_id"], + name="dss_tenant_provider_idx", + ), + ] + + class Integration(RowLevelSecurityProtectedModel): class IntegrationChoices(models.TextChoices): AMAZON_S3 = "amazon_s3", _("Amazon S3") @@ -1782,7 +2027,7 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel): class ResourceScanSummary(RowLevelSecurityProtectedModel): scan_id = models.UUIDField(default=uuid7, db_index=True) - resource_id = models.UUIDField(default=uuid4, db_index=True) + resource_id = models.UUIDField(default=uuid4) service = models.CharField(max_length=100) region = models.CharField(max_length=100) resource_type = models.CharField(max_length=100) @@ -1829,6 +2074,125 @@ class ResourceScanSummary(RowLevelSecurityProtectedModel): ] +class ScanCategorySummary(RowLevelSecurityProtectedModel): + """ + Pre-aggregated category metrics per scan by severity. + + Stores one row per (category, severity) combination per scan for efficient + overview queries. Categories come from check_metadata.categories. + + Count relationships (each is a subset of the previous): + - total_findings >= failed_findings >= new_failed_findings + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="category_summaries", + related_query_name="category_summary", + ) + + category = models.CharField(max_length=100) + severity = SeverityEnumField(choices=SeverityChoices) + + total_findings = models.IntegerField( + default=0, help_text="Non-muted findings (PASS + FAIL)" + ) + failed_findings = models.IntegerField( + default=0, help_text="Non-muted FAIL findings (subset of total_findings)" + ) + new_failed_findings = models.IntegerField( + default=0, + help_text="Non-muted FAIL with delta='new' (subset of failed_findings)", + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "scan_category_summaries" + + indexes = [ + models.Index(fields=["tenant_id", "scan"], name="scs_tenant_scan_idx"), + ] + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "scan_id", "category", "severity"), + name="unique_category_severity_per_scan", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + class JSONAPIMeta: + resource_name = "scan-category-summaries" + + +class ScanGroupSummary(RowLevelSecurityProtectedModel): + """ + Pre-aggregated resource group metrics per scan by severity. + + Stores one row per (resource_group, severity) combination per scan for efficient + overview queries. Resource groups come from check_metadata.Group. + + Count relationships (each is a subset of the previous): + - total_findings >= failed_findings >= new_failed_findings + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="resource_group_summaries", + related_query_name="resource_group_summary", + ) + + resource_group = models.CharField(max_length=50) + severity = SeverityEnumField(choices=SeverityChoices) + + total_findings = models.IntegerField( + default=0, help_text="Non-muted findings (PASS + FAIL)" + ) + failed_findings = models.IntegerField( + default=0, help_text="Non-muted FAIL findings (subset of total_findings)" + ) + new_failed_findings = models.IntegerField( + default=0, + help_text="Non-muted FAIL with delta='new' (subset of failed_findings)", + ) + resources_count = models.IntegerField( + default=0, help_text="Count of distinct resource_uid values" + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "scan_resource_group_summaries" + + indexes = [ + models.Index(fields=["tenant_id", "scan"], name="srgs_tenant_scan_idx"), + ] + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "scan_id", "resource_group", "severity"), + name="unique_resource_group_severity_per_scan", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + class JSONAPIMeta: + resource_name = "scan-resource-group-summaries" + + class LighthouseConfiguration(RowLevelSecurityProtectedModel): """ Stores configuration and API keys for LLM services. @@ -1935,6 +2299,59 @@ class LighthouseConfiguration(RowLevelSecurityProtectedModel): resource_name = "lighthouse-configurations" +class MuteRule(RowLevelSecurityProtectedModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + # Rule metadata + name = models.CharField( + max_length=100, + validators=[MinLengthValidator(3)], + help_text="Human-readable name for this rule", + ) + reason = models.TextField( + validators=[MinLengthValidator(3)], + max_length=500, + help_text="Reason for muting", + ) + enabled = models.BooleanField( + default=True, help_text="Whether this rule is currently enabled" + ) + + # Audit fields + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="created_mute_rules", + help_text="User who created this rule", + ) + + # Rule criteria - array of finding UIDs + finding_uids = ArrayField( + models.CharField(max_length=255), help_text="List of finding UIDs to mute" + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "mute_rules" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + models.UniqueConstraint( + fields=("tenant_id", "name"), + name="unique_mute_rule_name_per_tenant", + ), + ] + + class JSONAPIMeta: + resource_name = "mute-rules" + + class Processor(RowLevelSecurityProtectedModel): class ProcessorChoices(models.TextChoices): MUTELIST = "mutelist", _("Mutelist") @@ -1983,6 +2400,8 @@ class LighthouseProviderConfiguration(RowLevelSecurityProtectedModel): class LLMProviderChoices(models.TextChoices): OPENAI = "openai", _("OpenAI") + BEDROCK = "bedrock", _("AWS Bedrock") + OPENAI_COMPATIBLE = "openai_compatible", _("OpenAI Compatible") id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) @@ -2104,7 +2523,7 @@ class LighthouseTenantConfiguration(RowLevelSecurityProtectedModel): ] class JSONAPIMeta: - resource_name = "lighthouse-config" + resource_name = "lighthouse-configurations" class LighthouseProviderModels(RowLevelSecurityProtectedModel): @@ -2153,3 +2572,286 @@ class LighthouseProviderModels(RowLevelSecurityProtectedModel): name="lh_prov_models_cfg_idx", ), ] + + class JSONAPIMeta: + resource_name = "lighthouse-models" + + +class ThreatScoreSnapshot(RowLevelSecurityProtectedModel): + """ + Stores historical ThreatScore metrics for a given scan. + Snapshots are created automatically after each ThreatScore report generation. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="threatscore_snapshots", + related_query_name="threatscore_snapshot", + ) + + provider = models.ForeignKey( + Provider, + on_delete=models.CASCADE, + related_name="threatscore_snapshots", + related_query_name="threatscore_snapshot", + ) + + compliance_id = models.CharField( + max_length=100, + blank=False, + null=False, + help_text="Compliance framework ID (e.g., 'prowler_threatscore_aws')", + ) + + # Overall ThreatScore metrics + overall_score = models.DecimalField( + max_digits=5, + decimal_places=2, + help_text="Overall ThreatScore percentage (0-100)", + ) + + # Score improvement/degradation compared to previous snapshot + score_delta = models.DecimalField( + max_digits=5, + decimal_places=2, + null=True, + blank=True, + help_text="Score change compared to previous snapshot (positive = improvement)", + ) + + # Section breakdown stored as JSON + # Format: {"1. IAM": 85.5, "2. Attack Surface": 92.3, ...} + section_scores = models.JSONField( + default=dict, + blank=True, + help_text="ThreatScore breakdown by section", + ) + + # Critical requirements metadata stored as JSON + # Format: [{"requirement_id": "...", "risk_level": 5, "weight": 150, ...}, ...] + critical_requirements = models.JSONField( + default=list, + blank=True, + help_text="List of critical failed requirements (risk >= 4)", + ) + + # Summary statistics + total_requirements = models.IntegerField( + default=0, + help_text="Total number of requirements evaluated", + ) + + passed_requirements = models.IntegerField( + default=0, + help_text="Number of requirements with PASS status", + ) + + failed_requirements = models.IntegerField( + default=0, + help_text="Number of requirements with FAIL status", + ) + + manual_requirements = models.IntegerField( + default=0, + help_text="Number of requirements with MANUAL status", + ) + + total_findings = models.IntegerField( + default=0, + help_text="Total number of findings across all requirements", + ) + + passed_findings = models.IntegerField( + default=0, + help_text="Number of findings with PASS status", + ) + + failed_findings = models.IntegerField( + default=0, + help_text="Number of findings with FAIL status", + ) + + def __str__(self): + return f"ThreatScore {self.overall_score}% for scan {self.scan_id} ({self.inserted_at})" + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "threatscore_snapshots" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "scan_id"], + name="threatscore_snap_t_scan_idx", + ), + models.Index( + fields=["tenant_id", "provider_id"], + name="threatscore_snap_t_prov_idx", + ), + models.Index( + fields=["tenant_id", "inserted_at"], + name="threatscore_snap_t_time_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "threatscore-snapshots" + + +class AttackSurfaceOverview(RowLevelSecurityProtectedModel): + """ + Pre-aggregated attack surface metrics per scan. + + Stores counts for each attack surface type (internet-exposed, secrets, + privilege-escalation, ec2-imdsv1) to enable fast overview queries. + """ + + class AttackSurfaceTypeChoices(models.TextChoices): + INTERNET_EXPOSED = "internet-exposed", _("Internet Exposed") + SECRETS = "secrets", _("Exposed Secrets") + PRIVILEGE_ESCALATION = "privilege-escalation", _("Privilege Escalation") + EC2_IMDSV1 = "ec2-imdsv1", _("EC2 IMDSv1 Enabled") + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="attack_surface_overviews", + related_query_name="attack_surface_overview", + ) + + attack_surface_type = models.CharField( + max_length=50, + choices=AttackSurfaceTypeChoices.choices, + ) + + # Finding counts + total_findings = models.IntegerField(default=0) # All findings (PASS + FAIL) + failed_findings = models.IntegerField(default=0) # Non-muted failed findings + muted_failed_findings = models.IntegerField(default=0) # Muted failed findings + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "attack_surface_overviews" + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "scan_id", "attack_surface_type"), + name="unique_attack_surface_per_scan", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "scan_id"], + name="attack_surf_tenant_scan_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "attack-surface-overviews" + + +class ProviderComplianceScore(RowLevelSecurityProtectedModel): + """ + Compliance requirement status from latest completed scan per provider. + + Used for efficient compliance watchlist queries with FAIL-dominant aggregation + across multiple providers. Updated via atomic upsert after each scan completion. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="compliance_scores", + related_query_name="compliance_score", + ) + + provider = models.ForeignKey( + Provider, + on_delete=models.CASCADE, + related_name="compliance_scores", + related_query_name="compliance_score", + ) + + compliance_id = models.TextField() + requirement_id = models.TextField() + requirement_status = StatusEnumField(choices=StatusChoices) + + scan_completed_at = models.DateTimeField() + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "provider_compliance_scores" + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "provider_id", "compliance_id", "requirement_id"), + name="unique_provider_compliance_req", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "provider_id", "compliance_id"], + name="pcs_tenant_prov_comp_idx", + ), + ] + + +class TenantComplianceSummary(RowLevelSecurityProtectedModel): + """ + Pre-aggregated compliance counts per tenant with FAIL-dominant logic applied. + + One row per (tenant, compliance_id). Used for fast watchlist queries when + no provider filter is applied. Recalculated after each scan by aggregating + across all providers with FAIL-dominant logic at requirement level. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + + compliance_id = models.TextField() + + requirements_passed = models.IntegerField(default=0) + requirements_failed = models.IntegerField(default=0) + requirements_manual = models.IntegerField(default=0) + total_requirements = models.IntegerField(default=0) + + updated_at = models.DateTimeField(auto_now=True) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "tenant_compliance_summaries" + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "compliance_id"), + name="unique_tenant_compliance_summary", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index 6a95e82932..97d7d785e0 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -65,11 +65,11 @@ def get_providers(role: Role) -> QuerySet[Provider]: A QuerySet of Provider objects filtered by the role's provider groups. If the role has no provider groups, returns an empty queryset. """ - tenant = role.tenant + tenant_id = role.tenant_id provider_groups = role.provider_groups.all() if not provider_groups.exists(): return Provider.objects.none() return Provider.objects.filter( - tenant=tenant, provider_groups__in=provider_groups + tenant_id=tenant_id, provider_groups__in=provider_groups ).distinct() diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 6c05866c76..88d67c01c1 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.15.0 + version: 1.20.0 description: |- Prowler API specification. @@ -280,6 +280,447 @@ paths: schema: $ref: '#/components/schemas/OpenApiResponseResponse' description: API key was successfully revoked + /api/v1/attack-paths-scans: + get: + operationId: attack_paths_scans_list + description: Retrieve Attack Paths scans for the tenant with support for filtering, + ordering, and pagination. + summary: List Attack Paths scans + parameters: + - in: query + name: fields[attack-paths-scans] + schema: + type: array + items: + type: string + enum: + - state + - progress + - provider + - provider_alias + - provider_type + - provider_uid + - scan + - task + - inserted_at + - started_at + - completed_at + - duration + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[completed_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[started_at] + schema: + type: string + format: date + - in: query + name: filter[state] + schema: + type: string + x-spec-enum-id: d38ba07264e1ed34 + enum: + - available + - cancelled + - completed + - executing + - failed + - scheduled + description: |- + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + - in: query + name: filter[state__in] + schema: + type: array + items: + type: string + x-spec-enum-id: d38ba07264e1ed34 + enum: + - available + - cancelled + - completed + - executing + - failed + - scheduled + description: |- + Multiple values may be separated by commas. + + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + explode: false + style: form + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + - scan + - task + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - inserted_at + - -inserted_at + - started_at + - -started_at + explode: false + tags: + - Attack Paths + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedAttackPathsScanList' + description: '' + /api/v1/attack-paths-scans/{id}: + get: + operationId: attack_paths_scans_retrieve + description: Fetch full details for a specific Attack Paths scan. + summary: Retrieve Attack Paths scan details + parameters: + - in: query + name: fields[attack-paths-scans] + schema: + type: array + items: + type: string + enum: + - state + - progress + - provider + - provider_alias + - provider_type + - provider_uid + - scan + - task + - inserted_at + - started_at + - completed_at + - duration + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this attack paths scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + - scan + - task + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Attack Paths + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/AttackPathsScanResponse' + description: '' + /api/v1/attack-paths-scans/{id}/queries: + get: + operationId: attack_paths_scans_queries_retrieve + description: Retrieve the catalog of Attack Paths queries available for this + Attack Paths scan. + summary: List Attack Paths queries + parameters: + - in: query + name: fields[attack-paths-scans] + schema: + type: array + items: + type: string + enum: + - state + - progress + - provider + - provider_alias + - provider_type + - provider_uid + - scan + - task + - inserted_at + - started_at + - completed_at + - duration + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this attack paths scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + - scan + - task + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Attack Paths + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedAttackPathsQueryList' + description: '' + '404': + description: No queries found for the selected provider + /api/v1/attack-paths-scans/{id}/queries/run: + post: + operationId: attack_paths_scans_queries_run_create + description: Execute the selected Attack Paths query against the Attack Paths + graph and return the resulting subgraph. + summary: Execute an Attack Paths query + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this attack paths scan. + required: true + tags: + - Attack Paths + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/AttackPathsQueryRunRequestRequest' + required: true + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OpenApiResponseResponse' + description: '' + '400': + description: Bad request (e.g., Unknown Attack Paths query for the selected + provider) + '404': + description: No Attack Paths found for the given query and parameters + '500': + description: Attack Paths query execution failed due to a database error /api/v1/compliance-overviews: get: operationId: compliance_overviews_list @@ -711,6 +1152,8 @@ paths: - severity - check_id - check_metadata + - categories + - resource_groups - raw_result - inserted_at - updated_at @@ -723,6 +1166,19 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[check_id] schema: @@ -865,19 +1321,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -885,22 +1361,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -910,7 +1396,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -947,6 +1438,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -1197,6 +1701,8 @@ paths: - severity - check_id - check_metadata + - categories + - resource_groups - raw_result - inserted_at - updated_at @@ -1257,6 +1763,19 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[check_id] schema: @@ -1396,19 +1915,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -1416,22 +1955,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -1441,7 +1990,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -1478,6 +2032,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -1706,6 +2273,8 @@ paths: - severity - check_id - check_metadata + - categories + - resource_groups - raw_result - inserted_at - updated_at @@ -1718,6 +2287,19 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[check_id] schema: @@ -1835,19 +2417,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -1855,22 +2457,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -1880,7 +2492,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -1917,6 +2534,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -2127,9 +2757,24 @@ paths: - services - regions - resource_types + - categories + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[check_id] schema: @@ -2272,19 +2917,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -2292,22 +2957,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -2317,7 +2992,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -2354,6 +3034,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -2577,9 +3270,24 @@ paths: - services - regions - resource_types + - categories + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[check_id] schema: @@ -2697,19 +3405,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -2717,22 +3445,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -2742,7 +3480,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -2779,6 +3522,19 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_name] schema: @@ -3585,7 +4341,7 @@ paths: summary: Get Lighthouse AI Tenant config parameters: - in: query - name: fields[lighthouse-config] + name: fields[lighthouse-configurations] schema: type: array items: @@ -3690,7 +4446,7 @@ paths: summary: List all LLM models parameters: - in: query - name: fields[LighthouseProviderModels] + name: fields[lighthouse-models] schema: type: array items: @@ -3727,26 +4483,34 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b enum: + - bedrock - openai + - openai_compatible description: |- LLM provider name * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b enum: + - bedrock - openai + - openai_compatible description: |- Multiple values may be separated by commas. * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible explode: false style: form - name: filter[search] @@ -3811,7 +4575,7 @@ paths: summary: Retrieve LLM model details parameters: - in: query - name: fields[LighthouseProviderModels] + name: fields[lighthouse-models] schema: type: array items: @@ -3849,7 +4613,7 @@ paths: get: operationId: lighthouse_providers_list description: Retrieve all LLM provider configurations for the current tenant - summary: List all LLM provider configs + summary: List all LLM provider configurations parameters: - in: query name: fields[lighthouse-providers] @@ -3876,26 +4640,34 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b enum: + - bedrock - openai + - openai_compatible description: |- LLM provider name * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b enum: + - bedrock - openai + - openai_compatible description: |- Multiple values may be separated by commas. * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible explode: false style: form - name: filter[search] @@ -3957,7 +4729,7 @@ paths: operationId: lighthouse_providers_create description: Create a per-tenant configuration for an LLM provider. Only one configuration per provider type is allowed per tenant. - summary: Create LLM provider config + summary: Create LLM provider configuration tags: - Lighthouse AI requestBody: @@ -3986,7 +4758,7 @@ paths: operationId: lighthouse_providers_retrieve description: Get details for a specific provider configuration in the current tenant. - summary: Retrieve LLM provider config + summary: Retrieve LLM provider configuration parameters: - in: query name: fields[lighthouse-providers] @@ -4026,7 +4798,7 @@ paths: patch: operationId: lighthouse_providers_partial_update description: Partially update a provider configuration (e.g., base_url, is_active). - summary: Update LLM provider config + summary: Update LLM provider configuration parameters: - in: path name: id @@ -4062,7 +4834,7 @@ paths: operationId: lighthouse_providers_destroy description: Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion. - summary: Delete LLM provider config + summary: Delete LLM provider configuration parameters: - in: path name: id @@ -4121,7 +4893,7 @@ paths: post: operationId: lighthouse_providers_refresh_models_create description: Fetch available models for this provider configuration and upsert - into catalog. + into catalog. Supports OpenAI, OpenAI-compatible, and AWS Bedrock providers. summary: Refresh LLM models catalog parameters: - in: path @@ -4157,6 +4929,767 @@ paths: task_args: null metadata: null description: '' + /api/v1/mute-rules: + get: + operationId: mute_rules_list + description: Retrieve a list of all mute rules with filtering options. + summary: List all mute rules + parameters: + - in: query + name: fields[mute-rules] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - name + - reason + - enabled + - created_by + - finding_uids + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[created_by] + schema: + type: string + format: uuid + - in: query + name: filter[enabled] + schema: + type: boolean + - in: query + name: filter[id] + schema: + type: string + format: uuid + - in: query + name: filter[id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - in: query + name: filter[reason__icontains] + schema: + type: string + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + - in: query + name: filter[updated_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__lte] + schema: + type: string + format: date-time + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - created_by + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - name + - -name + - enabled + - -enabled + - inserted_at + - -inserted_at + - updated_at + - -updated_at + explode: false + tags: + - Mute Rules + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedMuteRuleList' + description: '' + post: + operationId: mute_rules_create + description: Create a new mute rule by providing finding IDs, name, and reason. + The rule will immediately mute the selected findings and launch a background + task to mute all historical findings with matching UIDs. + summary: Create a new mute rule + tags: + - Mute Rules + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/MuteRuleCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MuteRuleCreateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/MuteRuleCreateRequest' + required: true + security: + - JWT or API Key: [] + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/MuteRuleCreateResponse' + description: '' + /api/v1/mute-rules/{id}: + get: + operationId: mute_rules_retrieve + description: Fetch detailed information about a specific mute rule by ID. + summary: Retrieve a mute rule + parameters: + - in: query + name: fields[mute-rules] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - name + - reason + - enabled + - created_by + - finding_uids + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this mute rule. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - created_by + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Mute Rules + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/MuteRuleResponse' + description: '' + patch: + operationId: mute_rules_partial_update + description: Update certain fields of an existing mute rule (e.g., name, reason, + enabled). + summary: Partially update a mute rule + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this mute rule. + required: true + tags: + - Mute Rules + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedMuteRuleUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedMuteRuleUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedMuteRuleUpdateRequest' + required: true + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/SerializerMetaclassResponse' + description: '' + delete: + operationId: mute_rules_destroy + description: 'Remove a mute rule from the system. Note: Previously muted findings + remain muted.' + summary: Delete a mute rule + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this mute rule. + required: true + tags: + - Mute Rules + security: + - JWT or API Key: [] + responses: + '204': + description: No response body + /api/v1/overviews/attack-surfaces: + get: + operationId: overviews_attack_surfaces_list + description: Retrieve aggregated attack surface metrics from latest completed + scans per provider. + summary: Get attack surface overview + parameters: + - in: query + name: fields[attack-surface-overviews] + schema: + type: array + items: + type: string + enum: + - id + - total_findings + - failed_findings + - muted_failed_findings + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - total_findings + - -total_findings + - failed_findings + - -failed_findings + - muted_failed_findings + - -muted_failed_findings + explode: false + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedAttackSurfaceOverviewList' + description: '' + /api/v1/overviews/categories: + get: + operationId: overviews_categories_list + description: 'Retrieve aggregated category metrics from latest completed scans + per provider. Returns one row per category with total, failed, and new failed + findings counts, plus a severity breakdown showing failed findings per severity + level. ' + summary: Get category overview + parameters: + - in: query + name: fields[category-overviews] + schema: + type: array + items: + type: string + enum: + - id + - total_findings + - failed_findings + - new_failed_findings + - severity + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - total_findings + - -total_findings + - failed_findings + - -failed_findings + - new_failed_findings + - -new_failed_findings + - severity + - -severity + explode: false + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedCategoryOverviewList' + description: '' + /api/v1/overviews/compliance-watchlist: + get: + operationId: overviews_compliance_watchlist_list + description: 'Retrieve compliance metrics with FAIL-dominant aggregation. Without + filters: uses pre-aggregated TenantComplianceSummary. With provider filters: + queries ProviderComplianceScore with FAIL-dominant logic where any FAIL in + a requirement marks it as failed.' + summary: Get compliance watchlist overview + parameters: + - in: query + name: fields[compliance-watchlist-overviews] + schema: + type: array + items: + type: string + enum: + - id + - compliance_id + - requirements_passed + - requirements_failed + - requirements_manual + - total_requirements + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - compliance_id + - -compliance_id + - requirements_passed + - -requirements_passed + - requirements_failed + - -requirements_failed + - requirements_manual + - -requirements_manual + - total_requirements + - -total_requirements + explode: false + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedComplianceWatchlistOverviewList' + description: '' /api/v1/overviews/findings: get: operationId: overviews_findings_retrieve @@ -4230,15 +5763,20 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -4246,22 +5784,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -4271,7 +5819,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -4411,15 +5964,20 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -4427,22 +5985,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -4452,7 +6020,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -4530,6 +6103,168 @@ paths: schema: $ref: '#/components/schemas/OverviewSeverityResponse' description: '' + /api/v1/overviews/findings_severity/timeseries: + get: + operationId: overviews_findings_severity_timeseries_retrieve + description: Retrieve daily aggregated findings data grouped by severity levels + over a date range. Returns one data point per day with counts of failed findings + by severity (critical, high, medium, low, informational) and muted findings. + Days without scans are filled forward with the most recent known values. Use + date_from (required) and date_to filters to specify the range. + summary: Get findings severity data over time + parameters: + - in: query + name: fields[findings-severity-over-time] + schema: + type: array + items: + type: string + enum: + - id + - critical + - high + - medium + - low + - informational + - muted + - scan_ids + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[date_from] + schema: + type: string + format: date + - in: query + name: filter[date_to] + schema: + type: string + format: date + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - critical + - -critical + - high + - -high + - medium + - -medium + - low + - -low + - informational + - -informational + - muted + - -muted + - scan_ids + - -scan_ids + explode: false + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/FindingsSeverityOverTimeResponse' + description: '' /api/v1/overviews/providers: get: operationId: overviews_providers_retrieve @@ -4595,13 +6330,371 @@ paths: schema: $ref: '#/components/schemas/OverviewProviderCountResponse' description: '' + /api/v1/overviews/regions: + get: + operationId: overviews_regions_retrieve + description: Retrieve an aggregated summary of findings grouped by region. The + response includes the total, passed, failed, and muted findings for each region + based on the latest completed scans per provider. Standard overview filters + (inserted_at, provider filters, region filters, etc.) are supported. + summary: Get findings data by region + parameters: + - in: query + name: fields[regions-overview] + schema: + type: array + items: + type: string + enum: + - id + - provider_type + - region + - total + - fail + - muted + - pass + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - provider_type + - -provider_type + - region + - -region + - total + - -total + - fail + - -fail + - muted + - -muted + - pass + - -pass + explode: false + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OverviewRegionResponse' + description: '' + /api/v1/overviews/resource-groups: + get: + operationId: overviews_resource_groups_list + description: Retrieve aggregated resource group metrics from latest completed + scans per provider. Returns one row per resource group with total, failed, + and new failed findings counts, plus a severity breakdown showing failed findings + per severity level, and a count of distinct resources evaluated per group. + summary: Get resource group overview + parameters: + - in: query + name: fields[resource-group-overviews] + schema: + type: array + items: + type: string + enum: + - id + - total_findings + - failed_findings + - new_failed_findings + - resources_count + - severity + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 2d8d323e9cc0044b + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - iac + - kubernetes + - m365 + - mongodbatlas + - openstack + - oraclecloud + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + explode: false + style: form + - in: query + name: filter[resource_group] + schema: + type: string + - in: query + name: filter[resource_group__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - total_findings + - -total_findings + - failed_findings + - -failed_findings + - new_failed_findings + - -new_failed_findings + - resources_count + - -resources_count + - severity + - -severity + explode: false + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedResourceGroupOverviewList' + description: '' /api/v1/overviews/services: get: operationId: overviews_services_retrieve description: Retrieve an aggregated summary of findings grouped by service. The response includes the total count of findings for each service, as long - as there are at least one finding for that service. At least one of the `inserted_at` - filters must be provided. + as there are at least one finding for that service. summary: Get findings data by service parameters: - in: query @@ -4658,15 +6751,20 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -4674,22 +6772,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -4699,7 +6807,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -4756,6 +6869,90 @@ paths: schema: $ref: '#/components/schemas/OverviewServiceResponse' description: '' + /api/v1/overviews/threatscore: + get: + operationId: overviews_threatscore_retrieve + description: Retrieve ThreatScore metrics. By default, returns the latest snapshot + for each provider. Use snapshot_id to retrieve a specific historical snapshot. + summary: Get ThreatScore snapshots + parameters: + - in: query + name: fields[threatscore-snapshots] + schema: + type: array + items: + type: string + enum: + - id + - inserted_at + - scan + - provider + - compliance_id + - overall_score + - score_delta + - section_scores + - critical_requirements + - total_requirements + - passed_requirements + - failed_requirements + - manual_requirements + - total_findings + - passed_findings + - failed_findings + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - scan + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + - in: query + name: provider_id + schema: + type: string + format: uuid + description: Filter by specific provider ID + - in: query + name: provider_id__in + schema: + type: string + description: Filter by multiple provider IDs (comma-separated UUIDs) + - in: query + name: provider_type + schema: + type: string + description: Filter by provider type (aws, azure, gcp, etc.) + - in: query + name: provider_type__in + schema: + type: string + description: Filter by multiple provider types (comma-separated) + - in: query + name: snapshot_id + schema: + type: string + format: uuid + description: Retrieve a specific snapshot by ID. If not provided, returns + latest snapshots. + tags: + - Overview + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ThreatScoreSnapshotResponse' + description: '' /api/v1/processors: get: operationId: processors_list @@ -5385,15 +7582,20 @@ paths: name: filter[provider] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -5401,22 +7603,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -5426,22 +7638,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -5449,22 +7671,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -5474,7 +7706,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - name: filter[search] @@ -6009,10 +8246,27 @@ paths: - findings - failed_findings_count - url + - metadata + - details + - partition + - groups - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[inserted_at] schema: @@ -6068,19 +8322,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -6088,22 +8362,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -6113,7 +8397,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -6334,6 +8623,10 @@ paths: - findings - failed_findings_count - url + - metadata + - details + - partition + - groups - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -6368,6 +8661,87 @@ paths: schema: $ref: '#/components/schemas/ResourceResponse' description: '' + /api/v1/resources/{id}/events: + get: + operationId: resources_events_list + description: |- + Retrieve events showing modification history for a resource. Returns who modified the resource and when. Currently only available for AWS resources. + + **Note:** Some events may not appear due to CloudTrail indexing limitations. Not all AWS API calls record the resource identifier in a searchable format. + summary: Get events for a resource + parameters: + - in: query + name: fields[resource-events] + schema: + type: array + items: + type: string + enum: + - id + - event_time + - event_name + - event_source + - actor + - actor_uid + - actor_type + - source_ip_address + - user_agent + - request_data + - response_data + - error_code + - error_message + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this resource. + required: true + - in: query + name: include_read_events + schema: + type: boolean + description: 'Include read-only events (Describe*, Get*, List*, etc.). Default: + false. Set to true to include all events.' + - in: query + name: lookback_days + schema: + type: integer + description: 'Number of days to look back (default: 90, min: 1, max: 90).' + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - in: query + name: page[size] + schema: + type: integer + description: 'Maximum number of events to return (default: 50, min: 1, max: + 50).' + tags: + - Resource + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedResourceEventList' + description: '' + '400': + description: Invalid provider or parameters + '500': + description: Unexpected error retrieving events + '502': + description: Provider credentials invalid, expired, or lack required permissions + '503': + description: Provider service unavailable /api/v1/resources/latest: get: operationId: resources_latest_retrieve @@ -6393,10 +8767,27 @@ paths: - findings - failed_findings_count - url + - metadata + - details + - partition + - groups - type description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[name] schema: @@ -6437,19 +8828,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -6457,22 +8868,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -6482,7 +8903,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -6649,9 +9075,23 @@ paths: - services - regions - types + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[inserted_at] schema: @@ -6707,19 +9147,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -6727,22 +9187,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -6752,7 +9222,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -6940,9 +9415,23 @@ paths: - services - regions - types + - groups description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[groups] + schema: + type: string + - in: query + name: filter[groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[name] schema: @@ -6983,19 +9472,39 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -7003,22 +9512,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -7028,7 +9547,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -7826,15 +10350,20 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- * `aws` - AWS * `azure` - Azure @@ -7842,22 +10371,32 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b enum: + - alibabacloud - aws - azure + - cloudflare - gcp - github + - iac - kubernetes - m365 - - oci + - mongodbatlas + - openstack + - oraclecloud description: |- Multiple values may be separated by commas. @@ -7867,7 +10406,12 @@ paths: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack explode: false style: form - in: query @@ -8203,6 +10747,138 @@ paths: description: CSV file containing the compliance report '404': description: Compliance report not found + /api/v1/scans/{id}/ens: + get: + operationId: scans_ens_retrieve + description: Download ENS RD2022 compliance report (e.g., 'ens_rd2022_aws') + as a PDF file. + summary: Retrieve ENS RD2022 compliance report + parameters: + - in: query + name: fields[scans] + schema: + type: array + items: + type: string + enum: + - name + - trigger + - state + - unique_resource_count + - progress + - duration + - provider + - task + - inserted_at + - started_at + - completed_at + - scheduled_at + - next_scan_at + - processor + - url + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: PDF file containing the ENS compliance report + '202': + description: The task is in progress + '401': + description: API key missing or user not Authenticated + '403': + description: There is a problem with credentials + '404': + description: The scan has no ENS reports, or the ENS report generation task + has not started yet + /api/v1/scans/{id}/nis2: + get: + operationId: scans_nis2_retrieve + description: Download NIS2 compliance report (Directive (EU) 2022/2555) as a + PDF file. + summary: Retrieve NIS2 compliance report + parameters: + - in: query + name: fields[scans] + schema: + type: array + items: + type: string + enum: + - name + - trigger + - state + - unique_resource_count + - progress + - duration + - provider + - task + - inserted_at + - started_at + - completed_at + - scheduled_at + - next_scan_at + - processor + - url + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this scan. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: PDF file containing the NIS2 compliance report + '202': + description: The task is in progress + '401': + description: API key missing or user not Authenticated + '403': + description: There is a problem with credentials + '404': + description: The scan has no NIS2 reports, or the NIS2 report generation + task has not started yet /api/v1/scans/{id}/report: get: operationId: scans_report_retrieve @@ -9799,6 +12475,447 @@ paths: description: '' components: schemas: + AttackPathsNode: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-result-nodes + attributes: + type: object + properties: + id: + type: string + labels: + type: array + items: + type: string + properties: + type: object + additionalProperties: {} + required: + - id + - labels + - properties + AttackPathsQuery: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-queries + id: {} + attributes: + type: object + properties: + id: + type: string + name: + type: string + short_description: + type: string + description: + type: string + provider: + type: string + parameters: + type: array + items: + $ref: '#/components/schemas/AttackPathsQueryParameter' + attribution: + allOf: + - $ref: '#/components/schemas/AttackPathsQueryAttribution' + nullable: true + required: + - id + - name + - short_description + - description + - provider + - parameters + AttackPathsQueryAttribution: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-attributions + id: {} + attributes: + type: object + properties: + text: + type: string + link: + type: string + required: + - text + - link + AttackPathsQueryParameter: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-parameters + id: {} + attributes: + type: object + properties: + name: + type: string + label: + type: string + data_type: + type: string + default: string + description: + type: string + nullable: true + placeholder: + type: string + nullable: true + required: + - name + - label + AttackPathsQueryResult: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-results + attributes: + type: object + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/AttackPathsNode' + relationships: + type: array + items: + $ref: '#/components/schemas/AttackPathsRelationship' + required: + - nodes + - relationships + AttackPathsQueryRunRequestRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-run-requests + attributes: + type: object + properties: + id: + type: string + minLength: 1 + parameters: + type: object + additionalProperties: {} + required: + - id + required: + - data + AttackPathsRelationship: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-query-result-relationships + attributes: + type: object + properties: + id: + type: string + label: + type: string + source: + type: string + target: + type: string + properties: + type: object + additionalProperties: {} + required: + - id + - label + - source + - target + - properties + AttackPathsScan: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-paths-scans + id: + type: string + format: uuid + attributes: + type: object + properties: + state: + enum: + - available + - scheduled + - executing + - completed + - failed + - cancelled + type: string + description: |- + * `available` - Available + * `scheduled` - Scheduled + * `executing` - Executing + * `completed` - Completed + * `failed` - Failed + * `cancelled` - Cancelled + x-spec-enum-id: d38ba07264e1ed34 + readOnly: true + progress: + type: integer + maximum: 2147483647 + minimum: -2147483648 + provider_alias: + type: string + readOnly: true + provider_type: + type: string + readOnly: true + provider_uid: + type: string + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + duration: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + description: Duration in seconds + relationships: + type: object + properties: + provider: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - providers + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + scan: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - scans + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + nullable: true + task: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - tasks + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + nullable: true + required: + - provider + AttackPathsScanResponse: + type: object + properties: + data: + $ref: '#/components/schemas/AttackPathsScan' + required: + - data + AttackSurfaceOverview: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - attack-surface-overviews + id: {} + attributes: + type: object + properties: + id: + type: string + total_findings: + type: integer + failed_findings: + type: integer + muted_failed_findings: + type: integer + required: + - id + - total_findings + - failed_findings + - muted_failed_findings + CategoryOverview: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - category-overviews + id: {} + attributes: + type: object + properties: + id: + type: string + total_findings: + type: integer + failed_findings: + type: integer + new_failed_findings: + type: integer + severity: + description: 'Severity breakdown: {informational, low, medium, high, + critical}' + required: + - id + - total_findings + - failed_findings + - new_failed_findings + - severity ComplianceOverview: type: object required: @@ -9948,6 +13065,43 @@ components: type: string required: - regions + ComplianceWatchlistOverview: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - compliance-watchlist-overviews + id: {} + attributes: + type: object + properties: + id: + type: string + compliance_id: + type: string + requirements_passed: + type: integer + requirements_failed: + type: integer + requirements_manual: + type: integer + total_requirements: + type: integer + required: + - id + - compliance_id + - requirements_passed + - requirements_failed + - requirements_manual + - total_requirements Finding: type: object required: @@ -10015,6 +13169,17 @@ components: type: string maxLength: 100 check_metadata: {} + categories: + type: array + items: + type: string + maxLength: 100 + nullable: true + description: Categories from check metadata for efficient filtering + resource_groups: + type: string + nullable: true + description: Resource group from check metadata for efficient filtering raw_result: {} inserted_at: type: string @@ -10165,10 +13330,19 @@ components: type: array items: type: string + categories: + type: array + items: + type: string + groups: + type: array + items: + type: string required: - services - regions - resource_types + - categories FindingMetadataResponse: type: object properties: @@ -10183,6 +13357,60 @@ components: $ref: '#/components/schemas/Finding' required: - data + FindingsSeverityOverTime: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - findings-severity-over-time + id: {} + attributes: + type: object + properties: + id: + type: string + format: date + critical: + type: integer + high: + type: integer + medium: + type: integer + low: + type: integer + informational: + type: integer + muted: + type: integer + scan_ids: + type: array + items: + type: string + format: uuid + required: + - id + - critical + - high + - medium + - low + - informational + - muted + - scan_ids + FindingsSeverityOverTimeResponse: + type: object + properties: + data: + $ref: '#/components/schemas/FindingsSeverityOverTime' + required: + - data Integration: type: object required: @@ -11670,12 +14898,16 @@ components: provider_type: enum: - openai + - bedrock + - openai_compatible type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b description: |- LLM provider name * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible base_url: type: string format: uri @@ -11707,18 +14939,84 @@ components: provider_type: enum: - openai + - bedrock + - openai_compatible type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b description: |- - LLM provider name + LLM provider type. Determines which credential format to use. See 'credentials' field documentation for provider-specific requirements. * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible base_url: type: string format: uri nullable: true - maxLength: 200 + description: Base URL for the LLM provider API. Required for 'openai_compatible' + provider type. credentials: + oneOf: + - type: object + title: OpenAI Credentials + properties: + api_key: + type: string + description: OpenAI API key. Must start with 'sk-' followed by + alphanumeric characters, hyphens, or underscores. + pattern: ^sk-[\w-]+$ + required: + - api_key + - title: AWS Bedrock Credentials + oneOf: + - title: IAM Access Key Pair + type: object + description: Authenticate with AWS access key and secret key. Recommended + when you manage IAM users or roles. + properties: + access_key_id: + type: string + description: AWS access key ID. + pattern: ^AKIA[0-9A-Z]{16}$ + secret_access_key: + type: string + description: AWS secret access key. + pattern: ^[A-Za-z0-9/+=]{40}$ + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - access_key_id + - secret_access_key + - region + - title: Amazon Bedrock API Key + type: object + description: Authenticate with an Amazon Bedrock API key (bearer + token). Region is still required. + properties: + api_key: + type: string + description: Amazon Bedrock API key (bearer token). + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - api_key + - region + - type: object + title: OpenAI Compatible Credentials + properties: + api_key: + type: string + description: 'API key for OpenAI-compatible provider. The format + varies by provider. Note: The ''base_url'' field (separate from + credentials) is required when using this provider type.' + required: + - api_key writeOnly: true is_active: type: boolean @@ -11747,18 +15045,86 @@ components: provider_type: enum: - openai + - bedrock + - openai_compatible type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b description: |- - LLM provider name + LLM provider type. Determines which credential format to use. See 'credentials' field documentation for provider-specific requirements. * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible base_url: type: string format: uri nullable: true - maxLength: 200 + minLength: 1 + description: Base URL for the LLM provider API. Required for 'openai_compatible' + provider type. credentials: + oneOf: + - type: object + title: OpenAI Credentials + properties: + api_key: + type: string + description: OpenAI API key. Must start with 'sk-' followed + by alphanumeric characters, hyphens, or underscores. + pattern: ^sk-[\w-]+$ + required: + - api_key + - title: AWS Bedrock Credentials + oneOf: + - title: IAM Access Key Pair + type: object + description: Authenticate with AWS access key and secret key. + Recommended when you manage IAM users or roles. + properties: + access_key_id: + type: string + description: AWS access key ID. + pattern: ^AKIA[0-9A-Z]{16}$ + secret_access_key: + type: string + description: AWS secret access key. + pattern: ^[A-Za-z0-9/+=]{40}$ + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - access_key_id + - secret_access_key + - region + - title: Amazon Bedrock API Key + type: object + description: Authenticate with an Amazon Bedrock API key (bearer + token). Region is still required. + properties: + api_key: + type: string + description: Amazon Bedrock API key (bearer token). + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - api_key + - region + - type: object + title: OpenAI Compatible Credentials + properties: + api_key: + type: string + description: 'API key for OpenAI-compatible provider. The + format varies by provider. Note: The ''base_url'' field + (separate from credentials) is required when using this + provider type.' + required: + - api_key writeOnly: true is_active: type: boolean @@ -11804,19 +15170,85 @@ components: provider_type: enum: - openai + - bedrock + - openai_compatible type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b readOnly: true description: |- LLM provider name * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible base_url: type: string format: uri nullable: true - maxLength: 200 + description: Base URL for the LLM provider API. Required for 'openai_compatible' + provider type. credentials: + oneOf: + - type: object + title: OpenAI Credentials + properties: + api_key: + type: string + description: OpenAI API key. Must start with 'sk-' followed by + alphanumeric characters, hyphens, or underscores. + pattern: ^sk-[\w-]+$ + required: + - api_key + - title: AWS Bedrock Credentials + oneOf: + - title: IAM Access Key Pair + type: object + description: Authenticate with AWS access key and secret key. Recommended + when you manage IAM users or roles. + properties: + access_key_id: + type: string + description: AWS access key ID. + pattern: ^AKIA[0-9A-Z]{16}$ + secret_access_key: + type: string + description: AWS secret access key. + pattern: ^[A-Za-z0-9/+=]{40}$ + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - access_key_id + - secret_access_key + - region + - title: Amazon Bedrock API Key + type: object + description: Authenticate with an Amazon Bedrock API key (bearer + token). Region is still required. + properties: + api_key: + type: string + description: Amazon Bedrock API key (bearer token). + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - api_key + - region + - type: object + title: OpenAI Compatible Credentials + properties: + api_key: + type: string + description: 'API key for OpenAI-compatible provider. The format + varies by provider. Note: The ''base_url'' field (separate from + credentials) is required when using this provider type.' + required: + - api_key writeOnly: true is_active: type: boolean @@ -11840,7 +15272,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - LighthouseProviderModels + - lighthouse-models id: type: string format: uuid @@ -11913,7 +15345,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - lighthouse-config + - lighthouse-configurations id: type: string format: uuid @@ -11950,7 +15382,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - lighthouse-config + - lighthouse-configurations id: type: string format: uuid @@ -12061,6 +15493,272 @@ components: $ref: '#/components/schemas/Membership' required: - data + MuteRule: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - mute-rules + id: + type: string + format: uuid + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + name: + type: string + description: Human-readable name for this rule + maxLength: 100 + minLength: 3 + reason: + type: string + description: Reason for muting + minLength: 3 + maxLength: 500 + enabled: + type: boolean + description: Whether this rule is currently enabled + finding_uids: + type: array + items: + type: string + readOnly: true + description: List of finding UIDs that are muted by this rule + required: + - name + - reason + relationships: + type: object + properties: + created_by: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + nullable: true + MuteRuleCreate: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - mute-rules + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + name: + type: string + description: Human-readable name for this rule + maxLength: 100 + minLength: 3 + reason: + type: string + description: Reason for muting + minLength: 3 + maxLength: 500 + enabled: + type: boolean + readOnly: true + description: Whether this rule is currently enabled + finding_ids: + type: array + items: + type: string + format: uuid + writeOnly: true + description: List of Finding IDs to mute (will be converted to UIDs) + finding_uids: + type: array + items: + type: string + readOnly: true + description: List of finding UIDs that are muted by this rule + required: + - name + - reason + - finding_ids + relationships: + type: object + properties: + created_by: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + nullable: true + MuteRuleCreateRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - mute-rules + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + name: + type: string + minLength: 3 + description: Human-readable name for this rule + maxLength: 100 + reason: + type: string + minLength: 3 + description: Reason for muting + maxLength: 500 + enabled: + type: boolean + readOnly: true + description: Whether this rule is currently enabled + finding_ids: + type: array + items: + type: string + format: uuid + writeOnly: true + description: List of Finding IDs to mute (will be converted to UIDs) + finding_uids: + type: array + items: + type: string + minLength: 1 + readOnly: true + description: List of finding UIDs that are muted by this rule + required: + - name + - reason + - finding_ids + relationships: + type: object + properties: + created_by: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share + common attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + nullable: true + required: + - data + MuteRuleCreateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/MuteRuleCreate' + required: + - data + MuteRuleResponse: + type: object + properties: + data: + $ref: '#/components/schemas/MuteRule' + required: + - data OpenApiResponseResponse: type: object properties: @@ -12215,6 +15913,53 @@ components: $ref: '#/components/schemas/OverviewProvider' required: - data + OverviewRegion: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - regions-overview + id: {} + attributes: + type: object + properties: + id: + type: string + readOnly: true + provider_type: + type: string + region: + type: string + total: + type: integer + fail: + type: integer + muted: + type: integer + pass: + type: integer + required: + - provider_type + - region + - total + - fail + - muted + - pass + OverviewRegionResponse: + type: object + properties: + data: + $ref: '#/components/schemas/OverviewRegion' + required: + - data OverviewService: type: object required: @@ -12300,6 +16045,42 @@ components: $ref: '#/components/schemas/OverviewSeverity' required: - data + PaginatedAttackPathsQueryList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AttackPathsQuery' + required: + - data + PaginatedAttackPathsScanList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AttackPathsScan' + required: + - data + PaginatedAttackSurfaceOverviewList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AttackSurfaceOverview' + required: + - data + PaginatedCategoryOverviewList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/CategoryOverview' + required: + - data PaginatedComplianceOverviewAttributesList: type: object properties: @@ -12327,6 +16108,15 @@ components: $ref: '#/components/schemas/ComplianceOverview' required: - data + PaginatedComplianceWatchlistOverviewList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComplianceWatchlistOverview' + required: + - data PaginatedFindingList: type: object properties: @@ -12399,6 +16189,15 @@ components: $ref: '#/components/schemas/Membership' required: - data + PaginatedMuteRuleList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/MuteRule' + required: + - data PaginatedProcessorList: type: object properties: @@ -12435,6 +16234,24 @@ components: $ref: '#/components/schemas/ProviderSecret' required: - data + PaginatedResourceEventList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ResourceEvent' + required: + - data + PaginatedResourceGroupOverviewList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ResourceGroupOverview' + required: + - data PaginatedResourceList: type: object properties: @@ -12877,19 +16694,87 @@ components: provider_type: enum: - openai + - bedrock + - openai_compatible type: string - x-spec-enum-id: 80350f3ab09becd9 + x-spec-enum-id: 30bc523cb9ec0e8b readOnly: true description: |- LLM provider name * `openai` - OpenAI + * `bedrock` - AWS Bedrock + * `openai_compatible` - OpenAI Compatible base_url: type: string format: uri nullable: true - maxLength: 200 + minLength: 1 + description: Base URL for the LLM provider API. Required for 'openai_compatible' + provider type. credentials: + oneOf: + - type: object + title: OpenAI Credentials + properties: + api_key: + type: string + description: OpenAI API key. Must start with 'sk-' followed + by alphanumeric characters, hyphens, or underscores. + pattern: ^sk-[\w-]+$ + required: + - api_key + - title: AWS Bedrock Credentials + oneOf: + - title: IAM Access Key Pair + type: object + description: Authenticate with AWS access key and secret key. + Recommended when you manage IAM users or roles. + properties: + access_key_id: + type: string + description: AWS access key ID. + pattern: ^AKIA[0-9A-Z]{16}$ + secret_access_key: + type: string + description: AWS secret access key. + pattern: ^[A-Za-z0-9/+=]{40}$ + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - access_key_id + - secret_access_key + - region + - title: Amazon Bedrock API Key + type: object + description: Authenticate with an Amazon Bedrock API key (bearer + token). Region is still required. + properties: + api_key: + type: string + description: Amazon Bedrock API key (bearer token). + region: + type: string + description: 'AWS region identifier where Bedrock is available. + Examples: us-east-1, us-west-2, eu-west-1, ap-northeast-1.' + pattern: ^[a-z]{2}-[a-z]+-\d+$ + required: + - api_key + - region + - type: object + title: OpenAI Compatible Credentials + properties: + api_key: + type: string + description: 'API key for OpenAI-compatible provider. The + format varies by provider. Note: The ''base_url'' field + (separate from credentials) is required when using this + provider type.' + required: + - api_key writeOnly: true is_active: type: boolean @@ -12911,7 +16796,7 @@ components: member is used to describe resource objects that share common attributes and relationships. enum: - - lighthouse-config + - lighthouse-configurations id: type: string format: uuid @@ -12926,6 +16811,44 @@ components: default_models: {} required: - data + PatchedMuteRuleUpdateRequest: + type: object + properties: + data: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - mute-rules + id: + type: string + format: uuid + attributes: + type: object + properties: + name: + type: string + minLength: 3 + description: Human-readable name for this rule + maxLength: 100 + reason: + type: string + minLength: 3 + description: Reason for muting + maxLength: 500 + enabled: + type: boolean + description: Whether this rule is currently enabled + required: + - data PatchedProcessorUpdateRequest: type: object properties: @@ -13396,6 +17319,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: @@ -13429,6 +17363,123 @@ components: - fingerprint - tenancy - region + - type: object + title: MongoDB Atlas API Key + properties: + atlas_public_key: + type: string + description: MongoDB Atlas API public key. + atlas_private_key: + type: string + description: MongoDB Atlas API private key. + required: + - atlas_public_key + - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user + that will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM + user that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, + defaults to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret + - type: object + title: Cloudflare API Token + properties: + api_token: + type: string + description: Cloudflare API Token for authentication (recommended). + required: + - api_token + - type: object + title: Cloudflare API Key + Email + properties: + api_key: + type: string + description: Cloudflare Global API Key for authentication + (legacy). + api_email: + type: string + format: email + description: Email address associated with the Cloudflare + account. + required: + - api_key + - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration + file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., + https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret @@ -14423,7 +18474,12 @@ components: - kubernetes - m365 - github - - oci + - mongodbatlas + - iac + - oraclecloud + - alibabacloud + - cloudflare + - openstack type: string description: |- * `aws` - AWS @@ -14432,8 +18488,13 @@ components: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure - x-spec-enum-id: 6f034074d7104650 + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + x-spec-enum-id: 2d8d323e9cc0044b uid: type: string title: Unique identifier for the provider, set by the provider @@ -14545,9 +18606,14 @@ components: - kubernetes - m365 - github - - oci + - mongodbatlas + - iac + - oraclecloud + - alibabacloud + - cloudflare + - openstack type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b description: |- Type of provider to create. @@ -14557,7 +18623,12 @@ components: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack uid: type: string title: Unique identifier for the provider, set by the provider @@ -14601,9 +18672,14 @@ components: - kubernetes - m365 - github - - oci + - mongodbatlas + - iac + - oraclecloud + - alibabacloud + - cloudflare + - openstack type: string - x-spec-enum-id: 6f034074d7104650 + x-spec-enum-id: 2d8d323e9cc0044b description: |- Type of provider to create. @@ -14613,7 +18689,12 @@ components: * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - * `oci` - Oracle Cloud Infrastructure + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack uid: type: string minLength: 3 @@ -15322,6 +19403,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: @@ -15353,6 +19445,119 @@ components: - fingerprint - tenancy - region + - type: object + title: MongoDB Atlas API Key + properties: + atlas_public_key: + type: string + description: MongoDB Atlas API public key. + atlas_private_key: + type: string + description: MongoDB Atlas API private key. + required: + - atlas_public_key + - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user that + will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM user + that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, defaults + to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret + - type: object + title: Cloudflare API Token + properties: + api_token: + type: string + description: Cloudflare API Token for authentication (recommended). + required: + - api_token + - type: object + title: Cloudflare API Key + Email + properties: + api_key: + type: string + description: Cloudflare Global API Key for authentication (legacy). + api_email: + type: string + format: email + description: Email address associated with the Cloudflare account. + required: + - api_key + - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret_type @@ -15622,6 +19827,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: @@ -15655,6 +19871,123 @@ components: - fingerprint - tenancy - region + - type: object + title: MongoDB Atlas API Key + properties: + atlas_public_key: + type: string + description: MongoDB Atlas API public key. + atlas_private_key: + type: string + description: MongoDB Atlas API private key. + required: + - atlas_public_key + - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user + that will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM + user that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, + defaults to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret + - type: object + title: Cloudflare API Token + properties: + api_token: + type: string + description: Cloudflare API Token for authentication (recommended). + required: + - api_token + - type: object + title: Cloudflare API Key + Email + properties: + api_key: + type: string + description: Cloudflare Global API Key for authentication + (legacy). + api_email: + type: string + format: email + description: Email address associated with the Cloudflare + account. + required: + - api_key + - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration + file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., + https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret_type @@ -15940,6 +20273,17 @@ components: required: - github_app_id - github_app_key + - type: object + title: IaC Repository Credentials + properties: + repository_url: + type: string + description: Repository URL to scan for IaC files. + access_token: + type: string + description: Optional access token for private repositories. + required: + - repository_url - type: object title: Oracle Cloud Infrastructure (OCI) API Key Credentials properties: @@ -15971,6 +20315,119 @@ components: - fingerprint - tenancy - region + - type: object + title: MongoDB Atlas API Key + properties: + atlas_public_key: + type: string + description: MongoDB Atlas API public key. + atlas_private_key: + type: string + description: MongoDB Atlas API private key. + required: + - atlas_public_key + - atlas_private_key + - type: object + title: Alibaba Cloud Static Credentials + properties: + access_key_id: + type: string + description: The Alibaba Cloud access key ID for authentication. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret for authentication. + security_token: + type: string + description: The STS security token for temporary credentials + (optional). + required: + - access_key_id + - access_key_secret + - type: object + title: Alibaba Cloud RAM Role Assumption + properties: + role_arn: + type: string + description: The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole). + access_key_id: + type: string + description: The Alibaba Cloud access key ID of the RAM user that + will assume the role. + access_key_secret: + type: string + description: The Alibaba Cloud access key secret of the RAM user + that will assume the role. + role_session_name: + type: string + description: An identifier for the role session (optional, defaults + to 'ProwlerSession'). + required: + - role_arn + - access_key_id + - access_key_secret + - type: object + title: Cloudflare API Token + properties: + api_token: + type: string + description: Cloudflare API Token for authentication (recommended). + required: + - api_token + - type: object + title: Cloudflare API Key + Email + properties: + api_key: + type: string + description: Cloudflare Global API Key for authentication (legacy). + api_email: + type: string + format: email + description: Email address associated with the Cloudflare account. + required: + - api_key + - api_email + - type: object + title: OpenStack clouds.yaml Credentials + properties: + clouds_yaml_content: + type: string + description: The full content of a clouds.yaml configuration file. + clouds_yaml_cloud: + type: string + description: The name of the cloud to use from the clouds.yaml + file. + required: + - clouds_yaml_content + - clouds_yaml_cloud + - type: object + title: OpenStack Explicit Credentials + properties: + auth_url: + type: string + description: OpenStack Keystone authentication URL (e.g., https://openstack.example.com:5000/v3). + username: + type: string + description: OpenStack username for authentication. + password: + type: string + description: OpenStack password for authentication. + region_name: + type: string + description: OpenStack region name (e.g., RegionOne). + identity_api_version: + type: string + description: Keystone API version (default: 3). + user_domain_name: + type: string + description: User domain name (default: Default). + project_domain_name: + type: string + description: Project domain name (default: Default). + required: + - auth_url + - username + - password + - region_name writeOnly: true required: - secret @@ -16059,6 +20516,26 @@ components: failed_findings_count: type: integer readOnly: true + metadata: + type: string + readOnly: true + nullable: true + details: + type: string + readOnly: true + nullable: true + partition: + type: string + readOnly: true + nullable: true + groups: + type: array + items: + type: string + maxLength: 100 + readOnly: true + nullable: true + description: Groups for categorization (e.g., compute, storage, IAM) type: type: string readOnly: true @@ -16120,6 +20597,101 @@ components: readOnly: true required: - provider + ResourceEvent: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - resource-events + id: {} + attributes: + type: object + properties: + id: + type: string + event_time: + type: string + format: date-time + event_name: + type: string + event_source: + type: string + actor: + type: string + actor_uid: + type: string + nullable: true + actor_type: + type: string + nullable: true + source_ip_address: + type: string + nullable: true + user_agent: + type: string + nullable: true + request_data: + nullable: true + response_data: + nullable: true + error_code: + type: string + nullable: true + error_message: + type: string + nullable: true + required: + - id + - event_time + - event_name + - event_source + - actor + ResourceGroupOverview: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - resource-group-overviews + id: {} + attributes: + type: object + properties: + id: + type: string + total_findings: + type: integer + failed_findings: + type: integer + new_failed_findings: + type: integer + resources_count: + type: integer + severity: + description: 'Severity breakdown: {informational, low, medium, high, + critical}' + required: + - id + - total_findings + - failed_findings + - new_failed_findings + - resources_count + - severity ResourceMetadata: type: object required: @@ -16150,10 +20722,15 @@ components: type: array items: type: string + groups: + type: array + items: + type: string required: - services - regions - types + - groups ResourceMetadataResponse: type: object properties: @@ -17024,7 +21601,7 @@ components: type: object properties: data: - $ref: '#/components/schemas/ProviderGroup' + $ref: '#/components/schemas/MuteRule' required: - data Task: @@ -17531,6 +22108,143 @@ components: $ref: '#/components/schemas/Tenant' required: - data + ThreatScoreSnapshot: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - threatscore-snapshots + id: {} + attributes: + type: object + properties: + id: + type: string + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + compliance_id: + type: string + readOnly: true + description: Compliance framework ID (e.g., 'prowler_threatscore_aws') + overall_score: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + readOnly: true + description: Overall ThreatScore percentage (0-100) + score_delta: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + readOnly: true + nullable: true + description: Score change compared to previous snapshot (positive = + improvement) + section_scores: + readOnly: true + description: ThreatScore breakdown by section + critical_requirements: + readOnly: true + description: List of critical failed requirements (risk >= 4) + total_requirements: + type: integer + readOnly: true + description: Total number of requirements evaluated + passed_requirements: + type: integer + readOnly: true + description: Number of requirements with PASS status + failed_requirements: + type: integer + readOnly: true + description: Number of requirements with FAIL status + manual_requirements: + type: integer + readOnly: true + description: Number of requirements with MANUAL status + total_findings: + type: integer + readOnly: true + description: Total number of findings across all requirements + passed_findings: + type: integer + readOnly: true + description: Number of findings with PASS status + failed_findings: + type: integer + readOnly: true + description: Number of findings with FAIL status + relationships: + type: object + properties: + scan: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - scans + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + provider: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - providers + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + ThreatScoreSnapshotResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ThreatScoreSnapshot' + required: + - data Token: type: object required: @@ -18036,6 +22750,8 @@ tags: revoking tasks that have not started. - name: Scan description: Endpoints for triggering manual scans and viewing scan results. +- name: Attack Paths + description: Endpoints for Attack Paths scan status and executing Attack Paths queries. - name: Schedule description: Endpoints for managing scan schedules, allowing configuration of automated scans with different scheduling options. @@ -18068,3 +22784,7 @@ tags: - name: API Keys description: Endpoints for API keys management. These can be used as an alternative to JWT authorization. +- name: Mute Rules + description: Endpoints for simple mute rules management. These can be used as an + alternative to the Mutelist Processor if you need to mute specific findings across + your tenant with a specific reason. diff --git a/api/src/backend/api/tests/integration/test_rls_transaction.py b/api/src/backend/api/tests/integration/test_rls_transaction.py new file mode 100644 index 0000000000..6731b39d71 --- /dev/null +++ b/api/src/backend/api/tests/integration/test_rls_transaction.py @@ -0,0 +1,39 @@ +"""Tests for rls_transaction retry and fallback logic.""" + +import pytest +from django.db import DEFAULT_DB_ALIAS +from rest_framework_json_api.serializers import ValidationError + +from api.db_utils import rls_transaction + + +@pytest.mark.django_db +class TestRLSTransaction: + """Simple integration tests for rls_transaction using real DB.""" + + @pytest.fixture + def tenant(self, tenants_fixture): + return tenants_fixture[0] + + def test_success_on_primary(self, tenant): + """Basic: transaction succeeds on primary database.""" + with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor: + cursor.execute("SELECT 1") + result = cursor.fetchone() + assert result == (1,) + + def test_invalid_uuid_raises_validation_error(self): + """Invalid UUID raises ValidationError before DB operations.""" + with pytest.raises(ValidationError, match="Must be a valid UUID"): + with rls_transaction("not-a-uuid", using=DEFAULT_DB_ALIAS): + pass + + def test_custom_parameter_name(self, tenant): + """Test custom RLS parameter name.""" + custom_param = "api.custom_id" + with rls_transaction( + str(tenant.id), parameter=custom_param, using=DEFAULT_DB_ALIAS + ) as cursor: + cursor.execute("SELECT current_setting(%s, true)", [custom_param]) + result = cursor.fetchone() + assert result == (str(tenant.id),) diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 1509705894..712bc33882 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -1,10 +1,13 @@ import os +import sys +import types from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from django.conf import settings +import api import api.apps as api_apps_module from api.apps import ( ApiConfig, @@ -150,3 +153,82 @@ def test_ensure_crypto_keys_skips_when_env_vars(monkeypatch, tmp_path): # Assert: orchestrator did not trigger generation when env present assert called["ensure"] is False + + +@pytest.fixture(autouse=True) +def stub_api_modules(): + """Provide dummy modules imported during ApiConfig.ready().""" + created = [] + for name in ("api.schema_extensions", "api.signals"): + if name not in sys.modules: + sys.modules[name] = types.ModuleType(name) + created.append(name) + + yield + + for name in created: + sys.modules.pop(name, None) + + +def _set_argv(monkeypatch, argv): + monkeypatch.setattr(sys, "argv", argv, raising=False) + + +def _set_testing(monkeypatch, value): + monkeypatch.setattr(settings, "TESTING", value, raising=False) + + +def _make_app(): + return ApiConfig("api", api) + + +def test_ready_initializes_driver_for_api_process(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["gunicorn"]) + _set_testing(monkeypatch, False) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_called_once() + + +def test_ready_skips_driver_for_celery(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["celery", "-A", "api"]) + _set_testing(monkeypatch, False) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_not_called() + + +def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["manage.py", "migrate"]) + _set_testing(monkeypatch, False) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_not_called() + + +def test_ready_skips_driver_when_testing(monkeypatch): + config = _make_app() + _set_argv(monkeypatch, ["gunicorn"]) + _set_testing(monkeypatch, True) + + with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( + "api.attack_paths.database.init_driver" + ) as init_driver: + config.ready() + + init_driver.assert_not_called() diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py new file mode 100644 index 0000000000..4208107710 --- /dev/null +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -0,0 +1,174 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from rest_framework.exceptions import APIException, ValidationError + +from api.attack_paths import database as graph_database +from api.attack_paths import views_helpers + + +def test_normalize_run_payload_extracts_attributes_section(): + payload = { + "data": { + "id": "ignored", + "attributes": { + "id": "aws-rds", + "parameters": {"ip": "192.0.2.0"}, + }, + } + } + + result = views_helpers.normalize_run_payload(payload) + + assert result == {"id": "aws-rds", "parameters": {"ip": "192.0.2.0"}} + + +def test_normalize_run_payload_passthrough_for_non_dict(): + sentinel = "not-a-dict" + assert views_helpers.normalize_run_payload(sentinel) is sentinel + + +def test_prepare_query_parameters_includes_provider_and_casts( + attack_paths_query_definition_factory, +): + definition = attack_paths_query_definition_factory(cast_type=int) + result = views_helpers.prepare_query_parameters( + definition, + {"limit": "5"}, + provider_uid="123456789012", + ) + + assert result["provider_uid"] == "123456789012" + assert result["limit"] == 5 + + +@pytest.mark.parametrize( + "provided,expected_message", + [ + ({}, "Missing required parameter"), + ({"limit": 10, "extra": True}, "Unknown parameter"), + ], +) +def test_prepare_query_parameters_validates_names( + attack_paths_query_definition_factory, provided, expected_message +): + definition = attack_paths_query_definition_factory() + + with pytest.raises(ValidationError) as exc: + views_helpers.prepare_query_parameters(definition, provided, provider_uid="1") + + assert expected_message in str(exc.value) + + +def test_prepare_query_parameters_validates_cast( + attack_paths_query_definition_factory, +): + definition = attack_paths_query_definition_factory(cast_type=int) + + with pytest.raises(ValidationError) as exc: + views_helpers.prepare_query_parameters( + definition, + {"limit": "not-an-int"}, + provider_uid="1", + ) + + assert "Invalid value" in str(exc.value) + + +def test_execute_attack_paths_query_serializes_graph( + attack_paths_query_definition_factory, attack_paths_graph_stub_classes +): + definition = attack_paths_query_definition_factory( + id="aws-rds", + name="RDS", + short_description="Short desc", + description="", + cypher="MATCH (n) RETURN n", + parameters=[], + ) + parameters = {"provider_uid": "123"} + attack_paths_scan = SimpleNamespace(graph_database="tenant-db") + + node = attack_paths_graph_stub_classes.Node( + element_id="node-1", + labels=["AWSAccount"], + properties={ + "name": "account", + "complex": { + "items": [ + attack_paths_graph_stub_classes.NativeValue("value"), + {"nested": 1}, + ] + }, + }, + ) + relationship = attack_paths_graph_stub_classes.Relationship( + element_id="rel-1", + rel_type="OWNS", + start_node=node, + end_node=attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance"], {}), + properties={"weight": 1}, + ) + graph = SimpleNamespace(nodes=[node], relationships=[relationship]) + + run_result = MagicMock() + run_result.graph.return_value = graph + + session = MagicMock() + session.run.return_value = run_result + + session_ctx = MagicMock() + session_ctx.__enter__.return_value = session + session_ctx.__exit__.return_value = False + + with patch( + "api.attack_paths.views_helpers.graph_database.get_session", + return_value=session_ctx, + ) as mock_get_session: + result = views_helpers.execute_attack_paths_query( + attack_paths_scan, definition, parameters + ) + + mock_get_session.assert_called_once_with("tenant-db") + session.run.assert_called_once_with(definition.cypher, parameters) + assert result["nodes"][0]["id"] == "node-1" + assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value" + assert result["relationships"][0]["label"] == "OWNS" + + +def test_execute_attack_paths_query_wraps_graph_errors( + attack_paths_query_definition_factory, +): + definition = attack_paths_query_definition_factory( + id="aws-rds", + name="RDS", + short_description="Short desc", + description="", + cypher="MATCH (n) RETURN n", + parameters=[], + ) + attack_paths_scan = SimpleNamespace(graph_database="tenant-db") + parameters = {"provider_uid": "123"} + + class ExplodingContext: + def __enter__(self): + raise graph_database.GraphDatabaseQueryException("boom") + + def __exit__(self, exc_type, exc, tb): + return False + + with ( + patch( + "api.attack_paths.views_helpers.graph_database.get_session", + return_value=ExplodingContext(), + ), + patch("api.attack_paths.views_helpers.logger") as mock_logger, + ): + with pytest.raises(APIException): + views_helpers.execute_attack_paths_query( + attack_paths_scan, definition, parameters + ) + + mock_logger.error.assert_called_once() diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py new file mode 100644 index 0000000000..46ba101c4a --- /dev/null +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -0,0 +1,303 @@ +""" +Tests for Neo4j database lazy initialization. + +The Neo4j driver connects on first use by default. API processes may +eagerly initialize the driver during app startup, while Celery workers +remain lazy. These tests validate the database module behavior itself. +""" + +import threading +from unittest.mock import MagicMock, patch + +import pytest + + +class TestLazyInitialization: + """Test that Neo4j driver is initialized lazily on first use.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + def test_driver_not_initialized_at_import(self): + """Driver should be None after module import (no eager connection).""" + import api.attack_paths.database as db_module + + assert db_module._driver is None + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_init_driver_creates_connection_on_first_call( + self, mock_driver_factory, mock_settings + ): + """init_driver() should create connection only when called.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + assert db_module._driver is None + + result = db_module.init_driver() + + mock_driver_factory.assert_called_once() + mock_driver.verify_connectivity.assert_called_once() + assert result is mock_driver + assert db_module._driver is mock_driver + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_init_driver_returns_cached_driver_on_subsequent_calls( + self, mock_driver_factory, mock_settings + ): + """Subsequent calls should return cached driver without reconnecting.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + first_result = db_module.init_driver() + second_result = db_module.init_driver() + third_result = db_module.init_driver() + + # Only one connection attempt + assert mock_driver_factory.call_count == 1 + assert mock_driver.verify_connectivity.call_count == 1 + + # All calls return same instance + assert first_result is second_result is third_result + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_get_driver_delegates_to_init_driver( + self, mock_driver_factory, mock_settings + ): + """get_driver() should use init_driver() for lazy initialization.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + result = db_module.get_driver() + + assert result is mock_driver + mock_driver_factory.assert_called_once() + + +class TestAtexitRegistration: + """Test that atexit cleanup handler is registered correctly.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.atexit.register") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_atexit_registered_on_first_init( + self, mock_driver_factory, mock_atexit_register, mock_settings + ): + """atexit.register should be called on first initialization.""" + import api.attack_paths.database as db_module + + mock_driver_factory.return_value = MagicMock() + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + db_module.init_driver() + + mock_atexit_register.assert_called_once_with(db_module.close_driver) + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.atexit.register") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_atexit_registered_only_once( + self, mock_driver_factory, mock_atexit_register, mock_settings + ): + """atexit.register should only be called once across multiple inits. + + The double-checked locking on _driver ensures the atexit registration + block only executes once (when _driver is first created). + """ + import api.attack_paths.database as db_module + + mock_driver_factory.return_value = MagicMock() + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + db_module.init_driver() + db_module.init_driver() + db_module.init_driver() + + # Only registered once because subsequent calls hit the fast path + assert mock_atexit_register.call_count == 1 + + +class TestCloseDriver: + """Test driver cleanup functionality.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + def test_close_driver_closes_and_clears_driver(self): + """close_driver() should close the driver and set it to None.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + db_module._driver = mock_driver + + db_module.close_driver() + + mock_driver.close.assert_called_once() + assert db_module._driver is None + + def test_close_driver_handles_none_driver(self): + """close_driver() should handle case where driver is None.""" + import api.attack_paths.database as db_module + + db_module._driver = None + + # Should not raise + db_module.close_driver() + + assert db_module._driver is None + + def test_close_driver_clears_driver_even_on_close_error(self): + """Driver should be cleared even if close() raises an exception.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver.close.side_effect = Exception("Connection error") + db_module._driver = mock_driver + + with pytest.raises(Exception, match="Connection error"): + db_module.close_driver() + + # Driver should still be cleared + assert db_module._driver is None + + +class TestThreadSafety: + """Test thread-safe initialization.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + """Reset module-level singleton state before each test.""" + import api.attack_paths.database as db_module + + original_driver = db_module._driver + + db_module._driver = None + + yield + + db_module._driver = original_driver + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_concurrent_init_creates_single_driver( + self, mock_driver_factory, mock_settings + ): + """Multiple threads calling init_driver() should create only one driver.""" + import api.attack_paths.database as db_module + + mock_driver = MagicMock() + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + results = [] + errors = [] + + def call_init(): + try: + result = db_module.init_driver() + results.append(result) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=call_init) for _ in range(10)] + + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Threads raised errors: {errors}" + + # Only one driver created + assert mock_driver_factory.call_count == 1 + + # All threads got the same driver instance + assert all(r is mock_driver for r in results) + assert len(results) == 10 diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 7312335921..8774f33787 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -6,7 +6,6 @@ from api.compliance import ( get_prowler_provider_checks, get_prowler_provider_compliance, load_prowler_checks, - load_prowler_compliance, ) from api.models import Provider @@ -35,55 +34,6 @@ class TestCompliance: assert compliance_data == mock_compliance.get_bulk.return_value mock_compliance.get_bulk.assert_called_once_with(provider_type) - @patch("api.models.Provider.ProviderChoices") - @patch("api.compliance.get_prowler_provider_compliance") - @patch("api.compliance.generate_compliance_overview_template") - @patch("api.compliance.load_prowler_checks") - def test_load_prowler_compliance( - self, - mock_load_prowler_checks, - mock_generate_compliance_overview_template, - mock_get_prowler_provider_compliance, - mock_provider_choices, - ): - mock_provider_choices.values = ["aws", "azure"] - - compliance_data_aws = {"compliance_aws": MagicMock()} - compliance_data_azure = {"compliance_azure": MagicMock()} - - compliance_data_dict = { - "aws": compliance_data_aws, - "azure": compliance_data_azure, - } - - def mock_get_compliance(provider_type): - return compliance_data_dict[provider_type] - - mock_get_prowler_provider_compliance.side_effect = mock_get_compliance - - mock_generate_compliance_overview_template.return_value = { - "template_key": "template_value" - } - - mock_load_prowler_checks.return_value = {"checks_key": "checks_value"} - - load_prowler_compliance() - - from api.compliance import PROWLER_CHECKS, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE - - assert PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE == { - "template_key": "template_value" - } - assert PROWLER_CHECKS == {"checks_key": "checks_value"} - - expected_prowler_compliance = compliance_data_dict - mock_get_prowler_provider_compliance.assert_any_call("aws") - mock_get_prowler_provider_compliance.assert_any_call("azure") - mock_generate_compliance_overview_template.assert_called_once_with( - expected_prowler_compliance - ) - mock_load_prowler_checks.assert_called_once_with(expected_prowler_compliance) - @patch("api.compliance.get_prowler_provider_checks") @patch("api.models.Provider.ProviderChoices") def test_load_prowler_checks( diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py index b4ec73e715..f706ab8c71 100644 --- a/api/src/backend/api/tests/test_db_utils.py +++ b/api/src/backend/api/tests/test_db_utils.py @@ -1,12 +1,15 @@ from datetime import datetime, timezone from enum import Enum -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from django.conf import settings +from django.db import DEFAULT_DB_ALIAS, OperationalError from freezegun import freeze_time +from rest_framework_json_api.serializers import ValidationError from api.db_utils import ( + POSTGRES_TENANT_VAR, _should_create_index_on_partition, batch_delete, create_objects_in_batches, @@ -14,11 +17,22 @@ from api.db_utils import ( generate_api_key_prefix, generate_random_token, one_week_from_now, + rls_transaction, update_objects_in_batches, ) from api.models import Provider +@pytest.fixture +def enable_read_replica(): + """ + Fixture to enable READ_REPLICA_ALIAS for tests that need replica functionality. + This avoids polluting the global test configuration. + """ + with patch("api.db_utils.READ_REPLICA_ALIAS", "replica"): + yield "replica" + + class TestEnumToChoices: def test_enum_to_choices_simple(self): class Color(Enum): @@ -339,3 +353,498 @@ class TestGenerateApiKeyPrefix: prefix = generate_api_key_prefix() random_part = prefix[3:] # Strip 'pk_' assert all(char in allowed_chars for char in random_part) + + +@pytest.mark.django_db +class TestRlsTransaction: + def test_rls_transaction_valid_uuid_string(self, tenants_fixture): + """Test rls_transaction with valid UUID string.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with rls_transaction(tenant_id) as cursor: + assert cursor is not None + cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR]) + result = cursor.fetchone() + assert result[0] == tenant_id + + def test_rls_transaction_valid_uuid_object(self, tenants_fixture): + """Test rls_transaction with UUID object.""" + tenant = tenants_fixture[0] + + with rls_transaction(tenant.id) as cursor: + assert cursor is not None + cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR]) + result = cursor.fetchone() + assert result[0] == str(tenant.id) + + def test_rls_transaction_invalid_uuid_raises_validation_error(self): + """Test rls_transaction raises ValidationError for invalid UUID.""" + invalid_uuid = "not-a-valid-uuid" + + with pytest.raises(ValidationError, match="Must be a valid UUID"): + with rls_transaction(invalid_uuid): + pass + + def test_rls_transaction_uses_default_database_when_no_alias(self, tenants_fixture): + """Test rls_transaction uses DEFAULT_DB_ALIAS when no alias specified.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=None): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic"): + with rls_transaction(tenant_id): + pass + + mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS) + + def test_rls_transaction_uses_specified_alias(self, tenants_fixture): + """Test rls_transaction uses specified database alias via using parameter.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + custom_alias = "custom_db" + + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic"): + with patch("api.db_utils.set_read_db_alias") as mock_set_alias: + with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias: + mock_set_alias.return_value = "test_token" + with rls_transaction(tenant_id, using=custom_alias): + pass + + mock_connections.__getitem__.assert_called_with(custom_alias) + mock_set_alias.assert_called_once_with(custom_alias) + mock_reset_alias.assert_called_once_with("test_token") + + def test_rls_transaction_uses_read_replica_from_router( + self, tenants_fixture, enable_read_replica + ): + """Test rls_transaction uses read replica alias from router.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic"): + with patch("api.db_utils.set_read_db_alias") as mock_set_alias: + with patch( + "api.db_utils.reset_read_db_alias" + ) as mock_reset_alias: + mock_set_alias.return_value = "test_token" + with rls_transaction(tenant_id): + pass + + mock_connections.__getitem__.assert_called() + mock_set_alias.assert_called_once() + mock_reset_alias.assert_called_once() + + def test_rls_transaction_fallback_to_default_when_alias_not_in_connections( + self, tenants_fixture + ): + """Test rls_transaction falls back to DEFAULT_DB_ALIAS when alias not in connections.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + invalid_alias = "nonexistent_db" + + with patch("api.db_utils.get_read_db_alias", return_value=invalid_alias): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + + def contains_check(alias): + return alias == DEFAULT_DB_ALIAS + + mock_connections.__contains__.side_effect = contains_check + mock_connections.__getitem__.return_value = mock_conn + + with patch("api.db_utils.transaction.atomic"): + with rls_transaction(tenant_id): + pass + + mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS) + + def test_rls_transaction_successful_execution_on_replica_no_retries( + self, tenants_fixture, enable_read_replica + ): + """Test successful execution on replica without retries.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic"): + with patch("api.db_utils.set_read_db_alias", return_value="token"): + with patch("api.db_utils.reset_read_db_alias"): + with rls_transaction(tenant_id): + pass + + assert mock_cursor.execute.call_count == 1 + + def test_rls_transaction_retry_with_exponential_backoff_on_operational_error( + self, tenants_fixture, enable_read_replica + ): + """Test retry with exponential backoff on OperationalError on replica.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + call_count = 0 + + def atomic_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise OperationalError("Connection error") + return MagicMock( + __enter__=MagicMock(return_value=None), + __exit__=MagicMock(return_value=False), + ) + + with patch( + "api.db_utils.transaction.atomic", side_effect=atomic_side_effect + ): + with patch("api.db_utils.time.sleep") as mock_sleep: + with patch( + "api.db_utils.set_read_db_alias", return_value="token" + ): + with patch("api.db_utils.reset_read_db_alias"): + with patch("api.db_utils.logger") as mock_logger: + with rls_transaction(tenant_id): + pass + + assert mock_sleep.call_count == 2 + mock_sleep.assert_any_call(0.5) + mock_sleep.assert_any_call(1.0) + assert mock_logger.info.call_count == 2 + + def test_rls_transaction_max_three_attempts_for_replica( + self, tenants_fixture, enable_read_replica + ): + """Test maximum 3 attempts for replica database.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic") as mock_atomic: + mock_atomic.side_effect = OperationalError("Persistent error") + + with patch("api.db_utils.time.sleep"): + with patch( + "api.db_utils.set_read_db_alias", return_value="token" + ): + with patch("api.db_utils.reset_read_db_alias"): + with pytest.raises(OperationalError): + with rls_transaction(tenant_id): + pass + + assert mock_atomic.call_count == 3 + + def test_rls_transaction_only_one_attempt_for_primary(self, tenants_fixture): + """Test only 1 attempt for primary database.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=None): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic") as mock_atomic: + mock_atomic.side_effect = OperationalError("Primary error") + + with pytest.raises(OperationalError): + with rls_transaction(tenant_id): + pass + + assert mock_atomic.call_count == 1 + + def test_rls_transaction_fallback_to_primary_after_max_attempts( + self, tenants_fixture, enable_read_replica + ): + """Test fallback to primary DB after max attempts on replica.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + call_count = 0 + + def atomic_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise OperationalError("Replica error") + return MagicMock( + __enter__=MagicMock(return_value=None), + __exit__=MagicMock(return_value=False), + ) + + with patch( + "api.db_utils.transaction.atomic", side_effect=atomic_side_effect + ): + with patch("api.db_utils.time.sleep"): + with patch( + "api.db_utils.set_read_db_alias", return_value="token" + ): + with patch("api.db_utils.reset_read_db_alias"): + with patch("api.db_utils.logger") as mock_logger: + with rls_transaction(tenant_id): + pass + + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args[0][0] + assert "falling back to primary DB" in warning_msg + + def test_rls_transaction_logger_warning_on_fallback( + self, tenants_fixture, enable_read_replica + ): + """Test logger warnings are emitted on fallback to primary.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + call_count = 0 + + def atomic_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise OperationalError("Replica error") + return MagicMock( + __enter__=MagicMock(return_value=None), + __exit__=MagicMock(return_value=False), + ) + + with patch( + "api.db_utils.transaction.atomic", side_effect=atomic_side_effect + ): + with patch("api.db_utils.time.sleep"): + with patch( + "api.db_utils.set_read_db_alias", return_value="token" + ): + with patch("api.db_utils.reset_read_db_alias"): + with patch("api.db_utils.logger") as mock_logger: + with rls_transaction(tenant_id): + pass + + assert mock_logger.info.call_count == 2 + assert mock_logger.warning.call_count == 1 + + def test_rls_transaction_operational_error_raised_immediately_on_primary( + self, tenants_fixture + ): + """Test OperationalError raised immediately on primary without retry.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=None): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic") as mock_atomic: + mock_atomic.side_effect = OperationalError("Primary error") + + with patch("api.db_utils.time.sleep") as mock_sleep: + with pytest.raises(OperationalError): + with rls_transaction(tenant_id): + pass + + mock_sleep.assert_not_called() + + def test_rls_transaction_operational_error_raised_after_max_attempts( + self, tenants_fixture, enable_read_replica + ): + """Test OperationalError raised after max attempts on replica.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic") as mock_atomic: + mock_atomic.side_effect = OperationalError( + "Persistent replica error" + ) + + with patch("api.db_utils.time.sleep"): + with patch( + "api.db_utils.set_read_db_alias", return_value="token" + ): + with patch("api.db_utils.reset_read_db_alias"): + with pytest.raises(OperationalError): + with rls_transaction(tenant_id): + pass + + def test_rls_transaction_router_token_set_for_non_default_alias( + self, tenants_fixture + ): + """Test router token is set when using non-default alias.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + custom_alias = "custom_db" + + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic"): + with patch("api.db_utils.set_read_db_alias") as mock_set_alias: + with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias: + mock_set_alias.return_value = "test_token" + with rls_transaction(tenant_id, using=custom_alias): + pass + + mock_set_alias.assert_called_once_with(custom_alias) + mock_reset_alias.assert_called_once_with("test_token") + + def test_rls_transaction_router_token_reset_in_finally_block(self, tenants_fixture): + """Test router token is reset in finally block even on error.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + custom_alias = "custom_db" + + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic") as mock_atomic: + mock_atomic.side_effect = Exception("Unexpected error") + + with patch("api.db_utils.set_read_db_alias", return_value="test_token"): + with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias: + with pytest.raises(Exception): + with rls_transaction(tenant_id, using=custom_alias): + pass + + mock_reset_alias.assert_called_once_with("test_token") + + def test_rls_transaction_router_token_not_set_for_default_alias( + self, tenants_fixture + ): + """Test router token is not set when using default alias.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with patch("api.db_utils.get_read_db_alias", return_value=None): + with patch("api.db_utils.connections") as mock_connections: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_connections.__getitem__.return_value = mock_conn + mock_connections.__contains__.return_value = True + + with patch("api.db_utils.transaction.atomic"): + with patch("api.db_utils.set_read_db_alias") as mock_set_alias: + with patch( + "api.db_utils.reset_read_db_alias" + ) as mock_reset_alias: + with rls_transaction(tenant_id): + pass + + mock_set_alias.assert_not_called() + mock_reset_alias.assert_not_called() + + def test_rls_transaction_set_config_query_executed_with_correct_params( + self, tenants_fixture + ): + """Test SET_CONFIG_QUERY executed with correct parameters.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with rls_transaction(tenant_id) as cursor: + cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR]) + result = cursor.fetchone() + assert result[0] == tenant_id + + def test_rls_transaction_custom_parameter(self, tenants_fixture): + """Test rls_transaction with custom parameter name.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + custom_param = "api.user_id" + + with rls_transaction(tenant_id, parameter=custom_param) as cursor: + cursor.execute("SELECT current_setting(%s)", [custom_param]) + result = cursor.fetchone() + assert result[0] == tenant_id + + def test_rls_transaction_cursor_yielded_correctly(self, tenants_fixture): + """Test cursor is yielded correctly.""" + tenant = tenants_fixture[0] + tenant_id = str(tenant.id) + + with rls_transaction(tenant_id) as cursor: + assert cursor is not None + cursor.execute("SELECT 1") + result = cursor.fetchone() + assert result[0] == 1 diff --git a/api/src/backend/api/tests/test_decorators.py b/api/src/backend/api/tests/test_decorators.py index 8ac31b1d49..9a113abad8 100644 --- a/api/src/backend/api/tests/test_decorators.py +++ b/api/src/backend/api/tests/test_decorators.py @@ -2,9 +2,12 @@ import uuid from unittest.mock import call, patch import pytest +from django.core.exceptions import ObjectDoesNotExist +from django.db import IntegrityError from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY -from api.decorators import set_tenant +from api.decorators import handle_provider_deletion, set_tenant +from api.exceptions import ProviderDeletedException @pytest.mark.django_db @@ -34,3 +37,142 @@ class TestSetTenantDecorator: with pytest.raises(KeyError): random_func("test_arg") + + +@pytest.mark.django_db +class TestHandleProviderDeletionDecorator: + def test_success_no_exception(self, tenants_fixture, providers_fixture): + """Decorated function runs normally when no exception is raised.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + @handle_provider_deletion + def task_func(**kwargs): + return "success" + + result = task_func( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + ) + assert result == "success" + + @patch("api.decorators.rls_transaction") + @patch("api.decorators.Provider.objects.filter") + def test_provider_deleted_with_provider_id( + self, mock_filter, mock_rls, tenants_fixture + ): + """Raises ProviderDeletedException when provider_id provided and provider deleted.""" + tenant = tenants_fixture[0] + deleted_provider_id = str(uuid.uuid4()) + + mock_rls.return_value.__enter__ = lambda s: None + mock_rls.return_value.__exit__ = lambda s, *args: None + mock_filter.return_value.exists.return_value = False + + @handle_provider_deletion + def task_func(**kwargs): + raise ObjectDoesNotExist("Some object not found") + + with pytest.raises(ProviderDeletedException) as exc_info: + task_func(tenant_id=str(tenant.id), provider_id=deleted_provider_id) + + assert deleted_provider_id in str(exc_info.value) + + @patch("api.decorators.rls_transaction") + @patch("api.decorators.Provider.objects.filter") + @patch("api.decorators.Scan.objects.filter") + def test_provider_deleted_with_scan_id( + self, mock_scan_filter, mock_provider_filter, mock_rls, tenants_fixture + ): + """Raises ProviderDeletedException when scan exists but provider deleted.""" + tenant = tenants_fixture[0] + scan_id = str(uuid.uuid4()) + provider_id = str(uuid.uuid4()) + + mock_rls.return_value.__enter__ = lambda s: None + mock_rls.return_value.__exit__ = lambda s, *args: None + + mock_scan = type("MockScan", (), {"provider_id": provider_id})() + mock_scan_filter.return_value.first.return_value = mock_scan + mock_provider_filter.return_value.exists.return_value = False + + @handle_provider_deletion + def task_func(**kwargs): + raise ObjectDoesNotExist("Some object not found") + + with pytest.raises(ProviderDeletedException) as exc_info: + task_func(tenant_id=str(tenant.id), scan_id=scan_id) + + assert provider_id in str(exc_info.value) + + @patch("api.decorators.rls_transaction") + @patch("api.decorators.Scan.objects.filter") + def test_scan_deleted_cascade(self, mock_scan_filter, mock_rls, tenants_fixture): + """Raises ProviderDeletedException when scan was deleted (CASCADE from provider).""" + tenant = tenants_fixture[0] + scan_id = str(uuid.uuid4()) + + mock_rls.return_value.__enter__ = lambda s: None + mock_rls.return_value.__exit__ = lambda s, *args: None + mock_scan_filter.return_value.first.return_value = None + + @handle_provider_deletion + def task_func(**kwargs): + raise ObjectDoesNotExist("Some object not found") + + with pytest.raises(ProviderDeletedException) as exc_info: + task_func(tenant_id=str(tenant.id), scan_id=scan_id) + + assert scan_id in str(exc_info.value) + + @patch("api.decorators.rls_transaction") + @patch("api.decorators.Provider.objects.filter") + def test_provider_exists_reraises_original( + self, mock_filter, mock_rls, tenants_fixture, providers_fixture + ): + """Re-raises original exception when provider still exists.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + mock_rls.return_value.__enter__ = lambda s: None + mock_rls.return_value.__exit__ = lambda s, *args: None + mock_filter.return_value.exists.return_value = True + + @handle_provider_deletion + def task_func(**kwargs): + raise ObjectDoesNotExist("Actual object missing") + + with pytest.raises(ObjectDoesNotExist): + task_func(tenant_id=str(tenant.id), provider_id=str(provider.id)) + + @patch("api.decorators.rls_transaction") + @patch("api.decorators.Provider.objects.filter") + def test_integrity_error_provider_deleted( + self, mock_filter, mock_rls, tenants_fixture + ): + """Raises ProviderDeletedException on IntegrityError when provider deleted.""" + tenant = tenants_fixture[0] + deleted_provider_id = str(uuid.uuid4()) + + mock_rls.return_value.__enter__ = lambda s: None + mock_rls.return_value.__exit__ = lambda s, *args: None + mock_filter.return_value.exists.return_value = False + + @handle_provider_deletion + def task_func(**kwargs): + raise IntegrityError("FK constraint violation") + + with pytest.raises(ProviderDeletedException): + task_func(tenant_id=str(tenant.id), provider_id=deleted_provider_id) + + def test_missing_provider_and_scan_raises_assertion(self, tenants_fixture): + """Raises AssertionError when neither provider_id nor scan_id in kwargs.""" + + @handle_provider_deletion + def task_func(**kwargs): + raise ObjectDoesNotExist("Some object not found") + + with pytest.raises(AssertionError) as exc_info: + task_func(tenant_id=str(tenants_fixture[0].id)) + + assert "provider or scan" in str(exc_info.value) diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index 79d405d4a7..13878794b1 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -1,9 +1,21 @@ +from datetime import datetime, timezone + import pytest from allauth.socialaccount.models import SocialApp from django.core.exceptions import ValidationError +from django.db import IntegrityError from api.db_router import MainRouter -from api.models import Resource, ResourceTag, SAMLConfiguration, SAMLDomainIndex +from api.models import ( + ProviderComplianceScore, + Resource, + ResourceTag, + SAMLConfiguration, + SAMLDomainIndex, + StateChoices, + StatusChoices, + TenantComplianceSummary, +) @pytest.mark.django_db @@ -324,3 +336,159 @@ class TestSAMLConfigurationModel: errors = exc_info.value.message_dict assert "metadata_xml" in errors assert "There is a problem with your metadata." in errors["metadata_xml"][0] + + +@pytest.mark.django_db +class TestProviderComplianceScoreModel: + def test_create_provider_compliance_score(self, providers_fixture, scans_fixture): + provider = providers_fixture[0] + scan = scans_fixture[0] + scan.completed_at = datetime.now(timezone.utc) + scan.save() + + score = ProviderComplianceScore.objects.create( + tenant_id=provider.tenant_id, + provider=provider, + scan=scan, + compliance_id="aws_cis_2.0", + requirement_id="req_1", + requirement_status=StatusChoices.PASS, + scan_completed_at=scan.completed_at, + ) + + assert score.compliance_id == "aws_cis_2.0" + assert score.requirement_id == "req_1" + assert score.requirement_status == StatusChoices.PASS + + def test_unique_constraint_per_provider_compliance_requirement( + self, providers_fixture, scans_fixture + ): + provider = providers_fixture[0] + scan = scans_fixture[0] + scan.completed_at = datetime.now(timezone.utc) + scan.save() + + ProviderComplianceScore.objects.create( + tenant_id=provider.tenant_id, + provider=provider, + scan=scan, + compliance_id="aws_cis_2.0", + requirement_id="req_1", + requirement_status=StatusChoices.PASS, + scan_completed_at=scan.completed_at, + ) + + with pytest.raises(IntegrityError): + ProviderComplianceScore.objects.create( + tenant_id=provider.tenant_id, + provider=provider, + scan=scan, + compliance_id="aws_cis_2.0", + requirement_id="req_1", + requirement_status=StatusChoices.FAIL, + scan_completed_at=scan.completed_at, + ) + + def test_different_providers_same_requirement_allowed( + self, providers_fixture, scans_fixture + ): + provider1, provider2, *_ = providers_fixture + scan1 = scans_fixture[0] + scan1.completed_at = datetime.now(timezone.utc) + scan1.save() + + scan2 = scans_fixture[2] + scan2.state = StateChoices.COMPLETED + scan2.completed_at = datetime.now(timezone.utc) + scan2.save() + + score1 = ProviderComplianceScore.objects.create( + tenant_id=provider1.tenant_id, + provider=provider1, + scan=scan1, + compliance_id="aws_cis_2.0", + requirement_id="req_1", + requirement_status=StatusChoices.PASS, + scan_completed_at=scan1.completed_at, + ) + + score2 = ProviderComplianceScore.objects.create( + tenant_id=provider2.tenant_id, + provider=provider2, + scan=scan2, + compliance_id="aws_cis_2.0", + requirement_id="req_1", + requirement_status=StatusChoices.FAIL, + scan_completed_at=scan2.completed_at, + ) + + assert score1.id != score2.id + assert score1.requirement_status != score2.requirement_status + + +@pytest.mark.django_db +class TestTenantComplianceSummaryModel: + def test_create_tenant_compliance_summary(self, tenants_fixture): + tenant = tenants_fixture[0] + + summary = TenantComplianceSummary.objects.create( + tenant_id=tenant.id, + compliance_id="aws_cis_2.0", + requirements_passed=5, + requirements_failed=2, + requirements_manual=1, + total_requirements=8, + ) + + assert summary.compliance_id == "aws_cis_2.0" + assert summary.requirements_passed == 5 + assert summary.requirements_failed == 2 + assert summary.requirements_manual == 1 + assert summary.total_requirements == 8 + assert summary.updated_at is not None + + def test_unique_constraint_per_tenant_compliance(self, tenants_fixture): + tenant = tenants_fixture[0] + + TenantComplianceSummary.objects.create( + tenant_id=tenant.id, + compliance_id="aws_cis_2.0", + requirements_passed=5, + requirements_failed=2, + requirements_manual=1, + total_requirements=8, + ) + + with pytest.raises(IntegrityError): + TenantComplianceSummary.objects.create( + tenant_id=tenant.id, + compliance_id="aws_cis_2.0", + requirements_passed=3, + requirements_failed=4, + requirements_manual=1, + total_requirements=8, + ) + + def test_different_tenants_same_compliance_allowed(self, tenants_fixture): + tenant1, tenant2, *_ = tenants_fixture + + summary1 = TenantComplianceSummary.objects.create( + tenant_id=tenant1.id, + compliance_id="aws_cis_2.0", + requirements_passed=5, + requirements_failed=2, + requirements_manual=1, + total_requirements=8, + ) + + summary2 = TenantComplianceSummary.objects.create( + tenant_id=tenant2.id, + compliance_id="aws_cis_2.0", + requirements_passed=3, + requirements_failed=4, + requirements_manual=1, + total_requirements=8, + ) + + assert summary1.id != summary2.id + assert summary1.requirements_passed != summary2.requirements_passed diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 4926d722b0..236cbf87a2 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -16,13 +16,19 @@ from api.utils import ( return_prowler_provider, validate_invitation, ) +from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudProvider from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection from prowler.providers.azure.azure_provider import AzureProvider +from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.github.github_provider import GithubProvider +from prowler.providers.iac.iac_provider import IacProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider from prowler.providers.m365.m365_provider import M365Provider -from prowler.providers.oraclecloud.oci_provider import OciProvider +from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider +from prowler.providers.openstack.openstack_provider import OpenstackProvider +from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider class TestMergeDicts: @@ -109,7 +115,13 @@ class TestReturnProwlerProvider: (Provider.ProviderChoices.AZURE.value, AzureProvider), (Provider.ProviderChoices.KUBERNETES.value, KubernetesProvider), (Provider.ProviderChoices.M365.value, M365Provider), - (Provider.ProviderChoices.OCI.value, OciProvider), + (Provider.ProviderChoices.GITHUB.value, GithubProvider), + (Provider.ProviderChoices.MONGODBATLAS.value, MongodbatlasProvider), + (Provider.ProviderChoices.ORACLECLOUD.value, OraclecloudProvider), + (Provider.ProviderChoices.IAC.value, IacProvider), + (Provider.ProviderChoices.ALIBABACLOUD.value, AlibabacloudProvider), + (Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider), + (Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider), ], ) def test_return_prowler_provider(self, provider_type, expected_provider): @@ -206,7 +218,19 @@ class TestGetProwlerProviderKwargs: {"organizations": ["provider_uid"]}, ), ( - Provider.ProviderChoices.OCI.value, + Provider.ProviderChoices.ORACLECLOUD.value, + {}, + ), + ( + Provider.ProviderChoices.MONGODBATLAS.value, + {"atlas_organization_id": "provider_uid"}, + ), + ( + Provider.ProviderChoices.CLOUDFLARE.value, + {"filter_accounts": ["provider_uid"]}, + ), + ( + Provider.ProviderChoices.OPENSTACK.value, {}, ), ], @@ -246,6 +270,72 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, "mutelist_content": {"key": "value"}} assert result == expected_result + def test_get_prowler_provider_kwargs_iac_provider(self): + """Test that IaC provider gets correct kwargs with repository URL.""" + provider_uid = "https://github.com/org/repo" + secret_dict = {"access_token": "test_token"} + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.IAC.value + provider.secret = secret_mock + provider.uid = provider_uid + + result = get_prowler_provider_kwargs(provider) + + expected_result = { + "scan_repository_url": provider_uid, + "oauth_app_token": "test_token", + } + assert result == expected_result + + def test_get_prowler_provider_kwargs_iac_provider_without_token(self): + """Test that IaC provider works without access token for public repos.""" + provider_uid = "https://github.com/org/public-repo" + secret_dict = {} + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.IAC.value + provider.secret = secret_mock + provider.uid = provider_uid + + result = get_prowler_provider_kwargs(provider) + + expected_result = {"scan_repository_url": provider_uid} + assert result == expected_result + + def test_get_prowler_provider_kwargs_iac_provider_ignores_mutelist(self): + """Test that IaC provider does NOT receive mutelist_content. + + IaC provider uses Trivy's built-in mutelist logic, so it should not + receive mutelist_content even when a mutelist processor is configured. + """ + provider_uid = "https://github.com/org/repo" + secret_dict = {"access_token": "test_token"} + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + mutelist_processor = MagicMock() + mutelist_processor.configuration = {"Mutelist": {"key": "value"}} + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.IAC.value + provider.secret = secret_mock + provider.uid = provider_uid + + result = get_prowler_provider_kwargs(provider, mutelist_processor) + + # IaC provider should NOT have mutelist_content + assert "mutelist_content" not in result + expected_result = { + "scan_repository_url": provider_uid, + "oauth_app_token": "test_token", + } + assert result == expected_result + def test_get_prowler_provider_kwargs_unsupported_provider(self): # Setup provider_uid = "provider_uid" diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 0aa7efd0b9..02aefa18ff 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3,7 +3,8 @@ import io import json import os import tempfile -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal from pathlib import Path from types import SimpleNamespace from unittest.mock import ANY, MagicMock, Mock, patch @@ -31,9 +32,18 @@ from django_celery_results.models import TaskResult from rest_framework import status from rest_framework.response import Response +from api.attack_paths import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) from api.compliance import get_compliance_frameworks from api.db_router import MainRouter from api.models import ( + AttackSurfaceOverview, + ComplianceOverviewSummary, + ComplianceRequirementOverview, + DailySeveritySummary, + Finding, Integration, Invitation, LighthouseProviderConfiguration, @@ -53,14 +63,18 @@ from api.models import ( Scan, ScanSummary, StateChoices, + StatusChoices, Task, TenantAPIKey, + ThreatScoreSnapshot, User, UserRoleRelationship, ) from api.rls import Tenant from api.v1.serializers import TokenSerializer from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView +from prowler.lib.check.models import Severity +from prowler.lib.outputs.finding import Status class TestViewSet: @@ -1140,6 +1154,36 @@ class TestProviderViewSet: "uid": "a12345678901234567890123456789012345678", "alias": "Long Username", }, + { + "provider": "iac", + "uid": "https://github.com/user/repo.git", + "alias": "Git Repo", + }, + { + "provider": "iac", + "uid": "https://gitlab.com/user/project", + "alias": "GitLab Repo", + }, + { + "provider": "mongodbatlas", + "uid": "64b1d3c0e4b03b1234567890", + "alias": "Atlas Organization", + }, + { + "provider": "alibabacloud", + "uid": "1234567890123456", + "alias": "Alibaba Cloud Account", + }, + { + "provider": "cloudflare", + "uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "alias": "Cloudflare Account", + }, + { + "provider": "openstack", + "uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "alias": "OpenStack Project", + }, ] ), ) @@ -1153,6 +1197,161 @@ class TestProviderViewSet: assert Provider.objects.get().uid == provider_json_payload["uid"] assert Provider.objects.get().alias == provider_json_payload["alias"] + @pytest.mark.parametrize( + "provider_json_payload", + ( + [ + {"provider": "aws", "uid": "111111111111", "alias": "test"}, + {"provider": "gcp", "uid": "a12322-test54321", "alias": "test"}, + { + "provider": "kubernetes", + "uid": "kubernetes-test-123456789", + "alias": "test", + }, + { + "provider": "kubernetes", + "uid": "arn:aws:eks:us-east-1:111122223333:cluster/test-cluster-long-name-123456789", + "alias": "EKS", + }, + { + "provider": "kubernetes", + "uid": "gke_aaaa-dev_europe-test1_dev-aaaa-test-cluster-long-name-123456789", + "alias": "GKE", + }, + { + "provider": "kubernetes", + "uid": "gke_project/cluster-name", + "alias": "GKE", + }, + { + "provider": "kubernetes", + "uid": "admin@k8s-demo", + "alias": "test", + }, + { + "provider": "azure", + "uid": "8851db6b-42e5-4533-aa9e-30a32d67e875", + "alias": "test", + }, + { + "provider": "m365", + "uid": "TestingPro.onmicrosoft.com", + "alias": "test", + }, + { + "provider": "m365", + "uid": "subdomain.domain.es", + "alias": "test", + }, + { + "provider": "m365", + "uid": "microsoft.net", + "alias": "test", + }, + { + "provider": "m365", + "uid": "subdomain1.subdomain2.subdomain3.subdomain4.domain.net", + "alias": "test", + }, + { + "provider": "github", + "uid": "test-user", + "alias": "test", + }, + { + "provider": "github", + "uid": "test-organization", + "alias": "GitHub Org", + }, + { + "provider": "github", + "uid": "prowler-cloud", + "alias": "Prowler", + }, + { + "provider": "github", + "uid": "microsoft", + "alias": "Microsoft", + }, + { + "provider": "github", + "uid": "a12345678901234567890123456789012345678", + "alias": "Long Username", + }, + ] + ), + ) + @patch("api.v1.views.Task.objects.get") + @patch("api.v1.views.delete_provider_task.delay") + def test_providers_soft_delete( + self, + mock_delete_task, + mock_task_get, + authenticated_client, + provider_json_payload, + tasks_fixture, + ): + # Mock the Celery task response + prowler_task = tasks_fixture[0] + task_mock = Mock() + task_mock.id = prowler_task.id + mock_delete_task.return_value = task_mock + mock_task_get.return_value = prowler_task + + # 1.Create a provider + response = authenticated_client.post( + reverse("provider-list"), data=provider_json_payload, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.count() == 1 + provider_id = response.json()["data"]["id"] + + # 2. Soft delete the provider using the actual API endpoint + response = authenticated_client.delete( + reverse("provider-detail", kwargs={"pk": provider_id}) + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert Provider.objects.count() == 0 + assert Provider.all_objects.count() == 1 + + mock_delete_task.assert_called_once_with( + provider_id=str(provider_id), tenant_id=ANY + ) + + # 3. Create a provider with the same UID should succeed (since the old one is soft deleted) + response = authenticated_client.post( + reverse("provider-list"), data=provider_json_payload, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.count() == 1 + assert Provider.all_objects.count() == 2 + provider_id = response.json()["data"]["id"] + + # 4. Creating another provider with the same UID should fail (duplicate) + response = authenticated_client.post( + reverse("provider-list"), data=provider_json_payload, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + mock_delete_task.reset_mock() + mock_delete_task.return_value = task_mock + + # 5. Delete the second provider + response = authenticated_client.delete( + reverse("provider-detail", kwargs={"pk": provider_id}) + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert Provider.objects.count() == 0 + assert Provider.all_objects.count() == 2 + + # 6. Creating a provider with the same UID should succeed again + response = authenticated_client.post( + reverse("provider-list"), data=provider_json_payload, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.count() == 1 + assert Provider.all_objects.count() == 3 + @pytest.mark.parametrize( "provider_json_payload, error_code, error_pointer", ( @@ -1289,6 +1488,141 @@ class TestProviderViewSet: "github-uid", "uid", ), + ( + { + "provider": "iac", + "uid": "not-a-url", + "alias": "test", + }, + "iac-uid", + "uid", + ), + ( + { + "provider": "iac", + "uid": "ftp://invalid-protocol.com/repo", + "alias": "test", + }, + "iac-uid", + "uid", + ), + ( + { + "provider": "iac", + "uid": "http://", + "alias": "test", + }, + "iac-uid", + "uid", + ), + ( + { + "provider": "mongodbatlas", + "uid": "64b1d3c0e4b03b123456789g", + "alias": "test", + }, + "mongodbatlas-uid", + "uid", + ), + ( + { + "provider": "mongodbatlas", + "uid": "1234", + "alias": "test", + }, + "mongodbatlas-uid", + "uid", + ), + # Alibaba Cloud UID validation - too short (not 16 digits) + ( + { + "provider": "alibabacloud", + "uid": "123456789012345", + "alias": "test", + }, + "alibabacloud-uid", + "uid", + ), + # Alibaba Cloud UID validation - too long (not 16 digits) + ( + { + "provider": "alibabacloud", + "uid": "12345678901234567", + "alias": "test", + }, + "alibabacloud-uid", + "uid", + ), + # Alibaba Cloud UID validation - contains non-digits + ( + { + "provider": "alibabacloud", + "uid": "123456789012345a", + "alias": "test", + }, + "alibabacloud-uid", + "uid", + ), + # Cloudflare UID validation - too short (not 32 hex chars) + ( + { + "provider": "cloudflare", + "uid": "abc123", + "alias": "test", + }, + "cloudflare-uid", + "uid", + ), + # Cloudflare UID validation - uppercase hex (must be lowercase) + ( + { + "provider": "cloudflare", + "uid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", + "alias": "test", + }, + "cloudflare-uid", + "uid", + ), + # Cloudflare UID validation - non-hex characters + ( + { + "provider": "cloudflare", + "uid": "g1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "alias": "test", + }, + "cloudflare-uid", + "uid", + ), + # Cloudflare UID validation - too long (33 chars) + ( + { + "provider": "cloudflare", + "uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e", + "alias": "test", + }, + "cloudflare-uid", + "uid", + ), + # OpenStack UID validation - starts with special character + ( + { + "provider": "openstack", + "uid": "-invalid-project", + "alias": "test", + }, + "openstack-uid", + "uid", + ), + # OpenStack UID validation - too short (below min_length) + ( + { + "provider": "openstack", + "uid": "ab", + "alias": "test", + }, + "min_length", + "uid", + ), ] ), ) @@ -1462,22 +1796,22 @@ class TestProviderViewSet: ( "uid.icontains", "1", - 6, - ), # Updated: includes OCI provider with "1" in UID + 10, + ), ("alias", "aws_testing_1", 1), ("alias.icontains", "aws", 2), - ("inserted_at", TODAY, 7), # Updated: 7 providers now (added OCI) + ("inserted_at", TODAY, 11), ( "inserted_at.gte", "2024-01-01", - 7, - ), # Updated: 7 providers now (added OCI) + 11, + ), ("inserted_at.lte", "2024-01-01", 0), ( "updated_at.gte", "2024-01-01", - 7, - ), # Updated: 7 providers now (added OCI) + 11, + ), ("updated_at.lte", "2024-01-01", 0), ] ), @@ -1982,7 +2316,7 @@ class TestProviderSecretViewSet: ), # OCI with API key credentials (with key_content) ( - Provider.ProviderChoices.OCI.value, + Provider.ProviderChoices.ORACLECLOUD.value, ProviderSecret.TypeChoices.STATIC, { "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq", @@ -1994,7 +2328,7 @@ class TestProviderSecretViewSet: ), # OCI with API key credentials (with key_file) ( - Provider.ProviderChoices.OCI.value, + Provider.ProviderChoices.ORACLECLOUD.value, ProviderSecret.TypeChoices.STATIC, { "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq", @@ -2006,7 +2340,7 @@ class TestProviderSecretViewSet: ), # OCI with API key credentials (with passphrase) ( - Provider.ProviderChoices.OCI.value, + Provider.ProviderChoices.ORACLECLOUD.value, ProviderSecret.TypeChoices.STATIC, { "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq", @@ -2017,6 +2351,81 @@ class TestProviderSecretViewSet: "pass_phrase": "my-secure-passphrase", }, ), + # MongoDB Atlas credentials + ( + Provider.ProviderChoices.MONGODBATLAS.value, + ProviderSecret.TypeChoices.STATIC, + { + "atlas_public_key": "public-key", + "atlas_private_key": "private-key", + }, + ), + # Alibaba Cloud credentials (with access key only) + ( + Provider.ProviderChoices.ALIBABACLOUD.value, + ProviderSecret.TypeChoices.STATIC, + { + "access_key_id": "LTAI5t1234567890abcdef", + "access_key_secret": "my-secret-access-key", + }, + ), + # Alibaba Cloud credentials (with STS security token) + ( + Provider.ProviderChoices.ALIBABACLOUD.value, + ProviderSecret.TypeChoices.STATIC, + { + "access_key_id": "LTAI5t1234567890abcdef", + "access_key_secret": "my-secret-access-key", + "security_token": "my-security-token-for-sts", + }, + ), + # Alibaba Cloud RAM Role Assumption (minimal required fields) + ( + Provider.ProviderChoices.ALIBABACLOUD.value, + ProviderSecret.TypeChoices.ROLE, + { + "role_arn": "acs:ram::1234567890123456:role/ProwlerRole", + "access_key_id": "LTAI5t1234567890abcdef", + "access_key_secret": "my-secret-access-key", + }, + ), + # Alibaba Cloud RAM Role Assumption (with optional role_session_name) + ( + Provider.ProviderChoices.ALIBABACLOUD.value, + ProviderSecret.TypeChoices.ROLE, + { + "role_arn": "acs:ram::1234567890123456:role/ProwlerRole", + "access_key_id": "LTAI5t1234567890abcdef", + "access_key_secret": "my-secret-access-key", + "role_session_name": "ProwlerAuditSession", + }, + ), + # Cloudflare with API Token + ( + Provider.ProviderChoices.CLOUDFLARE.value, + ProviderSecret.TypeChoices.STATIC, + { + "api_token": "fake-cloudflare-api-token-for-testing", + }, + ), + # Cloudflare with API Key + Email + ( + Provider.ProviderChoices.CLOUDFLARE.value, + ProviderSecret.TypeChoices.STATIC, + { + "api_key": "fake-cloudflare-api-key-for-testing", + "api_email": "user@example.com", + }, + ), + # OpenStack with clouds.yaml content + ( + Provider.ProviderChoices.OPENSTACK.value, + ProviderSecret.TypeChoices.STATIC, + { + "clouds_yaml_content": "clouds:\n mycloud:\n auth:\n auth_url: https://openstack.example.com:5000/v3\n", + "clouds_yaml_cloud": "mycloud", + }, + ), ], ) def test_provider_secrets_create_valid( @@ -3293,6 +3702,426 @@ class TestTaskViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST +@pytest.mark.django_db +class TestAttackPathsScanViewSet: + @staticmethod + def _run_payload(query_id="aws-rds", parameters=None): + return { + "data": { + "type": "attack-paths-query-run-requests", + "attributes": { + "id": query_id, + "parameters": parameters or {}, + }, + } + } + + def test_attack_paths_scans_list_returns_latest_entry_per_provider( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + other_provider = providers_fixture[1] + + older_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.AVAILABLE, + progress=10, + ) + latest_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + progress=95, + ) + other_provider_scan = create_attack_paths_scan( + other_provider, + scan=scans_fixture[2], + state=StateChoices.FAILED, + progress=50, + ) + + response = authenticated_client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + ids = {item["id"] for item in data} + assert ids == {str(latest_scan.id), str(other_provider_scan.id)} + assert str(older_scan.id) not in ids + + provider_entry = next( + item + for item in data + if item["relationships"]["provider"]["data"]["id"] == str(provider.id) + ) + + first_attributes = provider_entry["attributes"] + assert first_attributes["provider_alias"] == provider.alias + assert first_attributes["provider_type"] == provider.provider + assert first_attributes["provider_uid"] == provider.uid + + def test_attack_paths_scans_list_respects_provider_group_visibility( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + create_attack_paths_scan, + ): + client = authenticated_client_no_permissions_rbac + limited_user = client.user + membership = Membership.objects.filter(user=limited_user).first() + tenant = membership.tenant + + allowed_provider = providers_fixture[0] + denied_provider = providers_fixture[1] + + allowed_scan = create_attack_paths_scan(allowed_provider) + create_attack_paths_scan(denied_provider) + + provider_group = ProviderGroup.objects.create( + name="limited-group", + tenant_id=tenant.id, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + limited_role = limited_user.roles.first() + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_role, + provider_group=provider_group, + ) + + response = client.get(reverse("attack-paths-scans-list")) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == str(allowed_scan.id) + + def test_attack_paths_scan_retrieve( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.COMPLETED, + progress=80, + ) + + response = authenticated_client.get( + reverse("attack-paths-scans-detail", kwargs={"pk": attack_paths_scan.id}) + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["id"] == str(attack_paths_scan.id) + assert data["relationships"]["provider"]["data"]["id"] == str(provider.id) + assert data["attributes"]["state"] == StateChoices.COMPLETED + + def test_attack_paths_scan_retrieve_not_found_for_foreign_tenant( + self, authenticated_client, create_attack_paths_scan + ): + other_tenant = Tenant.objects.create(name="Foreign AttackPaths Tenant") + foreign_provider = Provider.objects.create( + provider="aws", + uid="333333333333", + alias="foreign", + tenant_id=other_tenant.id, + ) + foreign_scan = create_attack_paths_scan(foreign_provider) + + response = authenticated_client.get( + reverse("attack-paths-scans-detail", kwargs={"pk": foreign_scan.id}) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_attack_paths_queries_returns_catalog( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + ) + + definitions = [ + AttackPathsQueryDefinition( + id="aws-rds", + name="RDS inventory", + short_description="List account RDS assets.", + description="List account RDS assets", + provider=provider.provider, + cypher="MATCH (n) RETURN n", + parameters=[ + AttackPathsQueryParameterDefinition(name="ip", label="IP address") + ], + ) + ] + + with patch( + "api.v1.views.get_queries_for_provider", return_value=definitions + ) as mock_get_queries: + response = authenticated_client.get( + reverse( + "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id} + ) + ) + + assert response.status_code == status.HTTP_200_OK + mock_get_queries.assert_called_once_with(provider.provider) + payload = response.json()["data"] + assert len(payload) == 1 + assert payload[0]["id"] == "aws-rds" + assert payload[0]["attributes"]["name"] == "RDS inventory" + assert payload[0]["attributes"]["parameters"][0]["name"] == "ip" + + def test_attack_paths_queries_returns_404_when_catalog_missing( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan(provider, scan=scans_fixture[0]) + + with patch("api.v1.views.get_queries_for_provider", return_value=[]): + response = authenticated_client.get( + reverse( + "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id} + ) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "No queries found" in str(response.json()) + + def test_run_attack_paths_query_returns_graph( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_database="tenant-db", + ) + query_definition = AttackPathsQueryDefinition( + id="aws-rds", + name="RDS inventory", + short_description="List account RDS assets.", + description="List account RDS assets", + provider=provider.provider, + cypher="MATCH (n) RETURN n", + parameters=[], + ) + prepared_parameters = {"provider_uid": provider.uid} + graph_payload = { + "nodes": [ + { + "id": "node-1", + "labels": ["AWSAccount"], + "properties": {"name": "root"}, + } + ], + "relationships": [ + { + "id": "rel-1", + "label": "OWNS", + "source": "node-1", + "target": "node-2", + "properties": {}, + } + ], + } + + with ( + patch( + "api.v1.views.get_query_by_id", return_value=query_definition + ) as mock_get_query, + patch( + "api.v1.views.attack_paths_views_helpers.prepare_query_parameters", + return_value=prepared_parameters, + ) as mock_prepare, + patch( + "api.v1.views.attack_paths_views_helpers.execute_attack_paths_query", + return_value=graph_payload, + ) as mock_execute, + patch("api.v1.views.graph_database.clear_cache") as mock_clear_cache, + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._run_payload("aws-rds"), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_200_OK + mock_get_query.assert_called_once_with("aws-rds") + mock_prepare.assert_called_once_with( + query_definition, + {}, + attack_paths_scan.provider.uid, + ) + mock_execute.assert_called_once_with( + attack_paths_scan, + query_definition, + prepared_parameters, + ) + mock_clear_cache.assert_called_once_with(attack_paths_scan.graph_database) + result = response.json()["data"] + attributes = result["attributes"] + assert attributes["nodes"] == graph_payload["nodes"] + assert attributes["relationships"] == graph_payload["relationships"] + + def test_run_attack_paths_query_requires_completed_scan( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + state=StateChoices.EXECUTING, + ) + + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id} + ), + data=self._run_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "must be completed" in response.json()["errors"][0]["detail"] + + def test_run_attack_paths_query_requires_graph_database( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_database=None, + ) + + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id} + ), + data=self._run_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "does not reference a graph database" in str(response.json()) + + def test_run_attack_paths_query_unknown_query( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + ) + + with patch("api.v1.views.get_query_by_id", return_value=None): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._run_payload("unknown-query"), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Unknown Attack Paths query" in response.json()["errors"][0]["detail"] + + def test_run_attack_paths_query_returns_404_when_no_nodes_found( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + ) + query_definition = AttackPathsQueryDefinition( + id="aws-empty", + name="empty", + short_description="", + description="", + provider=provider.provider, + cypher="MATCH (n) RETURN n", + ) + + with ( + patch("api.v1.views.get_query_by_id", return_value=query_definition), + patch( + "api.v1.views.attack_paths_views_helpers.prepare_query_parameters", + return_value={"provider_uid": provider.uid}, + ), + patch( + "api.v1.views.attack_paths_views_helpers.execute_attack_paths_query", + return_value={"nodes": [], "relationships": []}, + ), + patch("api.v1.views.graph_database.clear_cache"), + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-run", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._run_payload("aws-empty"), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + payload = response.json() + if "data" in payload: + attributes = payload["data"].get("attributes", {}) + assert attributes.get("nodes") == [] + assert attributes.get("relationships") == [] + else: + assert "errors" in payload + + @pytest.mark.django_db class TestResourceViewSet: def test_resources_list_none(self, authenticated_client): @@ -3313,6 +4142,10 @@ class TestResourceViewSet: ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(resources_fixture) + assert "metadata" in response.json()["data"][0]["attributes"] + assert "details" in response.json()["data"][0]["attributes"] + assert "partition" in response.json()["data"][0]["attributes"] + assert "groups" in response.json()["data"][0]["attributes"] @pytest.mark.parametrize( "include_values, expected_resources", @@ -3387,6 +4220,10 @@ class TestResourceViewSet: # full text search on resource tags ("search", "multi word", 1), ("search", "key2", 2), + # groups filter (ArrayField) + ("groups", "compute", 2), + ("groups", "storage", 1), + ("groups.in", "compute,storage", 3), ] ), ) @@ -3533,12 +4370,14 @@ class TestResourceViewSet: expected_services = {"ec2", "s3"} expected_regions = {"us-east-1", "eu-west-1"} expected_resource_types = {"prowler-test"} + expected_groups = {"compute", "storage"} assert data["data"]["type"] == "resources-metadata" assert data["data"]["id"] is None assert set(data["data"]["attributes"]["services"]) == expected_services assert set(data["data"]["attributes"]["regions"]) == expected_regions assert set(data["data"]["attributes"]["types"]) == expected_resource_types + assert set(data["data"]["attributes"]["groups"]) == expected_groups def test_resources_metadata_resource_filter_retrieve( self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture @@ -3574,6 +4413,7 @@ class TestResourceViewSet: assert data["data"]["attributes"]["services"] == [] assert data["data"]["attributes"]["regions"] == [] assert data["data"]["attributes"]["types"] == [] + assert data["data"]["attributes"]["groups"] == [] def test_resources_metadata_invalid_date(self, authenticated_client): response = authenticated_client.get( @@ -3613,6 +4453,826 @@ class TestResourceViewSet: assert attributes["services"] == [latest_scan_resource.service] assert attributes["regions"] == [latest_scan_resource.region] assert attributes["types"] == [latest_scan_resource.type] + assert "groups" in attributes + + def test_resources_latest_filter_by_provider_id( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id__in filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in_multiple( + self, authenticated_client, providers_fixture + ): + """Test that provider_id__in filter works with multiple provider IDs.""" + provider1, provider2 = providers_fixture[0], providers_fixture[1] + tenant_id = str(provider1.tenant_id) + + # Create completed scans for both providers + Scan.objects.create( + name="scan for provider 1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + Scan.objects.create( + name="scan for provider 2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + + # Create resources for each provider + resource1 = Resource.objects.create( + tenant_id=tenant_id, + provider=provider1, + uid="resource_provider_1", + name="Resource Provider 1", + region="us-east-1", + service="ec2", + type="instance", + ) + Resource.objects.create( + tenant_id=tenant_id, + provider=provider2, + uid="resource_provider_2", + name="Resource Provider 2", + region="us-west-2", + service="s3", + type="bucket", + ) + + # Test filtering by both providers + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + # Test filtering by single provider returns only that provider's resource + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid + + def test_resources_latest_filter_by_provider_id_no_match( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter returns empty when no match.""" + non_existent_id = str(uuid4()) + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": non_existent_id}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + + # Events endpoint tests + def test_events_non_aws_provider(self, authenticated_client, providers_fixture): + """Test events endpoint rejects non-AWS providers.""" + from api.models import Resource + + azure_provider = providers_fixture[4] # Azure provider from fixture + + resource = Resource.objects.create( + uid="test-resource-id", + name="Test Resource", + type="test-type", + region="us-east-1", + service="test-service", + provider=azure_provider, + tenant_id=azure_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "invalid_provider" + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["pointer"] == "/data/attributes/provider" + assert "AWS" in error["detail"] + + @pytest.mark.parametrize( + "lookback_days,expected_status,expected_code,expected_detail_contains", + [ + ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"), + ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ("91", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ("-5", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"), + ], + ) + def test_events_invalid_lookback_days( + self, + authenticated_client, + providers_fixture, + lookback_days, + expected_status, + expected_code, + expected_detail_contains, + ): + """Test events endpoint validates lookback_days with JSON:API compliant errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"lookback_days": lookback_days}, + ) + + assert response.status_code == expected_status + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == expected_code + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["parameter"] == "lookback_days" + assert expected_detail_contains in error["detail"] + + @pytest.mark.parametrize( + "page_size,expected_status,expected_code,expected_detail_contains", + [ + ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"), + ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ("51", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ("-1", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"), + ], + ) + def test_events_invalid_page_size( + self, + authenticated_client, + providers_fixture, + page_size, + expected_status, + expected_code, + expected_detail_contains, + ): + """Test events endpoint validates page[size] with JSON:API compliant errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-pagesize-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"page[size]": page_size}, + ) + + assert response.status_code == expected_status + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == expected_code + assert error["status"] == "400" # Must be string per JSON:API spec + assert error["source"]["parameter"] == "page[size]" + assert expected_detail_contains in error["detail"] + + @pytest.mark.parametrize( + "invalid_params,expected_invalid_param", + [ + ({"filter[service]": "ec2"}, "filter[service]"), + ({"filter[region]": "us-east-1"}, "filter[region]"), + ({"sort": "-name"}, "sort"), + ({"unknown_param": "value"}, "unknown_param"), + ({"filter[servic]": "ec2"}, "filter[servic]"), # Typo in filter name + ], + ) + def test_events_invalid_query_parameter( + self, + authenticated_client, + providers_fixture, + invalid_params, + expected_invalid_param, + ): + """Test events endpoint rejects unknown query parameters with JSON:API compliant errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + invalid_params, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Verify JSON:API error structure + errors = response.json()["errors"] + assert len(errors) >= 1 + + # Find the error for our expected invalid param + error = next( + (e for e in errors if e["source"]["parameter"] == expected_invalid_param), + None, + ) + assert ( + error is not None + ), f"Expected error for parameter '{expected_invalid_param}'" + assert error["code"] == "invalid" + assert error["status"] == "400" # Must be string per JSON:API spec + assert expected_invalid_param in error["detail"] + + def test_events_multiple_invalid_query_parameters( + self, + authenticated_client, + providers_fixture, + ): + """Test events endpoint returns error for first unknown parameter.""" + from api.models import Resource + + aws_provider = providers_fixture[0] + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Send multiple invalid parameters - only first one triggers error + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"filter[service]": "ec2", "sort": "-name", "unknown": "value"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Should have one error for the first invalid parameter encountered + errors = response.json()["errors"] + assert len(errors) == 1 + assert errors[0]["code"] == "invalid" + assert errors[0]["status"] == "400" + assert errors[0]["source"]["parameter"] in { + "filter[service]", + "sort", + "unknown", + } + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_success( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test successful events retrieval.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + # Create test resource + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test123", + name="Test EC2 Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider session + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock CloudTrail timeline response - events need event_id for serializer + mock_timeline_instance = Mock() + mock_events = [ + { + "event_id": "event-1-id", + "event_time": "2024-01-15T10:30:00Z", + "event_name": "RunInstances", + "event_source": "ec2.amazonaws.com", + "actor": "admin@example.com", + "actor_type": "IAMUser", + "source_ip_address": "203.0.113.1", + "user_agent": "aws-cli/2.0.0", + }, + { + "event_id": "event-2-id", + "event_time": "2024-01-16T14:20:00Z", + "event_name": "StopInstances", + "event_source": "ec2.amazonaws.com", + "actor": "operator@example.com", + "actor_type": "IAMUser", + }, + ] + mock_timeline_instance.get_resource_timeline.return_value = mock_events + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + # Make request with lookback_days parameter + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}), + {"lookback_days": "30"}, + ) + + # Assertions - response is wrapped by JSON:API renderer + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + events = response_data["data"] + + assert len(events) == 2 + + # Verify JSON:API structure: type and id are present + assert events[0]["type"] == "resource-events" + assert events[0]["id"] == "event-1-id" + assert events[1]["type"] == "resource-events" + assert events[1]["id"] == "event-2-id" + + # Verify attributes + assert events[0]["attributes"]["event_name"] == "RunInstances" + assert events[0]["attributes"]["actor"] == "admin@example.com" + assert events[1]["attributes"]["event_name"] == "StopInstances" + + # Verify CloudTrail was called with correct parameters + mock_cloudtrail_timeline.assert_called_once_with( + session=mock_session, + lookback_days=30, + max_results=50, # Default page size + write_events_only=True, # Default: exclude read events + ) + mock_timeline_instance.get_resource_timeline.assert_called_once_with( + region=resource.region, + resource_uid=resource.uid, + ) + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_default_lookback_days( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events uses default lookback_days (90) when not provided.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:s3:::test-bucket", + name="Test Bucket", + type="bucket", + region="us-east-1", + service="s3", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider session + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock CloudTrail timeline response + mock_timeline_instance = Mock() + mock_timeline_instance.get_resource_timeline.return_value = [] + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify default lookback_days (90) was used + mock_cloudtrail_timeline.assert_called_once_with( + session=mock_session, + lookback_days=90, # Default + max_results=50, + write_events_only=True, + ) + + @patch("api.v1.views.initialize_prowler_provider") + def test_events_no_credentials_error( + self, mock_initialize_provider, authenticated_client, providers_fixture + ): + """Test events handles missing credentials errors.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:rds:us-west-2:123456789012:db:test-db", + name="Test Database", + type="db-instance", + region="us-west-2", + service="rds", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + mock_initialize_provider.side_effect = NoCredentialsError() + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # 502 because this is an upstream auth failure, not API auth failure + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_auth_failed" + assert error["status"] == "502" # Must be string per JSON:API spec + assert "detail" in error + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_access_denied_error( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events handles AccessDenied errors from AWS.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func", + name="Test Function", + type="function", + region="eu-west-1", + service="lambda", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock ClientError with AccessDenied + mock_timeline_instance = Mock() + mock_timeline_instance.get_resource_timeline.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access denied"}}, + "LookupEvents", + ) + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # AccessDenied returns 502 (upstream error, not user's fault) + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_access_denied" + assert error["status"] == "502" # Must be string per JSON:API spec + assert "detail" in error + + @patch("api.v1.views.initialize_prowler_provider") + @patch("api.v1.views.CloudTrailTimeline") + def test_events_service_unavailable_error( + self, + mock_cloudtrail_timeline, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events handles generic AWS API errors as 503.""" + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func2", + name="Test Function 2", + type="function", + region="eu-west-1", + service="lambda", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock provider + mock_session = Mock() + mock_provider = Mock() + mock_provider._session.current_session = mock_session + mock_initialize_provider.return_value = mock_provider + + # Mock ClientError with non-AccessDenied error + mock_timeline_instance = Mock() + mock_timeline_instance.get_resource_timeline.side_effect = ClientError( + {"Error": {"Code": "ServiceUnavailable", "Message": "Service unavailable"}}, + "LookupEvents", + ) + mock_cloudtrail_timeline.return_value = mock_timeline_instance + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # Non-AccessDenied errors return 503 + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "service_unavailable" + assert error["status"] == "503" # Must be string per JSON:API spec + assert "detail" in error + + @patch("api.v1.views.initialize_prowler_provider") + def test_events_assume_role_access_denied( + self, + mock_initialize_provider, + authenticated_client, + providers_fixture, + ): + """Test events handles AWSAssumeRoleError during provider init. + + This tests the scenario from CLOUD-API-3HJ where the API task role + cannot assume the customer's ProwlerScan role due to IAM permissions. + The error happens during initialize_prowler_provider, which wraps + the ClientError in AWSAssumeRoleError. + """ + from api.models import Resource + from prowler.providers.aws.exceptions.exceptions import AWSAssumeRoleError + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:lambda:eu-west-1:123456789012:function:assume-role-test", + name="AssumeRole Test Function", + type="function", + region="eu-west-1", + service="lambda", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Mock initialize_prowler_provider raising AWSAssumeRoleError + # (this is what aws_provider.py actually raises when AssumeRole fails) + original_error = ClientError( + { + "Error": { + "Code": "AccessDenied", + "Message": ( + "User: arn:aws:sts::123456789012:assumed-role/api-task-role/xxx " + "is not authorized to perform: sts:AssumeRole on resource: " + "arn:aws:iam::123456789012:role/ProwlerScan" + ), + } + }, + "AssumeRole", + ) + mock_initialize_provider.side_effect = AWSAssumeRoleError( + original_exception=original_error, + file="aws_provider.py", + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # AWSAssumeRoleError returns 502 (upstream auth failure) + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + # Verify JSON:API error structure + error = response.json()["errors"][0] + assert error["code"] == "upstream_access_denied" + assert error["status"] == "502" + assert "detail" in error + + def test_events_unauthenticated_returns_401(self, providers_fixture): + """Test events endpoint returns 401 when no credentials are provided. + + This ensures the endpoint follows API conventions where missing authentication + returns 401 Unauthorized, not 404 Not Found. + """ + from rest_framework.test import APIClient + + from api.models import Resource + + aws_provider = providers_fixture[0] # AWS provider from fixture + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-unauth-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Use unauthenticated client (no JWT token) + unauthenticated_client = APIClient() + + response = unauthenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # Must return 401 Unauthorized, not 404 Not Found + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"Expected 401 Unauthorized but got {response.status_code}. " + "Unauthenticated requests should return 401, not 404." + ) + + def test_events_cross_tenant_returns_404( + self, authenticated_client, tenants_fixture + ): + """Test events endpoint returns 404 for resources in other tenants (RLS). + + Users cannot access resources belonging to other tenants due to + Row-Level Security. The resource should appear to not exist. + """ + from api.models import Provider, Resource + + # tenant3 (tenants_fixture[2]) has no membership for the test user + isolated_tenant = tenants_fixture[2] + + # Create provider in the isolated tenant + other_tenant_provider = Provider.objects.create( + provider="aws", + uid="999999999999", + alias="other_tenant_aws", + tenant_id=isolated_tenant.id, + ) + + # Create resource in the OTHER tenant (not the authenticated user's tenant) + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:999999999999:instance/i-other-tenant", + name="Other Tenant Resource", + type="instance", + region="us-east-1", + service="ec2", + provider=other_tenant_provider, + tenant_id=isolated_tenant.id, + ) + + response = authenticated_client.get( + reverse("resource-events", kwargs={"pk": resource.id}) + ) + + # RLS hides resources from other tenants - should appear as not found + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_events_expired_token_returns_401(self, providers_fixture, tenants_fixture): + """Test events endpoint returns 401 when JWT token is expired. + + Expired tokens should return 401 Unauthorized, not 404 Not Found. + This ensures authentication errors are properly distinguished from + resource not found errors. + """ + from rest_framework.test import APIClient + + from api.models import Resource + + aws_provider = providers_fixture[0] + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-expired-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + # Create an expired JWT token + tenant = tenants_fixture[0] + expired_payload = { + "token_type": "access", + "exp": datetime.now(timezone.utc) + - timedelta(hours=1), # Expired 1 hour ago + "iat": datetime.now(timezone.utc) - timedelta(hours=2), + "jti": str(uuid4()), + "user_id": str(uuid4()), + "tenant_id": str(tenant.id), + } + expired_token = jwt.encode( + expired_payload, settings.SECRET_KEY, algorithm="HS256" + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}") + + response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) + + # Must return 401 Unauthorized, not 404 Not Found + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f"Expected 401 Unauthorized but got {response.status_code}. " + "Expired tokens should return 401, not 404." + ) + + def test_events_invalid_token_returns_401(self, providers_fixture): + """Test events endpoint returns 401 when JWT token is completely invalid. + + Malformed or invalid tokens should return 401 Unauthorized, not 404 Not Found. + """ + from rest_framework.test import APIClient + + from api.models import Resource + + aws_provider = providers_fixture[0] + + resource = Resource.objects.create( + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-invalid-test", + name="Test Instance", + type="instance", + region="us-east-1", + service="ec2", + provider=aws_provider, + tenant_id=aws_provider.tenant_id, + ) + + client = APIClient() + + # Test with completely malformed token + client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token") + response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) + assert ( + response.status_code == status.HTTP_401_UNAUTHORIZED + ), f"Expected 401 for malformed token but got {response.status_code}" + + # Test with empty bearer token + client.credentials(HTTP_AUTHORIZATION="Bearer ") + response = client.get(reverse("resource-events", kwargs={"pk": resource.id})) + assert ( + response.status_code == status.HTTP_401_UNAUTHORIZED + ), f"Expected 401 for empty bearer token but got {response.status_code}" @pytest.mark.django_db @@ -3798,6 +5458,37 @@ class TestFindingViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 + def test_finding_filter_by_provider_id_alias( + self, authenticated_client, findings_fixture + ): + """Test that provider_id filter alias works identically to provider filter.""" + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[provider_id]": findings_fixture[0].scan.provider.id, + "filter[inserted_at]": TODAY, + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + def test_finding_filter_by_provider_id_in_alias( + self, authenticated_client, findings_fixture + ): + """Test that provider_id__in filter alias works identically to provider__in filter.""" + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[provider_id__in]": [ + findings_fixture[0].scan.provider.id, + findings_fixture[1].scan.provider.id, + ], + "filter[inserted_at]": TODAY, + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + @pytest.mark.parametrize( "filter_name", ( @@ -4019,6 +5710,28 @@ class TestFindingViewSet: == latest_scan_finding.status ) + def test_findings_latest_filter_by_provider_id_alias( + self, authenticated_client, latest_scan_finding + ): + """Test that provider_id filter alias works on latest findings endpoint.""" + response = authenticated_client.get( + reverse("finding-latest"), + {"filter[provider_id]": latest_scan_finding.scan.provider.id}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + + def test_findings_latest_filter_by_provider_id_in_alias( + self, authenticated_client, latest_scan_finding + ): + """Test that provider_id__in filter alias works on latest findings endpoint.""" + response = authenticated_client.get( + reverse("finding-latest"), + {"filter[provider_id__in]": str(latest_scan_finding.scan.provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + def test_findings_metadata_latest(self, authenticated_client, latest_scan_finding): response = authenticated_client.get( reverse("finding-metadata_latest"), @@ -4030,6 +5743,128 @@ class TestFindingViewSet: assert attributes["regions"] == latest_scan_finding.resource_regions assert attributes["resource_types"] == latest_scan_finding.resource_types + def test_findings_metadata_categories( + self, authenticated_client, findings_with_categories + ): + finding = findings_with_categories + response = authenticated_client.get( + reverse("finding-metadata"), + {"filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d")}, + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert set(attributes["categories"]) == {"gen-ai", "security"} + + def test_findings_metadata_latest_categories( + self, authenticated_client, latest_scan_finding_with_categories + ): + response = authenticated_client.get( + reverse("finding-metadata_latest"), + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert set(attributes["categories"]) == {"gen-ai", "iam"} + + def test_findings_metadata_latest_groups( + self, authenticated_client, latest_scan_finding_with_categories + ): + response = authenticated_client.get( + reverse("finding-metadata_latest"), + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert "groups" in attributes + assert "ai_ml" in attributes["groups"] + + def test_findings_filter_by_category( + self, authenticated_client, findings_with_categories + ): + finding = findings_with_categories + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[category]": "gen-ai", + "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert set(response.json()["data"][0]["attributes"]["categories"]) == { + "gen-ai", + "security", + } + + def test_findings_filter_by_category_in( + self, authenticated_client, findings_with_multiple_categories + ): + finding1, _ = findings_with_multiple_categories + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[category__in]": "gen-ai,iam", + "filter[inserted_at]": finding1.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + def test_findings_filter_by_category_no_match( + self, authenticated_client, findings_with_categories + ): + finding = findings_with_categories + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[category]": "nonexistent", + "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + + def test_findings_filter_by_resource_groups( + self, authenticated_client, findings_with_group + ): + finding = findings_with_group + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[resource_groups]": "storage", + "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["resource_groups"] == "storage" + + def test_findings_filter_by_resource_groups_in( + self, authenticated_client, findings_with_multiple_groups + ): + finding1, _ = findings_with_multiple_groups + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[resource_groups__in]": "storage,security", + "filter[inserted_at]": finding1.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + def test_findings_filter_by_resource_groups_no_match( + self, authenticated_client, findings_with_group + ): + finding = findings_with_group + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[resource_groups]": "nonexistent", + "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"), + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + @pytest.mark.django_db class TestJWTFields: @@ -5080,9 +6915,11 @@ class TestUserRoleRelationshipViewSet: def test_create_relationship_already_exists( self, authenticated_client, roles_fixture, create_test_user ): + # Only add Role One (which has manage_account=True) to ensure + # the second request has permission to add roles data = { "data": [ - {"type": "roles", "id": str(role.id)} for role in roles_fixture[:2] + {"type": "roles", "id": str(roles_fixture[0].id)}, ] } authenticated_client.post( @@ -5585,16 +7422,44 @@ class TestProviderGroupMembershipViewSet: @pytest.mark.django_db class TestComplianceOverviewViewSet: - def test_compliance_overview_list_none(self, authenticated_client): + @pytest.fixture(autouse=True) + def mock_backfill_task(self): + with patch("api.v1.views.backfill_compliance_summaries_task.delay") as mock: + yield mock + + def test_compliance_overview_list_none( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + mock_backfill_task, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + scan = Scan.objects.create( + name="empty-compliance-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + response = authenticated_client.get( reverse("complianceoverview-list"), - {"filter[scan_id]": "8d20ac7d-4cbc-435e-85f4-359be37af821"}, + {"filter[scan_id]": str(scan.id)}, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 0 + mock_backfill_task.assert_called_once() + _, kwargs = mock_backfill_task.call_args + assert kwargs["scan_id"] == str(scan.id) + assert str(kwargs["tenant_id"]) == str(tenant.id) def test_compliance_overview_list( - self, authenticated_client, compliance_requirements_overviews_fixture + self, + authenticated_client, + compliance_requirements_overviews_fixture, + mock_backfill_task, ): # List compliance overviews with existing data requirement_overview1 = compliance_requirements_overviews_fixture[0] @@ -5624,6 +7489,90 @@ class TestComplianceOverviewViewSet: assert "requirements_failed" in attributes assert "requirements_manual" in attributes assert "total_requirements" in attributes + mock_backfill_task.assert_called_once() + _, kwargs = mock_backfill_task.call_args + assert kwargs["scan_id"] == scan_id + + def test_compliance_overview_list_uses_preaggregated_summaries( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + mock_backfill_task, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + scan = Scan.objects.create( + name="preaggregated-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan, + compliance_id="cis_1.4_aws", + framework="CIS-1.4-AWS", + version="1.4", + description="CIS AWS Foundations Benchmark v1.4.0", + region="eu-west-1", + requirement_id="framework-metadata", + requirement_status=StatusChoices.PASS, + passed_checks=1, + failed_checks=0, + total_checks=1, + ) + + ComplianceOverviewSummary.objects.create( + tenant=tenant, + scan=scan, + compliance_id="cis_1.4_aws", + requirements_passed=5, + requirements_failed=1, + requirements_manual=2, + total_requirements=8, + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[scan_id]": str(scan.id)}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + overview = data[0] + assert overview["id"] == "cis_1.4_aws" + assert overview["attributes"]["requirements_passed"] == 5 + assert overview["attributes"]["requirements_failed"] == 1 + assert overview["attributes"]["requirements_manual"] == 2 + assert overview["attributes"]["total_requirements"] == 8 + assert "framework" in overview["attributes"] + assert "version" in overview["attributes"] + mock_backfill_task.assert_not_called() + + def test_compliance_overview_region_filter_skips_backfill( + self, + authenticated_client, + compliance_requirements_overviews_fixture, + mock_backfill_task, + ): + requirement_overview = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview.scan.id) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + { + "filter[scan_id]": scan_id, + "filter[region]": requirement_overview.region, + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) >= 1 + mock_backfill_task.assert_not_called() def test_compliance_overview_metadata( self, authenticated_client, compliance_requirements_overviews_fixture @@ -5777,6 +7726,11 @@ class TestComplianceOverviewViewSet: requirement_overview1 = compliance_requirements_overviews_fixture[0] scan_id = str(requirement_overview1.scan.id) + # Remove existing compliance data so the view falls back to task checks + scan = requirement_overview1.scan + ComplianceOverviewSummary.objects.filter(scan=scan).delete() + ComplianceRequirementOverview.objects.filter(scan=scan).delete() + # Mock a running task with patch.object( ComplianceOverviewViewSet, "get_task_response_if_running" @@ -5804,6 +7758,11 @@ class TestComplianceOverviewViewSet: requirement_overview1 = compliance_requirements_overviews_fixture[0] scan_id = str(requirement_overview1.scan.id) + # Remove existing compliance data so the view falls back to task checks + scan = requirement_overview1.scan + ComplianceOverviewSummary.objects.filter(scan=scan).delete() + ComplianceRequirementOverview.objects.filter(scan=scan).delete() + # Mock a failed task with patch.object( ComplianceOverviewViewSet, "get_task_response_if_running" @@ -5827,6 +7786,8 @@ class TestComplianceOverviewViewSet: ("framework", "framework", 1), ("version", "version", 1), ("region", "region", 1), + ("region__in", "region", 1), + ("region.in", "region", 1), ], ) def test_compliance_overview_filters( @@ -5899,10 +7860,10 @@ class TestOverviewViewSet: response = authenticated_client.get(reverse("overview-providers")) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 1 - assert response.json()["data"][0]["attributes"]["findings"]["total"] == 4 + assert response.json()["data"][0]["attributes"]["findings"]["total"] == 9 assert response.json()["data"][0]["attributes"]["findings"]["pass"] == 2 assert response.json()["data"][0]["attributes"]["findings"]["fail"] == 1 - assert response.json()["data"][0]["attributes"]["findings"]["muted"] == 1 + assert response.json()["data"][0]["attributes"]["findings"]["muted"] == 6 # Aggregated resources include all AWS providers present in the tenant assert response.json()["data"][0]["attributes"]["resources"]["total"] == 3 @@ -5954,10 +7915,10 @@ class TestOverviewViewSet: assert len(data) == 1 attributes = data[0]["attributes"] - assert attributes["findings"]["total"] == 10 + assert attributes["findings"]["total"] == 15 assert attributes["findings"]["pass"] == 5 assert attributes["findings"]["fail"] == 3 - assert attributes["findings"]["muted"] == 2 + assert attributes["findings"]["muted"] == 7 assert attributes["resources"]["total"] == 4 def test_overview_providers_count( @@ -5994,11 +7955,440 @@ class TestOverviewViewSet: for entry in grouped_data: assert "findings" not in entry["attributes"] + def _create_scan(self, tenant, provider, name, started_at=None): + scan_started = started_at or datetime.now(timezone.utc) - timedelta(hours=1) + return Scan.objects.create( + tenant=tenant, + provider=provider, + name=name, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + started_at=scan_started, + completed_at=scan_started + timedelta(minutes=30), + ) + + def _create_threatscore_snapshot( + self, + tenant, + scan, + provider, + *, + compliance_id, + overall_score, + score_delta, + section_scores, + critical_requirements, + total_requirements, + passed_requirements, + failed_requirements, + manual_requirements, + total_findings, + passed_findings, + failed_findings, + ): + return ThreatScoreSnapshot.objects.create( + tenant=tenant, + scan=scan, + provider=provider, + compliance_id=compliance_id, + overall_score=Decimal(overall_score), + score_delta=Decimal(score_delta) if score_delta is not None else None, + section_scores=section_scores, + critical_requirements=critical_requirements, + total_requirements=total_requirements, + passed_requirements=passed_requirements, + failed_requirements=failed_requirements, + manual_requirements=manual_requirements, + total_findings=total_findings, + passed_findings=passed_findings, + failed_findings=failed_findings, + ) + + def test_overview_threatscore_returns_weighted_aggregate_snapshot( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = self._create_scan(tenant, provider1, "agg-scan-one") + scan2 = self._create_scan(tenant, provider2, "agg-scan-two") + + snapshot1 = self._create_threatscore_snapshot( + tenant, + scan1, + provider1, + compliance_id="prowler_threatscore_aws", + overall_score="80.00", + score_delta="5.00", + section_scores={"1. IAM": "70.00", "2. Attack Surface": "60.00"}, + critical_requirements=[ + { + "requirement_id": "req_shared", + "title": "Shared requirement (preferred)", + "section": "1. IAM", + "subsection": "Sub IAM", + "risk_level": 5, + "weight": 150, + "passed_findings": 14, + "total_findings": 20, + "description": "Higher risk duplicate", + }, + { + "requirement_id": "req_unique_one", + "title": "Unique provider one", + "section": "2. Attack Surface", + "subsection": "Sub Attack", + "risk_level": 4, + "weight": 90, + "passed_findings": 20, + "total_findings": 30, + "description": "Lower risk", + }, + ], + total_requirements=120, + passed_requirements=90, + failed_requirements=30, + manual_requirements=0, + total_findings=100, + passed_findings=70, + failed_findings=30, + ) + + snapshot2 = self._create_threatscore_snapshot( + tenant, + scan2, + provider2, + compliance_id="prowler_threatscore_aws", + overall_score="20.00", + score_delta="-2.00", + section_scores={ + "1. IAM": "10.00", + "2. Attack Surface": "40.00", + "3. Logging": "30.00", + }, + critical_requirements=[ + { + "requirement_id": "req_shared", + "title": "Shared requirement (secondary)", + "section": "1. IAM", + "subsection": "Sub IAM", + "risk_level": 4, + "weight": 120, + "passed_findings": 8, + "total_findings": 12, + "description": "Lower risk duplicate", + }, + { + "requirement_id": "req_unique_two", + "title": "Unique provider two", + "section": "3. Logging", + "subsection": "Sub Logging", + "risk_level": 5, + "weight": 110, + "passed_findings": 6, + "total_findings": 10, + "description": "Another critical requirement", + }, + ], + total_requirements=80, + passed_requirements=30, + failed_requirements=50, + manual_requirements=0, + total_findings=50, + passed_findings=15, + failed_findings=35, + ) + + older_inserted = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc) + newer_inserted = datetime(2025, 1, 2, 12, 0, tzinfo=timezone.utc) + ThreatScoreSnapshot.objects.filter(id=snapshot1.id).update( + inserted_at=older_inserted + ) + ThreatScoreSnapshot.objects.filter(id=snapshot2.id).update( + inserted_at=newer_inserted + ) + snapshot2.refresh_from_db() + + response = authenticated_client.get(reverse("overview-threatscore")) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert len(body["data"]) == 1 + aggregated = body["data"][0] + + assert aggregated["id"] == "n/a" + assert aggregated["relationships"]["scan"]["data"] is None + assert aggregated["relationships"]["provider"]["data"] is None + + attrs = aggregated["attributes"] + assert Decimal(attrs["overall_score"]) == Decimal("60.00") + assert Decimal(attrs["score_delta"]) == Decimal("2.67") + assert attrs["inserted_at"] == snapshot2.inserted_at.isoformat().replace( + "+00:00", "Z" + ) + assert attrs["total_findings"] == 150 + assert attrs["passed_findings"] == 85 + assert attrs["failed_findings"] == 65 + assert attrs["total_requirements"] == 200 + assert attrs["passed_requirements"] == 120 + assert attrs["failed_requirements"] == 80 + assert attrs["manual_requirements"] == 0 + + assert attrs["section_scores"] == { + "1. IAM": "50.00", + "2. Attack Surface": "53.33", + "3. Logging": "30.00", + } + + expected_critical = [ + { + "requirement_id": "req_shared", + "title": "Shared requirement (preferred)", + "section": "1. IAM", + "subsection": "Sub IAM", + "risk_level": 5, + "weight": 150, + "passed_findings": 14, + "total_findings": 20, + "description": "Higher risk duplicate", + }, + { + "requirement_id": "req_unique_two", + "title": "Unique provider two", + "section": "3. Logging", + "subsection": "Sub Logging", + "risk_level": 5, + "weight": 110, + "passed_findings": 6, + "total_findings": 10, + "description": "Another critical requirement", + }, + { + "requirement_id": "req_unique_one", + "title": "Unique provider one", + "section": "2. Attack Surface", + "subsection": "Sub Attack", + "risk_level": 4, + "weight": 90, + "passed_findings": 20, + "total_findings": 30, + "description": "Lower risk", + }, + ] + assert attrs["critical_requirements"] == expected_critical + + def test_overview_threatscore_weight_fallback_to_requirements( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = self._create_scan(tenant, provider1, "fallback-scan-1") + scan2 = self._create_scan(tenant, provider2, "fallback-scan-2") + + self._create_threatscore_snapshot( + tenant, + scan1, + provider1, + compliance_id="prowler_threatscore_aws", + overall_score="90.00", + score_delta="4.00", + section_scores={"1. IAM": "90.00"}, + critical_requirements=[], + total_requirements=10, + passed_requirements=8, + failed_requirements=0, + manual_requirements=2, + total_findings=0, + passed_findings=0, + failed_findings=0, + ) + self._create_threatscore_snapshot( + tenant, + scan2, + provider2, + compliance_id="prowler_threatscore_aws", + overall_score="50.00", + score_delta="1.00", + section_scores={"1. IAM": "40.00"}, + critical_requirements=[], + total_requirements=12, + passed_requirements=5, + failed_requirements=7, + manual_requirements=0, + total_findings=10, + passed_findings=4, + failed_findings=6, + ) + + response = authenticated_client.get(reverse("overview-threatscore")) + assert response.status_code == status.HTTP_200_OK + aggregate = response.json()["data"][0]["attributes"] + + assert Decimal(aggregate["overall_score"]) == Decimal("67.78") + assert Decimal(aggregate["score_delta"]) == Decimal("2.33") + assert aggregate["total_findings"] == 10 + assert aggregate["total_requirements"] == 22 + assert aggregate["manual_requirements"] == 2 + assert aggregate["section_scores"] == {"1. IAM": "62.22"} + + def test_overview_threatscore_filter_by_scan_id_returns_snapshot( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, *_ = providers_fixture + scan = self._create_scan(tenant, provider1, "filter-scan") + + snapshot = self._create_threatscore_snapshot( + tenant, + scan, + provider1, + compliance_id="prowler_threatscore_aws", + overall_score="75.00", + score_delta="3.00", + section_scores={"1. IAM": "70.00"}, + critical_requirements=[], + total_requirements=50, + passed_requirements=30, + failed_requirements=20, + manual_requirements=0, + total_findings=25, + passed_findings=15, + failed_findings=10, + ) + + response = authenticated_client.get( + reverse("overview-threatscore"), {"filter[scan_id]": str(scan.id)} + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert len(body["data"]) == 1 + assert body["data"][0]["id"] == str(snapshot.id) + assert body["data"][0]["attributes"]["overall_score"] == "75.00" + + def test_overview_threatscore_snapshot_id_returns_specific_snapshot( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, *_ = providers_fixture + scan = self._create_scan(tenant, provider1, "snapshot-id-scan") + + snapshot = self._create_threatscore_snapshot( + tenant, + scan, + provider1, + compliance_id="prowler_threatscore_aws", + overall_score="88.50", + score_delta=None, + section_scores={"1. IAM": "80.00"}, + critical_requirements=[], + total_requirements=60, + passed_requirements=45, + failed_requirements=15, + manual_requirements=0, + total_findings=30, + passed_findings=25, + failed_findings=5, + ) + + response = authenticated_client.get( + reverse("overview-threatscore"), {"snapshot_id": str(snapshot.id)} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["data"]["id"] == str(snapshot.id) + assert data["data"]["attributes"]["score_delta"] is None + + def test_overview_threatscore_provider_filter_returns_unaggregated_snapshot( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = self._create_scan(tenant, provider1, "provider-filter-scan-1") + scan2 = self._create_scan(tenant, provider2, "provider-filter-scan-2") + + snapshot1 = self._create_threatscore_snapshot( + tenant, + scan1, + provider1, + compliance_id="prowler_threatscore_aws", + overall_score="55.55", + score_delta="1.10", + section_scores={"1. IAM": "50.00"}, + critical_requirements=[], + total_requirements=40, + passed_requirements=25, + failed_requirements=15, + manual_requirements=0, + total_findings=12, + passed_findings=7, + failed_findings=5, + ) + self._create_threatscore_snapshot( + tenant, + scan2, + provider2, + compliance_id="prowler_threatscore_aws", + overall_score="44.44", + score_delta="0.80", + section_scores={"1. IAM": "40.00"}, + critical_requirements=[], + total_requirements=30, + passed_requirements=18, + failed_requirements=12, + manual_requirements=0, + total_findings=10, + passed_findings=6, + failed_findings=4, + ) + + response = authenticated_client.get( + reverse("overview-threatscore"), + {"filter[provider_id__in]": str(provider1.id)}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == str(snapshot1.id) + assert data[0]["attributes"]["overall_score"] == "55.55" + def test_overview_services_list_no_required_filters( self, authenticated_client, scan_summaries_fixture ): response = authenticated_client.get(reverse("overview-services")) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == status.HTTP_200_OK + # Should return services from latest scans + assert len(response.json()["data"]) == 2 + + def test_overview_regions_list(self, authenticated_client, scan_summaries_fixture): + response = authenticated_client.get( + reverse("overview-regions"), {"filter[inserted_at]": TODAY} + ) + assert response.status_code == status.HTTP_200_OK + # Only two different regions in the fixture (region1, region2) + assert len(response.json()["data"]) == 2 + + data = response.json()["data"] + regions = {item["id"]: item["attributes"] for item in data} + + assert "aws:region1" in regions + assert "aws:region2" in regions + + # region1 has 5 findings (2 pass, 0 fail, 3 muted) + assert regions["aws:region1"]["total"] == 5 + assert regions["aws:region1"]["pass"] == 2 + assert regions["aws:region1"]["fail"] == 0 + assert regions["aws:region1"]["muted"] == 3 + + # region2 has 4 findings (0 pass, 1 fail, 3 muted) + assert regions["aws:region2"]["total"] == 4 + assert regions["aws:region2"]["pass"] == 0 + assert regions["aws:region2"]["fail"] == 1 + assert regions["aws:region2"]["muted"] == 3 def test_overview_services_list(self, authenticated_client, scan_summaries_fixture): response = authenticated_client.get( @@ -6007,15 +8397,14 @@ class TestOverviewViewSet: assert response.status_code == status.HTTP_200_OK # Only two different services assert len(response.json()["data"]) == 2 - # Fixed data from the fixture, TODO improve this at some point with something more dynamic + # Fixed data from the fixture service1_data = response.json()["data"][0] service2_data = response.json()["data"][1] assert service1_data["id"] == "service1" assert service2_data["id"] == "service2" - # TODO fix numbers when muted_findings filter is fixed - assert service1_data["attributes"]["total"] == 3 - assert service2_data["attributes"]["total"] == 1 + assert service1_data["attributes"]["total"] == 7 + assert service2_data["attributes"]["total"] == 2 assert service1_data["attributes"]["pass"] == 1 assert service2_data["attributes"]["pass"] == 1 @@ -6023,8 +8412,8 @@ class TestOverviewViewSet: assert service1_data["attributes"]["fail"] == 1 assert service2_data["attributes"]["fail"] == 0 - assert service1_data["attributes"]["muted"] == 1 - assert service2_data["attributes"]["muted"] == 0 + assert service1_data["attributes"]["muted"] == 5 + assert service2_data["attributes"]["muted"] == 1 def test_overview_findings_provider_id_in_filter( self, authenticated_client, tenants_fixture, providers_fixture @@ -6134,6 +8523,7 @@ class TestOverviewViewSet: tenant=tenant, ) + # Muted findings should be excluded from severity counts ScanSummary.objects.create( tenant=tenant, scan=scan1, @@ -6143,8 +8533,8 @@ class TestOverviewViewSet: region="region-a", _pass=4, fail=4, - muted=0, - total=8, + muted=3, + total=11, ) ScanSummary.objects.create( tenant=tenant, @@ -6155,8 +8545,8 @@ class TestOverviewViewSet: region="region-b", _pass=2, fail=2, - muted=0, - total=4, + muted=2, + total=6, ) ScanSummary.objects.create( tenant=tenant, @@ -6167,8 +8557,8 @@ class TestOverviewViewSet: region="region-c", _pass=1, fail=2, - muted=0, - total=3, + muted=5, + total=8, ) single_response = authenticated_client.get( @@ -6177,6 +8567,7 @@ class TestOverviewViewSet: ) assert single_response.status_code == status.HTTP_200_OK single_attributes = single_response.json()["data"]["attributes"] + # Should only count pass + fail, excluding muted (3 muted in high, 2 in medium) assert single_attributes["high"] == 8 assert single_attributes["medium"] == 4 assert single_attributes["critical"] == 0 @@ -6187,10 +8578,1152 @@ class TestOverviewViewSet: ) assert combined_response.status_code == status.HTTP_200_OK combined_attributes = combined_response.json()["data"]["attributes"] + # Should only count pass + fail, excluding muted (5 muted in critical) assert combined_attributes["high"] == 8 assert combined_attributes["medium"] == 4 assert combined_attributes["critical"] == 3 + def test_overview_findings_severity_timeseries_requires_date_from( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("overview-findings_severity_timeseries") + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "date_from" in response.json()["errors"][0]["source"]["pointer"] + + def test_overview_findings_severity_timeseries_invalid_date_format( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("overview-findings_severity_timeseries"), + {"filter[date_from]": "invalid-date"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Enter a valid date." in response.json()["errors"][0]["detail"] + + def test_overview_findings_severity_timeseries_empty_data( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("overview-findings_severity_timeseries"), + { + "filter[date_from]": "2024-01-01", + "filter[date_to]": "2024-01-03", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + # Should return 3 days with fill-forward (all zeros since no data) + assert len(data) == 3 + for item in data: + assert item["attributes"]["critical"] == 0 + assert item["attributes"]["high"] == 0 + assert item["attributes"]["medium"] == 0 + assert item["attributes"]["low"] == 0 + assert item["attributes"]["informational"] == 0 + assert item["attributes"]["muted"] == 0 + assert item["attributes"]["scan_ids"] == [] + + def test_overview_findings_severity_timeseries_with_data( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + # Create scan for day 1 + scan1 = Scan.objects.create( + name="severity-over-time-scan-1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + completed_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + + # Create scan for day 3 + scan3 = Scan.objects.create( + name="severity-over-time-scan-3", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + completed_at=datetime(2024, 1, 3, 12, 0, 0, tzinfo=timezone.utc), + ) + + # Create DailySeveritySummary for day 1 + DailySeveritySummary.objects.create( + tenant=tenant, + provider=provider1, + scan=scan1, + date=date(2024, 1, 1), + critical=10, + high=20, + medium=30, + low=40, + informational=50, + muted=5, + ) + + # Create DailySeveritySummary for day 3 + DailySeveritySummary.objects.create( + tenant=tenant, + provider=provider1, + scan=scan3, + date=date(2024, 1, 3), + critical=15, + high=25, + medium=35, + low=45, + informational=55, + muted=10, + ) + + response = authenticated_client.get( + reverse("overview-findings_severity_timeseries"), + { + "filter[date_from]": "2024-01-01", + "filter[date_to]": "2024-01-03", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 3 + + # Day 1 - actual data (id is the date) + assert data[0]["id"] == "2024-01-01" + assert data[0]["attributes"]["critical"] == 10 + assert data[0]["attributes"]["high"] == 20 + assert data[0]["attributes"]["scan_ids"] == [str(scan1.id)] + + # Day 2 - fill forward from day 1 (no data for this day) + assert data[1]["id"] == "2024-01-02" + assert data[1]["attributes"]["critical"] == 10 + assert data[1]["attributes"]["high"] == 20 + assert data[1]["attributes"]["scan_ids"] == [str(scan1.id)] + + # Day 3 - actual data + assert data[2]["id"] == "2024-01-03" + assert data[2]["attributes"]["critical"] == 15 + assert data[2]["attributes"]["high"] == 25 + assert data[2]["attributes"]["scan_ids"] == [str(scan3.id)] + + def test_overview_findings_severity_timeseries_aggregates_providers( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + # Same day, different providers + scan1 = Scan.objects.create( + name="severity-over-time-scan-p1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + completed_at=datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + scan2 = Scan.objects.create( + name="severity-over-time-scan-p2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + completed_at=datetime(2024, 2, 1, 14, 0, 0, tzinfo=timezone.utc), + ) + + # Create DailySeveritySummary for provider1 + DailySeveritySummary.objects.create( + tenant=tenant, + provider=provider1, + scan=scan1, + date=date(2024, 2, 1), + critical=10, + high=20, + medium=30, + low=40, + informational=50, + muted=5, + ) + + # Create DailySeveritySummary for provider2 + DailySeveritySummary.objects.create( + tenant=tenant, + provider=provider2, + scan=scan2, + date=date(2024, 2, 1), + critical=5, + high=10, + medium=15, + low=20, + informational=25, + muted=3, + ) + + response = authenticated_client.get( + reverse("overview-findings_severity_timeseries"), + { + "filter[date_from]": "2024-02-01", + "filter[date_to]": "2024-02-01", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + + # Should aggregate both providers + assert data[0]["attributes"]["critical"] == 15 # 10 + 5 + assert data[0]["attributes"]["high"] == 30 # 20 + 10 + assert data[0]["attributes"]["medium"] == 45 # 30 + 15 + assert data[0]["attributes"]["low"] == 60 # 40 + 20 + assert data[0]["attributes"]["informational"] == 75 # 50 + 25 + assert data[0]["attributes"]["muted"] == 8 # 5 + 3 + # scan_ids should contain both scans (order may vary) + assert set(data[0]["attributes"]["scan_ids"]) == {str(scan1.id), str(scan2.id)} + + def test_overview_findings_severity_timeseries_provider_filter( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = Scan.objects.create( + name="severity-over-time-filter-scan-p1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + completed_at=datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + scan2 = Scan.objects.create( + name="severity-over-time-filter-scan-p2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + completed_at=datetime(2024, 3, 1, 14, 0, 0, tzinfo=timezone.utc), + ) + + # Provider 1 - critical=100 + DailySeveritySummary.objects.create( + tenant=tenant, + provider=provider1, + scan=scan1, + date=date(2024, 3, 1), + critical=100, + high=0, + medium=0, + low=0, + informational=0, + muted=0, + ) + + # Provider 2 - critical=50 + DailySeveritySummary.objects.create( + tenant=tenant, + provider=provider2, + scan=scan2, + date=date(2024, 3, 1), + critical=50, + high=0, + medium=0, + low=0, + informational=0, + muted=0, + ) + + # Filter by provider1 only + response = authenticated_client.get( + reverse("overview-findings_severity_timeseries"), + { + "filter[date_from]": "2024-03-01", + "filter[date_to]": "2024-03-01", + "filter[provider_id]": str(provider1.id), + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["attributes"]["critical"] == 100 # Only provider1 + assert data[0]["attributes"]["scan_ids"] == [str(scan1.id)] + + def test_overview_attack_surface_no_data(self, authenticated_client): + response = authenticated_client.get(reverse("overview-attack-surface")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 4 + for item in data: + assert item["attributes"]["total_findings"] == 0 + assert item["attributes"]["failed_findings"] == 0 + assert item["attributes"]["muted_failed_findings"] == 0 + + def test_overview_attack_surface_with_data( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_attack_surface_overview, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="attack-surface-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_attack_surface_overview( + tenant, + scan, + AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED, + total=20, + failed=10, + muted_failed=3, + ) + create_attack_surface_overview( + tenant, + scan, + AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS, + total=15, + failed=8, + muted_failed=2, + ) + + response = authenticated_client.get(reverse("overview-attack-surface")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 4 + + results_by_type = {item["id"]: item["attributes"] for item in data} + assert results_by_type["internet-exposed"]["total_findings"] == 20 + assert results_by_type["internet-exposed"]["failed_findings"] == 10 + assert results_by_type["secrets"]["total_findings"] == 15 + assert results_by_type["secrets"]["failed_findings"] == 8 + assert results_by_type["privilege-escalation"]["total_findings"] == 0 + assert results_by_type["ec2-imdsv1"]["total_findings"] == 0 + + def test_overview_attack_surface_provider_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_attack_surface_overview, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = Scan.objects.create( + name="attack-surface-scan-1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="attack-surface-scan-2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_attack_surface_overview( + tenant, + scan1, + AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED, + total=10, + failed=5, + muted_failed=1, + ) + create_attack_surface_overview( + tenant, + scan2, + AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED, + total=20, + failed=15, + muted_failed=3, + ) + + response = authenticated_client.get( + reverse("overview-attack-surface"), + {"filter[provider_id]": str(provider1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + results_by_type = {item["id"]: item["attributes"] for item in data} + assert results_by_type["internet-exposed"]["total_findings"] == 10 + assert results_by_type["internet-exposed"]["failed_findings"] == 5 + + def test_overview_services_region_filter( + self, authenticated_client, scan_summaries_fixture + ): + response = authenticated_client.get( + reverse("overview-services"), + {"filter[region]": "region1"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + service_ids = {item["id"] for item in data} + assert service_ids == {"service1", "service2"} + + def test_overview_services_provider_type_filter( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + aws_provider, _, gcp_provider, *_ = providers_fixture + + aws_scan = Scan.objects.create( + name="aws-scan", + provider=aws_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + gcp_scan = Scan.objects.create( + name="gcp-scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + ScanSummary.objects.create( + tenant=tenant, + scan=aws_scan, + check_id="aws-check", + service="aws-service", + severity="high", + region="us-east-1", + _pass=5, + fail=2, + muted=1, + total=8, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=gcp_scan, + check_id="gcp-check", + service="gcp-service", + severity="medium", + region="us-central1", + _pass=3, + fail=1, + muted=0, + total=4, + ) + + response = authenticated_client.get( + reverse("overview-services"), + {"filter[provider_type]": "aws"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + service_ids = [item["id"] for item in data] + assert "aws-service" in service_ids + assert "gcp-service" not in service_ids + + @pytest.mark.parametrize( + "status_filter,field_to_check", + [ + ("FAIL", "fail"), + ("PASS", "_pass"), + ], + ) + def test_overview_findings_severity_status_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + status_filter, + field_to_check, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="status-filter-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + ScanSummary.objects.create( + tenant=tenant, + scan=scan, + check_id="status-check-high", + service="service-a", + severity="high", + region="us-east-1", + _pass=10, + fail=5, + muted=3, + total=18, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan, + check_id="status-check-medium", + service="service-a", + severity="medium", + region="us-east-1", + _pass=8, + fail=2, + muted=1, + total=11, + ) + + response = authenticated_client.get( + reverse("overview-findings_severity"), + { + "filter[provider_id]": str(provider.id), + "filter[status]": status_filter, + }, + ) + assert response.status_code == status.HTTP_200_OK + attrs = response.json()["data"]["attributes"] + if status_filter == "FAIL": + assert attrs["high"] == 5 + assert attrs["medium"] == 2 + else: + assert attrs["high"] == 10 + assert attrs["medium"] == 8 + + def test_overview_threatscore_compliance_id_filter( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + scan = self._create_scan(tenant, provider, "compliance-filter-scan") + + self._create_threatscore_snapshot( + tenant, + scan, + provider, + compliance_id="prowler_threatscore_aws", + overall_score="75.00", + score_delta="2.00", + section_scores={"1. IAM": "70.00"}, + critical_requirements=[], + total_requirements=50, + passed_requirements=35, + failed_requirements=15, + manual_requirements=0, + total_findings=30, + passed_findings=20, + failed_findings=10, + ) + self._create_threatscore_snapshot( + tenant, + scan, + provider, + compliance_id="cis_1.4_aws", + overall_score="65.00", + score_delta="1.00", + section_scores={"1. IAM": "60.00"}, + critical_requirements=[], + total_requirements=40, + passed_requirements=25, + failed_requirements=15, + manual_requirements=0, + total_findings=25, + passed_findings=15, + failed_findings=10, + ) + + response = authenticated_client.get( + reverse("overview-threatscore"), + {"filter[compliance_id]": "prowler_threatscore_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["attributes"]["overall_score"] == "75.00" + assert data[0]["attributes"]["compliance_id"] == "prowler_threatscore_aws" + + def test_overview_threatscore_provider_type_filter( + self, authenticated_client, tenants_fixture, providers_fixture + ): + tenant = tenants_fixture[0] + aws_provider, _, gcp_provider, *_ = providers_fixture + + aws_scan = self._create_scan(tenant, aws_provider, "aws-threatscore-scan") + gcp_scan = self._create_scan(tenant, gcp_provider, "gcp-threatscore-scan") + + self._create_threatscore_snapshot( + tenant, + aws_scan, + aws_provider, + compliance_id="prowler_threatscore_aws", + overall_score="80.00", + score_delta="3.00", + section_scores={"1. IAM": "75.00"}, + critical_requirements=[], + total_requirements=60, + passed_requirements=45, + failed_requirements=15, + manual_requirements=0, + total_findings=40, + passed_findings=30, + failed_findings=10, + ) + self._create_threatscore_snapshot( + tenant, + gcp_scan, + gcp_provider, + compliance_id="prowler_threatscore_gcp", + overall_score="70.00", + score_delta="2.00", + section_scores={"1. IAM": "65.00"}, + critical_requirements=[], + total_requirements=50, + passed_requirements=35, + failed_requirements=15, + manual_requirements=0, + total_findings=35, + passed_findings=25, + failed_findings=10, + ) + + response = authenticated_client.get( + reverse("overview-threatscore"), + {"filter[provider_type]": "aws"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["attributes"]["overall_score"] == "80.00" + + def test_overview_categories_no_data(self, authenticated_client): + response = authenticated_client.get(reverse("overview-categories")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + + def test_overview_categories_aggregates_by_category_with_severity( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_category_summary, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="categories-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_category_summary( + tenant, + scan, + "iam", + "high", + total_findings=20, + failed_findings=10, + new_failed_findings=5, + ) + create_scan_category_summary( + tenant, + scan, + "iam", + "medium", + total_findings=15, + failed_findings=8, + new_failed_findings=3, + ) + create_scan_category_summary( + tenant, + scan, + "encryption", + "critical", + total_findings=5, + failed_findings=2, + new_failed_findings=1, + ) + + response = authenticated_client.get(reverse("overview-categories")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + + results_by_category = {item["id"]: item["attributes"] for item in data} + + assert results_by_category["iam"]["total_findings"] == 35 + assert results_by_category["iam"]["failed_findings"] == 18 + assert results_by_category["iam"]["new_failed_findings"] == 8 + assert results_by_category["iam"]["severity"]["high"] == 10 + assert results_by_category["iam"]["severity"]["medium"] == 8 + assert results_by_category["iam"]["severity"]["critical"] == 0 + + assert results_by_category["encryption"]["total_findings"] == 5 + assert results_by_category["encryption"]["failed_findings"] == 2 + assert results_by_category["encryption"]["severity"]["critical"] == 2 + + @pytest.mark.parametrize( + "filter_key,filter_value_fn,expected_total,expected_failed", + [ + ("filter[provider_id]", lambda p1, _: str(p1.id), 10, 5), + ("filter[provider_type]", lambda *_: "aws", 10, 5), + ("filter[provider_type__in]", lambda *_: "aws,gcp", 30, 20), + ], + ) + def test_overview_categories_filters( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_category_summary, + filter_key, + filter_value_fn, + expected_total, + expected_failed, + ): + tenant = tenants_fixture[0] + provider1, _, gcp_provider, *_ = providers_fixture + + scan1 = Scan.objects.create( + name="categories-scan-1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="categories-scan-2", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_category_summary( + tenant, scan1, "iam", "high", total_findings=10, failed_findings=5 + ) + create_scan_category_summary( + tenant, scan2, "iam", "high", total_findings=20, failed_findings=15 + ) + + response = authenticated_client.get( + reverse("overview-categories"), + {filter_key: filter_value_fn(provider1, gcp_provider)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["attributes"]["total_findings"] == expected_total + assert data[0]["attributes"]["failed_findings"] == expected_failed + + def test_overview_categories_category_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_category_summary, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="category-filter-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_category_summary( + tenant, scan, "iam", "high", total_findings=10, failed_findings=5 + ) + create_scan_category_summary( + tenant, scan, "encryption", "medium", total_findings=20, failed_findings=8 + ) + create_scan_category_summary( + tenant, scan, "logging", "low", total_findings=15, failed_findings=3 + ) + + response = authenticated_client.get( + reverse("overview-categories"), + {"filter[category__in]": "iam,encryption"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + category_ids = {item["id"] for item in data} + assert category_ids == {"iam", "encryption"} + + def test_overview_categories_aggregates_multiple_providers( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_category_summary, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = Scan.objects.create( + name="multi-provider-scan-1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="multi-provider-scan-2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_category_summary( + tenant, + scan1, + "iam", + "high", + total_findings=10, + failed_findings=5, + new_failed_findings=2, + ) + create_scan_category_summary( + tenant, + scan2, + "iam", + "high", + total_findings=15, + failed_findings=8, + new_failed_findings=3, + ) + + response = authenticated_client.get(reverse("overview-categories")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == "iam" + assert data[0]["attributes"]["total_findings"] == 25 + assert data[0]["attributes"]["failed_findings"] == 13 + assert data[0]["attributes"]["new_failed_findings"] == 5 + + def test_overview_groups_no_data(self, authenticated_client): + response = authenticated_client.get(reverse("overview-resource-groups")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + + def test_overview_groups_aggregates_by_group_with_severity( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="resource-groups-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + # resources_count is group-level (same for all severities within a group) + create_scan_resource_group_summary( + tenant, + scan, + "storage", + "high", + total_findings=20, + failed_findings=10, + new_failed_findings=5, + resources_count=8, + ) + create_scan_resource_group_summary( + tenant, + scan, + "storage", + "medium", + total_findings=15, + failed_findings=7, + new_failed_findings=3, + resources_count=8, # Same as high - group-level count + ) + create_scan_resource_group_summary( + tenant, + scan, + "security", + "critical", + total_findings=10, + failed_findings=8, + new_failed_findings=2, + resources_count=4, + ) + + response = authenticated_client.get(reverse("overview-resource-groups")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + + storage_data = next(d for d in data if d["id"] == "storage") + security_data = next(d for d in data if d["id"] == "security") + + assert storage_data["attributes"]["total_findings"] == 35 + assert storage_data["attributes"]["failed_findings"] == 17 + assert storage_data["attributes"]["new_failed_findings"] == 8 + assert ( + storage_data["attributes"]["resources_count"] == 8 + ) # Group-level, not sum + assert security_data["attributes"]["total_findings"] == 10 + assert security_data["attributes"]["failed_findings"] == 8 + assert security_data["attributes"]["resources_count"] == 4 + + @pytest.mark.parametrize( + "filter_key,filter_value_fn,expected_total,expected_failed", + [ + ("filter[provider_id]", lambda p1, p2: str(p1.id), 10, 5), + ("filter[provider_id__in]", lambda p1, p2: f"{p1.id},{p2.id}", 25, 12), + ("filter[provider_type]", lambda p1, p2: "aws", 10, 5), + ("filter[provider_type__in]", lambda p1, p2: "aws,gcp", 25, 12), + ], + ) + def test_overview_groups_provider_filters( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + filter_key, + filter_value_fn, + expected_total, + expected_failed, + ): + tenant = tenants_fixture[0] + provider1 = providers_fixture[0] # AWS + gcp_provider = providers_fixture[2] # GCP + + scan1 = Scan.objects.create( + name="aws-rg-scan", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="gcp-rg-scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_resource_group_summary( + tenant, scan1, "storage", "high", total_findings=10, failed_findings=5 + ) + create_scan_resource_group_summary( + tenant, scan2, "storage", "high", total_findings=15, failed_findings=7 + ) + + response = authenticated_client.get( + reverse("overview-resource-groups"), + {filter_key: filter_value_fn(provider1, gcp_provider)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["attributes"]["total_findings"] == expected_total + assert data[0]["attributes"]["failed_findings"] == expected_failed + + def test_overview_groups_group_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + + scan = Scan.objects.create( + name="rg-filter-scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_resource_group_summary( + tenant, scan, "storage", "high", total_findings=10, failed_findings=5 + ) + create_scan_resource_group_summary( + tenant, scan, "compute", "medium", total_findings=20, failed_findings=8 + ) + create_scan_resource_group_summary( + tenant, scan, "security", "low", total_findings=15, failed_findings=3 + ) + + response = authenticated_client.get( + reverse("overview-resource-groups"), + {"filter[resource_group__in]": "storage,compute"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + group_ids = {item["id"] for item in data} + assert group_ids == {"storage", "compute"} + + def test_overview_groups_aggregates_multiple_providers( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + create_scan_resource_group_summary, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + + scan1 = Scan.objects.create( + name="multi-provider-rg-scan-1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="multi-provider-rg-scan-2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + + create_scan_resource_group_summary( + tenant, + scan1, + "storage", + "high", + total_findings=10, + failed_findings=5, + new_failed_findings=2, + resources_count=4, + ) + create_scan_resource_group_summary( + tenant, + scan2, + "storage", + "high", + total_findings=15, + failed_findings=8, + new_failed_findings=3, + resources_count=6, + ) + + response = authenticated_client.get(reverse("overview-resource-groups")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == "storage" + assert data[0]["attributes"]["total_findings"] == 25 + assert data[0]["attributes"]["failed_findings"] == 13 + assert data[0]["attributes"]["new_failed_findings"] == 5 + assert data[0]["attributes"]["resources_count"] == 10 + + def test_compliance_watchlist_no_filters_uses_tenant_summary( + self, authenticated_client, tenant_compliance_summary_fixture + ): + response = authenticated_client.get(reverse("overview-compliance-watchlist")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + assert len(data) == 2 + + by_id = {item["id"]: item["attributes"] for item in data} + assert "aws_cis_2.0" in by_id + assert by_id["aws_cis_2.0"]["requirements_passed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 2 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + assert by_id["aws_cis_2.0"]["total_requirements"] == 4 + + assert "gdpr_aws" in by_id + assert by_id["gdpr_aws"]["requirements_passed"] == 5 + assert by_id["gdpr_aws"]["requirements_failed"] == 0 + assert by_id["gdpr_aws"]["total_requirements"] == 7 + + def test_compliance_watchlist_with_provider_filter_uses_provider_scores( + self, + authenticated_client, + provider_compliance_scores_fixture, + providers_fixture, + ): + provider1 = providers_fixture[0] + url = f"{reverse('overview-compliance-watchlist')}?filter[provider_id]={provider1.id}" + response = authenticated_client.get(url) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + assert len(data) == 2 + by_id = {item["id"]: item["attributes"] for item in data} + + assert by_id["aws_cis_2.0"]["requirements_passed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + assert by_id["aws_cis_2.0"]["total_requirements"] == 3 + + def test_compliance_watchlist_fail_dominant_logic( + self, authenticated_client, provider_compliance_scores_fixture + ): + response = authenticated_client.get( + f"{reverse('overview-compliance-watchlist')}?filter[provider_type]=aws" + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + by_id = {item["id"]: item["attributes"] for item in data} + aws_cis = by_id["aws_cis_2.0"] + + assert aws_cis["requirements_failed"] == 2 + assert aws_cis["requirements_passed"] == 0 + assert aws_cis["requirements_manual"] == 1 + assert aws_cis["total_requirements"] == 3 + + def test_compliance_watchlist_provider_id_in_filter( + self, + authenticated_client, + provider_compliance_scores_fixture, + providers_fixture, + ): + provider1, provider2, *_ = providers_fixture + url = ( + f"{reverse('overview-compliance-watchlist')}" + f"?filter[provider_id__in]={provider1.id},{provider2.id}" + ) + response = authenticated_client.get(url) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) >= 1 + + def test_compliance_watchlist_empty_result(self, authenticated_client): + response = authenticated_client.get(reverse("overview-compliance-watchlist")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data == [] + + @pytest.mark.parametrize( + "invalid_provider_type", + ["invalid", "not_a_provider", "AWS", "awss"], + ) + def test_compliance_watchlist_invalid_provider_type_filter( + self, authenticated_client, invalid_provider_type + ): + url = f"{reverse('overview-compliance-watchlist')}?filter[provider_type]={invalid_provider_type}" + response = authenticated_client.get(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + @pytest.mark.django_db class TestScheduleViewSet: @@ -7345,25 +10878,20 @@ class TestTenantFinishACSView: assert "sso_saml_failed=true" in response.url def test_dispatch_skips_role_mapping_when_single_manage_account_user( - self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + saml_setup, + settings, + monkeypatch, ): """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] - # Create a single role with manage_account=True for the user - admin_role = Role.objects.using(MainRouter.admin_db).create( - name="admin", - tenant=tenant, - manage_account=True, - manage_users=True, - manage_billing=True, - manage_providers=True, - manage_integrations=True, - manage_scans=True, - unlimited_visibility=True, - ) + admin_role = admin_role_fixture UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) @@ -7434,35 +10962,26 @@ class TestTenantFinishACSView: .exists() ) - def test_dispatch_applies_role_mapping_when_multiple_manage_account_users( - self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch + def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_existing_role( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + roles_fixture, + saml_setup, + settings, + monkeypatch, ): - """Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role""" + """Test that role mapping is skipped when it would remove the last MANAGE_ACCOUNT user""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] - # Create a second user with manage_account=True - second_admin = User.objects.using(MainRouter.admin_db).create( - email="admin2@prowler.com", name="Second Admin" - ) - admin_role = Role.objects.using(MainRouter.admin_db).create( - name="admin", - tenant=tenant, - manage_account=True, - manage_users=True, - manage_billing=True, - manage_providers=True, - manage_integrations=True, - manage_scans=True, - unlimited_visibility=True, - ) + admin_role = admin_role_fixture + viewer_role = roles_fixture[3] UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=second_admin, role=admin_role, tenant_id=tenant.id - ) social_account = SocialAccount( user=user, @@ -7471,7 +10990,7 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["viewer"], # This SHOULD be applied + "userType": [viewer_role.name], }, ) @@ -7509,10 +11028,91 @@ class TestTenantFinishACSView: assert response.status_code == 302 - # Verify the viewer role was created and assigned (role mapping was applied) - viewer_role = Role.objects.using(MainRouter.admin_db).get( - name="viewer", tenant=tenant + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() ) + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role=viewer_role, tenant_id=tenant.id) + .exists() + ) + + def test_dispatch_applies_role_mapping_when_multiple_manage_account_users( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + roles_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + # Create a second user with manage_account=True + second_admin = User.objects.using(MainRouter.admin_db).create( + email="admin2@prowler.com", name="Second Admin" + ) + admin_role = admin_role_fixture + viewer_role = roles_fixture[3] + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=second_admin, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": [viewer_role.name], # This SHOULD be applied + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # Verify the viewer role was assigned (role mapping was applied) assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(user=user, role=viewer_role, tenant_id=tenant.id) @@ -7526,6 +11126,86 @@ class TestTenantFinishACSView: .exists() ) + def test_dispatch_applies_role_mapping_for_non_admin_user_with_single_admin( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + roles_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that role mapping is applied for a non-admin user when a single admin exists""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + admin_user = create_test_user + tenant = tenants_fixture[0] + non_admin_user = User.objects.using(MainRouter.admin_db).create( + email="viewer@prowler.com", name="Viewer" + ) + + admin_role = admin_role_fixture + viewer_role = roles_fixture[3] + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=admin_user, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=non_admin_user, + provider="saml", + extra_data={ + "firstName": ["Jane"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": [viewer_role.name], + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = non_admin_user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = non_admin_user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=non_admin_user, role=viewer_role, tenant_id=tenant.id) + .exists() + ) + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=admin_user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + @pytest.mark.django_db class TestLighthouseConfigViewSet: @@ -7536,7 +11216,7 @@ class TestLighthouseConfigViewSet: "type": "lighthouse-configurations", "attributes": { "name": "OpenAI", - "api_key": "sk-test1234567890T3BlbkFJtest1234567890", + "api_key": "sk-fake-test-key-for-unit-testing-only", "model": "gpt-4o", "temperature": 0.7, "max_tokens": 4000, @@ -8923,7 +12603,7 @@ class TestLighthouseTenantConfigViewSet: """Test creating a tenant config successfully via PATCH (upsert)""" payload = { "data": { - "type": "lighthouse-config", + "type": "lighthouse-configurations", "attributes": { "business_context": "Test business context for security analysis", "default_provider": "", @@ -8932,7 +12612,7 @@ class TestLighthouseTenantConfigViewSet: } } response = authenticated_client.patch( - reverse("lighthouse-config"), + reverse("lighthouse-configurations"), data=payload, content_type=API_JSON_CONTENT_TYPE, ) @@ -8949,7 +12629,7 @@ class TestLighthouseTenantConfigViewSet: """Test that PATCH creates config if not exists and updates if exists (upsert)""" payload = { "data": { - "type": "lighthouse-config", + "type": "lighthouse-configurations", "attributes": { "business_context": "First config", }, @@ -8958,7 +12638,7 @@ class TestLighthouseTenantConfigViewSet: # First PATCH creates the config response = authenticated_client.patch( - reverse("lighthouse-config"), + reverse("lighthouse-configurations"), data=payload, content_type=API_JSON_CONTENT_TYPE, ) @@ -8969,7 +12649,7 @@ class TestLighthouseTenantConfigViewSet: # Second PATCH updates the same config (not creating a duplicate) payload["data"]["attributes"]["business_context"] = "Updated config" response = authenticated_client.patch( - reverse("lighthouse-config"), + reverse("lighthouse-configurations"), data=payload, content_type=API_JSON_CONTENT_TYPE, ) @@ -8998,7 +12678,7 @@ class TestLighthouseTenantConfigViewSet: provider_config = LighthouseProviderConfiguration.objects.create( tenant_id=tenants_fixture[0].id, provider_type="openai", - credentials=b'{"api_key": "sk-test1234567890T3BlbkFJtest1234567890"}', + credentials=b'{"api_key": "sk-fake-test-key-for-unit-testing-only"}', is_active=True, ) @@ -9025,7 +12705,7 @@ class TestLighthouseTenantConfigViewSet: ) # Retrieve and verify the configuration - response = authenticated_client.get(reverse("lighthouse-config")) + response = authenticated_client.get(reverse("lighthouse-configurations")) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] assert data["id"] == str(config.id) @@ -9035,7 +12715,7 @@ class TestLighthouseTenantConfigViewSet: def test_lighthouse_tenant_config_retrieve_not_found(self, authenticated_client): """Test GET when config doesn't exist returns 404""" - response = authenticated_client.get(reverse("lighthouse-config")) + response = authenticated_client.get(reverse("lighthouse-configurations")) assert response.status_code == status.HTTP_404_NOT_FOUND assert "not found" in response.json()["errors"][0]["detail"].lower() @@ -9056,14 +12736,14 @@ class TestLighthouseTenantConfigViewSet: # Update it payload = { "data": { - "type": "lighthouse-config", + "type": "lighthouse-configurations", "attributes": { "business_context": "Updated context for cloud security", }, } } response = authenticated_client.patch( - reverse("lighthouse-config"), + reverse("lighthouse-configurations"), data=payload, content_type=API_JSON_CONTENT_TYPE, ) @@ -9088,14 +12768,14 @@ class TestLighthouseTenantConfigViewSet: # Try to set invalid provider payload = { "data": { - "type": "lighthouse-config", + "type": "lighthouse-configurations", "attributes": { "default_provider": "nonexistent-provider", }, } } response = authenticated_client.patch( - reverse("lighthouse-config"), + reverse("lighthouse-configurations"), data=payload, content_type=API_JSON_CONTENT_TYPE, ) @@ -9116,7 +12796,7 @@ class TestLighthouseTenantConfigViewSet: # Send invalid JSON response = authenticated_client.patch( - reverse("lighthouse-config"), + reverse("lighthouse-configurations"), data="invalid json", content_type=API_JSON_CONTENT_TYPE, ) @@ -9134,7 +12814,7 @@ class TestLighthouseProviderConfigViewSet: "type": "lighthouse-providers", "attributes": { "provider_type": "testprovider", - "credentials": {"api_key": "sk-testT3BlbkFJkey"}, + "credentials": {"api_key": "sk-fake-test-key-1234"}, }, } } @@ -9166,7 +12846,7 @@ class TestLighthouseProviderConfigViewSet: "credentials", [ {}, # empty credentials - {"token": "sk-testT3BlbkFJkey"}, # wrong key name + {"token": "sk-fake-test-key-1234"}, # wrong key name {"api_key": "ks-invalid-format"}, # wrong format ], ) @@ -9190,7 +12870,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_valid_credentials_success(self, authenticated_client): """OpenAI provider with valid sk-xxx format should succeed""" - valid_key = "sk-abc123T3BlbkFJxyz456" + valid_key = "sk-fake-abc-test-key-xyz" payload = { "data": { "type": "lighthouse-providers", @@ -9215,7 +12895,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_provider_duplicate_per_tenant(self, authenticated_client): """If an OpenAI provider exists for tenant, creating again should error""" - valid_key = "sk-dup123T3BlbkFJdup456" + valid_key = "sk-fake-dup-test-key-456" payload = { "data": { "type": "lighthouse-providers", @@ -9244,7 +12924,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_patch_base_url_and_is_active(self, authenticated_client): """After creating, should be able to patch base_url and is_active""" - valid_key = "sk-patch123T3BlbkFJpatch456" + valid_key = "sk-fake-patch-test-key-456" create_payload = { "data": { "type": "lighthouse-providers", @@ -9284,7 +12964,7 @@ class TestLighthouseProviderConfigViewSet: def test_openai_patch_invalid_credentials(self, authenticated_client): """PATCH with invalid credentials.api_key should error (400)""" - valid_key = "sk-ok123T3BlbkFJok456" + valid_key = "sk-fake-ok-test-key-456" create_payload = { "data": { "type": "lighthouse-providers", @@ -9320,7 +13000,7 @@ class TestLighthouseProviderConfigViewSet: assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST def test_openai_get_masking_and_fields_filter(self, authenticated_client): - valid_key = "sk-get123T3BlbkFJget456" + valid_key = "sk-fake-get-test-key-456" create_payload = { "data": { "type": "lighthouse-providers", @@ -9366,7 +13046,7 @@ class TestLighthouseProviderConfigViewSet: provider = LighthouseProviderConfiguration.objects.create( tenant_id=tenant.id, provider_type="openai", - credentials=b'{"api_key":"sk-test123T3BlbkFJ"}', + credentials=b'{"api_key":"sk-fake-test-key-123"}', is_active=True, ) @@ -9393,3 +13073,1091 @@ class TestLighthouseProviderConfigViewSet: # Unrelated entries should remain untouched assert cfg.default_models.get("other") == "model-x" + + @pytest.mark.parametrize( + "credentials", + [ + {}, # empty credentials + { + "access_key_id": "AKIAIOSFODNN7EXAMPLE" + }, # missing secret_access_key and region + { + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + }, # missing access_key_id and region + { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, # missing region + { # invalid access_key_id format (not starting with AKIA) + "access_key_id": "ABCD0123456789ABCDEF", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-east-1", + }, + { # invalid access_key_id format (wrong length) + "access_key_id": "AKIAIOSFODNN7EXAMPL", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-east-1", + }, + { # invalid secret_access_key format (wrong length) + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEK", + "region": "us-east-1", + }, + { # invalid region format + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "invalid-region", + }, + { # invalid region format (uppercase) + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "US-EAST-1", + }, + ], + ) + def test_bedrock_invalid_credentials(self, authenticated_client, credentials): + """Bedrock provider with invalid credentials should error""" + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": credentials, + }, + } + } + resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + def test_bedrock_valid_credentials_success(self, authenticated_client): + """Bedrock provider with valid AWS credentials should succeed and mask credentials""" + valid_credentials = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-east-1", + } + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": valid_credentials, + }, + } + } + resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp.status_code == status.HTTP_201_CREATED + data = resp.json()["data"] + + # Verify credentials are returned masked + masked_creds = data["attributes"].get("credentials") + assert masked_creds is not None + assert "access_key_id" in masked_creds + assert "secret_access_key" in masked_creds + assert "region" in masked_creds + # Verify all characters are masked with asterisks + assert all(c == "*" for c in masked_creds["access_key_id"]) + assert all(c == "*" for c in masked_creds["secret_access_key"]) + + def test_bedrock_provider_duplicate_per_tenant(self, authenticated_client): + """Creating a second Bedrock provider for same tenant should fail""" + valid_credentials = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-west-2", + } + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": valid_credentials, + }, + } + } + # First creation succeeds + resp1 = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp1.status_code == status.HTTP_201_CREATED + + # Second creation should fail with validation error + resp2 = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp2.status_code == status.HTTP_400_BAD_REQUEST + assert "already exists" in str(resp2.json()).lower() + + def test_bedrock_patch_credentials_and_fields_filter(self, authenticated_client): + """PATCH credentials and verify fields filter returns decrypted values""" + valid_credentials = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "eu-west-1", + } + create_payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": valid_credentials, + }, + } + } + create_resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=create_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert create_resp.status_code == status.HTTP_201_CREATED + provider_id = create_resp.json()["data"]["id"] + + # Update credentials with new valid ones + new_credentials = { + "access_key_id": "AKIAZZZZZZZZZZZZZZZZ", + "secret_access_key": "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/==", + "region": "ap-south-1", + } + patch_payload = { + "data": { + "type": "lighthouse-providers", + "id": provider_id, + "attributes": { + "credentials": new_credentials, + "is_active": False, + }, + } + } + patch_resp = authenticated_client.patch( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}), + data=patch_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert patch_resp.status_code == status.HTTP_200_OK + updated = patch_resp.json()["data"]["attributes"] + assert updated["is_active"] is False + + # Default GET should return masked credentials + get_resp = authenticated_client.get( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}) + ) + assert get_resp.status_code == status.HTTP_200_OK + masked = get_resp.json()["data"]["attributes"]["credentials"] + assert all(c == "*" for c in masked["access_key_id"]) + assert all(c == "*" for c in masked["secret_access_key"]) + + # Fields filter should return decrypted credentials + get_full = authenticated_client.get( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}) + + "?fields[lighthouse-providers]=credentials" + ) + assert get_full.status_code == status.HTTP_200_OK + creds = get_full.json()["data"]["attributes"]["credentials"] + assert creds["access_key_id"] == new_credentials["access_key_id"] + assert creds["secret_access_key"] == new_credentials["secret_access_key"] + assert creds["region"] == new_credentials["region"] + + def test_bedrock_partial_credential_update(self, authenticated_client): + """Test partial update of Bedrock credentials (e.g., only region)""" + # Create provider with full credentials + initial_credentials = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-east-1", + } + create_payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": initial_credentials, + }, + } + } + create_resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=create_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert create_resp.status_code == status.HTTP_201_CREATED + provider_id = create_resp.json()["data"]["id"] + + # Update only the region field + partial_update = { + "region": "eu-west-1", + } + patch_payload = { + "data": { + "type": "lighthouse-providers", + "id": provider_id, + "attributes": { + "credentials": partial_update, + }, + } + } + patch_resp = authenticated_client.patch( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}), + data=patch_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert patch_resp.status_code == status.HTTP_200_OK + + # Verify credentials with fields filter - region should be updated, keys preserved + get_full = authenticated_client.get( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}) + + "?fields[lighthouse-providers]=credentials" + ) + assert get_full.status_code == status.HTTP_200_OK + creds = get_full.json()["data"]["attributes"]["credentials"] + + # Original keys should be preserved + assert creds["access_key_id"] == initial_credentials["access_key_id"] + assert creds["secret_access_key"] == initial_credentials["secret_access_key"] + # Region should be updated + assert creds["region"] == "eu-west-1" + + def test_bedrock_valid_api_key_credentials_success(self, authenticated_client): + """Bedrock provider with valid API key + region should succeed and return masked credentials""" + valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110) + api_credentials = { + "api_key": valid_api_key, + "region": "us-east-1", + } + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": api_credentials, + }, + } + } + resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp.status_code == status.HTTP_201_CREATED + data = resp.json()["data"] + + # Verify credentials are returned masked + masked_creds = data["attributes"].get("credentials") + assert masked_creds is not None + assert "api_key" in masked_creds + assert "region" in masked_creds + assert all(c == "*" for c in masked_creds["api_key"]) + + def test_bedrock_mixed_api_key_and_access_keys_invalid_on_create( + self, authenticated_client + ): + """Bedrock provider with both API key and access keys should fail validation on create""" + valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110) + mixed_credentials = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "api_key": valid_api_key, + "region": "us-east-1", + } + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": mixed_credentials, + }, + } + } + resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + error_body = str(resp.json()).lower() + assert "either access key + secret key or api key" in error_body + + def test_bedrock_cannot_switch_from_api_key_to_access_keys_on_update( + self, authenticated_client + ): + """If created with API key, switching to access keys via update should be rejected""" + valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110) + create_payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": { + "api_key": valid_api_key, + "region": "us-east-1", + }, + }, + } + } + create_resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=create_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert create_resp.status_code == status.HTTP_201_CREATED + provider_id = create_resp.json()["data"]["id"] + + # Attempt to introduce access keys on update + patch_payload = { + "data": { + "type": "lighthouse-providers", + "id": provider_id, + "attributes": { + "credentials": { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }, + } + } + patch_resp = authenticated_client.patch( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}), + data=patch_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST + error_body = str(patch_resp.json()).lower() + assert "cannot change bedrock authentication method from api key" in error_body + + def test_bedrock_cannot_switch_from_access_keys_to_api_key_on_update( + self, authenticated_client + ): + """If created with access keys, switching to API key via update should be rejected""" + valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110) + initial_credentials = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-east-1", + } + create_payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "bedrock", + "credentials": initial_credentials, + }, + } + } + create_resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=create_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert create_resp.status_code == status.HTTP_201_CREATED + provider_id = create_resp.json()["data"]["id"] + + # Attempt to introduce API key on update + patch_payload = { + "data": { + "type": "lighthouse-providers", + "id": provider_id, + "attributes": { + "credentials": { + "api_key": valid_api_key, + }, + }, + } + } + patch_resp = authenticated_client.patch( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}), + data=patch_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST + error_body = str(patch_resp.json()).lower() + assert ( + "cannot change bedrock authentication method from access key" in error_body + ) + + @pytest.mark.parametrize( + "attributes", + [ + pytest.param( + { + "provider_type": "openai_compatible", + "credentials": {"api_key": "compat-key"}, + }, + id="missing", + ), + pytest.param( + { + "provider_type": "openai_compatible", + "credentials": {"api_key": "compat-key"}, + "base_url": "", + }, + id="empty", + ), + ], + ) + def test_openai_compatible_missing_base_url(self, authenticated_client, attributes): + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": attributes, + } + } + + resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + error_detail = str(resp.json()).lower() + assert "base_url" in error_detail + + def test_openai_compatible_invalid_credentials(self, authenticated_client): + payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "openai_compatible", + "base_url": "https://compat.example/v1", + "credentials": {"api_key": ""}, + }, + } + } + + resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + errors = resp.json().get("errors", []) + assert any( + error.get("source", {}).get("pointer") + == "/data/attributes/credentials/api_key" + for error in errors + ) + assert any( + "may not be blank" in error.get("detail", "").lower() for error in errors + ) + + def test_openai_compatible_patch_credentials_and_fields(self, authenticated_client): + create_payload = { + "data": { + "type": "lighthouse-providers", + "attributes": { + "provider_type": "openai_compatible", + "base_url": "https://compat.example/v1", + "credentials": {"api_key": "compat-key-123"}, + }, + } + } + + create_resp = authenticated_client.post( + reverse("lighthouse-providers-list"), + data=create_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert create_resp.status_code == status.HTTP_201_CREATED + provider_id = create_resp.json()["data"]["id"] + + updated_base_url = "https://compat.example/v2" + updated_api_key = "compat-key-456" + patch_payload = { + "data": { + "type": "lighthouse-providers", + "id": provider_id, + "attributes": { + "base_url": updated_base_url, + "credentials": {"api_key": updated_api_key}, + }, + } + } + + patch_resp = authenticated_client.patch( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}), + data=patch_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert patch_resp.status_code == status.HTTP_200_OK + updated_attrs = patch_resp.json()["data"]["attributes"] + assert updated_attrs["base_url"] == updated_base_url + assert updated_attrs["credentials"]["api_key"] == "*" * len(updated_api_key) + + get_resp = authenticated_client.get( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}) + ) + assert get_resp.status_code == status.HTTP_200_OK + masked = get_resp.json()["data"]["attributes"]["credentials"]["api_key"] + assert masked == "*" * len(updated_api_key) + + get_full = authenticated_client.get( + reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}) + + "?fields[lighthouse-providers]=credentials" + ) + assert get_full.status_code == status.HTTP_200_OK + creds = get_full.json()["data"]["attributes"]["credentials"] + assert creds["api_key"] == updated_api_key + + +@pytest.mark.django_db +class TestMuteRuleViewSet: + """Tests for MuteRule endpoints.""" + + def test_mute_rules_list(self, authenticated_client, mute_rules_fixture): + """Test listing all mute rules for the tenant.""" + response = authenticated_client.get(reverse("mute-rule-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == len(mute_rules_fixture) + + def test_mute_rules_list_empty(self, authenticated_client, tenants_fixture): + """Test listing mute rules when none exist returns empty list.""" + response = authenticated_client.get(reverse("mute-rule-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 0 + assert isinstance(data, list) + + def test_mute_rules_list_default_ordering( + self, authenticated_client, mute_rules_fixture + ): + """Test that mute rules are ordered by -inserted_at by default.""" + response = authenticated_client.get(reverse("mute-rule-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + if len(data) >= 2: + first_date = data[0]["attributes"]["inserted_at"] + second_date = data[1]["attributes"]["inserted_at"] + assert first_date >= second_date + + def test_mute_rules_retrieve(self, authenticated_client, mute_rules_fixture): + """Test retrieving a single mute rule by ID.""" + mute_rule = mute_rules_fixture[0] + response = authenticated_client.get( + reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}) + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["id"] == str(mute_rule.id) + assert data["attributes"]["name"] == mute_rule.name + assert data["attributes"]["reason"] == mute_rule.reason + assert data["attributes"]["enabled"] == mute_rule.enabled + assert "finding_uids" in data["attributes"] + assert "inserted_at" in data["attributes"] + assert "updated_at" in data["attributes"] + + def test_mute_rules_retrieve_invalid(self, authenticated_client): + """Test retrieving non-existent mute rule returns 404.""" + response = authenticated_client.get( + reverse( + "mute-rule-detail", + kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"}, + ) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.parametrize( + "filter_name, filter_value, expected_count", + ( + [ + ("name", "Test Rule 1", 1), + ("name.icontains", "rule", 2), + ("reason.icontains", "security", 1), + ("enabled", True, 1), + ("enabled", False, 1), + ] + ), + ) + def test_mute_rule_filters( + self, + authenticated_client, + mute_rules_fixture, + filter_name, + filter_value, + expected_count, + ): + """Test filtering mute rules by various fields.""" + filters = {f"filter[{filter_name}]": filter_value} + response = authenticated_client.get(reverse("mute-rule-list"), filters) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == expected_count + + def test_mute_rule_filter_by_created_by( + self, authenticated_client, mute_rules_fixture, create_test_user + ): + """Test filtering mute rules by creator.""" + response = authenticated_client.get( + reverse("mute-rule-list"), + {"filter[created_by]": create_test_user.id}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + + def test_mute_rule_search(self, authenticated_client, mute_rules_fixture): + """Test searching mute rules by name and reason.""" + response = authenticated_client.get( + reverse("mute-rule-list"), {"filter[search]": "Rule 1"} + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + + @pytest.mark.parametrize( + "sort_field, first_index", + ( + [ + ("name", 0), + ("-name", 1), + ("inserted_at", 0), + ("-inserted_at", 1), + ] + ), + ) + def test_mute_rule_ordering( + self, authenticated_client, mute_rules_fixture, sort_field, first_index + ): + """Test ordering mute rules by various fields.""" + response = authenticated_client.get( + reverse("mute-rule-list"), {"sort": sort_field} + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + assert data[0]["id"] == str(mute_rules_fixture[first_index].id) + + @patch("tasks.tasks.mute_historical_findings_task.apply_async") + def test_mute_rules_create_valid( + self, + mock_task, + authenticated_client, + findings_fixture, + create_test_user, + ): + """Test creating a valid mute rule.""" + finding_ids = [str(findings_fixture[0].id)] + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": "New Mute Rule", + "reason": "Security exception approved", + "finding_ids": finding_ids, + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + + # Verify response contains the created mute rule + response_data = response.json()["data"] + assert response_data["type"] == "mute-rules" + assert response_data["attributes"]["name"] == "New Mute Rule" + assert response_data["attributes"]["reason"] == "Security exception approved" + + # Verify the finding was immediately muted + from api.models import Finding + + finding = Finding.objects.get(id=findings_fixture[0].id) + assert finding.muted is True + assert finding.muted_at is not None + assert finding.muted_reason == "Security exception approved" + + # Verify background task was called + mock_task.assert_called_once() + + @patch("tasks.tasks.mute_historical_findings_task.apply_async") + def test_mute_rules_create_converts_finding_ids_to_uids( + self, + mock_task, + authenticated_client, + findings_fixture, + ): + """Test that finding_ids are converted to finding UIDs.""" + finding_ids = [str(findings_fixture[0].id), str(findings_fixture[1].id)] + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": "UID Conversion Test", + "reason": "Testing UID conversion", + "finding_ids": finding_ids, + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + + # Verify finding_uids contains the UIDs, not IDs + from api.models import MuteRule + + mute_rule = MuteRule.objects.get(name="UID Conversion Test") + expected_uids = [ + findings_fixture[0].uid, + findings_fixture[1].uid, + ] + assert set(mute_rule.finding_uids) == set(expected_uids) + + @patch("tasks.tasks.mute_historical_findings_task.apply_async") + def test_mute_rules_deduplicates_uids( + self, + mock_task, + authenticated_client, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + """Test that multiple findings with same UID result in only one UID in the rule.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + shared_uid = "prowler-aws-dedupe-test-001" + + finding1 = Finding.objects.create( + tenant=tenant, + uid=shared_uid, + scan=scan, + status=Status.FAIL, + status_extended="test", + severity=Severity.high, + impact=Severity.high, + check_id="test_check", + check_metadata={"CheckId": "test_check"}, + raw_result={}, + ) + + finding2 = Finding.objects.create( + tenant=tenant, + uid=shared_uid, + scan=scan, + status=Status.FAIL, + status_extended="test", + severity=Severity.high, + impact=Severity.high, + check_id="test_check", + check_metadata={"CheckId": "test_check"}, + raw_result={}, + ) + + finding_ids = [str(finding1.id), str(finding2.id)] + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": "Dedupe Test Rule", + "reason": "Testing UID deduplication", + "finding_ids": finding_ids, + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + + from api.models import MuteRule + + mute_rule = MuteRule.objects.get(name="Dedupe Test Rule") + assert len(mute_rule.finding_uids) == 1 + assert mute_rule.finding_uids[0] == shared_uid + + finding1.refresh_from_db() + finding2.refresh_from_db() + assert finding1.muted is True + assert finding2.muted is True + + @patch("tasks.tasks.mute_historical_findings_task.apply_async") + def test_mute_rules_create_overlap_detection_active( + self, + mock_task, + authenticated_client, + mute_rules_fixture, + findings_fixture, + ): + """Test that creating a rule with overlapping UIDs in active rule fails.""" + # mute_rules_fixture[0] is active and has findings_fixture[0] UID + finding_ids = [str(findings_fixture[0].id)] + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": "Overlapping Rule", + "reason": "This should fail", + "finding_ids": finding_ids, + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert "errors" in response.json() + error_detail = response.json()["errors"][0]["detail"] + assert ( + "already muted" in error_detail.lower() or "overlap" in error_detail.lower() + ) + + @patch("tasks.tasks.mute_historical_findings_task.apply_async") + def test_mute_rules_create_no_overlap_with_inactive( + self, + mock_task, + authenticated_client, + mute_rules_fixture, + findings_fixture, + ): + """Test that disabled rules don't prevent new rules with same UIDs.""" + # mute_rules_fixture[1] is disabled + # Disable the enabled rule first + mute_rules_fixture[0].enabled = False + mute_rules_fixture[0].save() + + finding_ids = [str(findings_fixture[0].id)] + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": "Non-overlapping Rule", + "reason": "Inactive rules don't block", + "finding_ids": finding_ids, + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + + def test_mute_rules_create_invalid_empty_finding_ids(self, authenticated_client): + """Test creating mute rule with empty finding_ids fails.""" + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": "Valid", + "reason": "Valid", + "finding_ids": [], + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + assert ( + response.json()["errors"][0]["source"]["pointer"] + == "/data/attributes/finding_ids" + ) + + @patch("tasks.tasks.mute_historical_findings_task.apply_async") + def test_mute_rules_create_invalid_finding_ids( + self, mock_task, authenticated_client + ): + """Test creating mute rule with non-existent finding IDs fails.""" + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": "Invalid Findings", + "reason": "This should fail", + "finding_ids": ["f498b103-c760-4785-9a3e-e23fafbb7b02"], + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + + def test_mute_rules_create_duplicate_name( + self, authenticated_client, mute_rules_fixture + ): + """Test creating a mute rule with duplicate name fails.""" + existing_name = mute_rules_fixture[0].name + data = { + "data": { + "type": "mute-rules", + "attributes": { + "name": existing_name, + "reason": "Duplicate name test", + "finding_ids": ["f498b103-c760-4785-9a3e-e23fafbb7b02"], + }, + } + } + response = authenticated_client.post( + reverse("mute-rule-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + + def test_mute_rules_update_name(self, authenticated_client, mute_rules_fixture): + """Test updating mute rule name.""" + mute_rule = mute_rules_fixture[0] + data = { + "data": { + "type": "mute-rules", + "id": str(mute_rule.id), + "attributes": { + "name": "Updated Name", + }, + } + } + response = authenticated_client.patch( + reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json()["data"] + assert response_data["attributes"]["name"] == "Updated Name" + + # Verify database was updated + mute_rule.refresh_from_db() + assert mute_rule.name == "Updated Name" + + def test_mute_rules_update_reason(self, authenticated_client, mute_rules_fixture): + """Test updating mute rule reason.""" + mute_rule = mute_rules_fixture[0] + data = { + "data": { + "type": "mute-rules", + "id": str(mute_rule.id), + "attributes": { + "reason": "Updated reason for muting", + }, + } + } + response = authenticated_client.patch( + reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json()["data"] + assert response_data["attributes"]["reason"] == "Updated reason for muting" + + mute_rule.refresh_from_db() + assert mute_rule.reason == "Updated reason for muting" + + def test_mute_rules_update_enabled(self, authenticated_client, mute_rules_fixture): + """Test disabling a mute rule.""" + mute_rule = mute_rules_fixture[0] + assert mute_rule.enabled is True + + data = { + "data": { + "type": "mute-rules", + "id": str(mute_rule.id), + "attributes": { + "enabled": False, + }, + } + } + response = authenticated_client.patch( + reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json()["data"] + assert response_data["attributes"]["enabled"] is False + + mute_rule.refresh_from_db() + assert mute_rule.enabled is False + + def test_mute_rules_update_duplicate_name( + self, authenticated_client, mute_rules_fixture + ): + """Test updating mute rule with duplicate name fails.""" + first_rule = mute_rules_fixture[0] + second_rule = mute_rules_fixture[1] + + data = { + "data": { + "type": "mute-rules", + "id": str(second_rule.id), + "attributes": { + "name": first_rule.name, + }, + } + } + response = authenticated_client.patch( + reverse("mute-rule-detail", kwargs={"pk": second_rule.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + + def test_mute_rules_delete(self, authenticated_client, mute_rules_fixture): + """Test deleting a mute rule.""" + mute_rule = mute_rules_fixture[0] + response = authenticated_client.delete( + reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify rule was deleted + from api.models import MuteRule + + assert not MuteRule.objects.filter(id=mute_rule.id).exists() + + def test_mute_rules_tenant_isolation( + self, authenticated_client, mute_rules_fixture, tenants_fixture + ): + """Test that users can only access mute rules from their tenant.""" + # Create a second tenant with a mute rule + from api.models import MuteRule, Tenant + + other_tenant = Tenant.objects.create(name="Other Tenant") + other_rule = MuteRule.objects.create( + tenant=other_tenant, + name="Other Tenant Rule", + reason="Should not be visible", + finding_uids=["test-uid"], + ) + + # Try to access other tenant's rule + response = authenticated_client.get( + reverse("mute-rule-detail", kwargs={"pk": other_rule.id}) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # List should only show current tenant's rules + response = authenticated_client.get(reverse("mute-rule-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == len(mute_rules_fixture) + for rule_data in data: + assert rule_data["id"] != str(other_rule.id) diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 0675c64d74..355c3b6e03 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,16 +14,27 @@ 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.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.kubernetes.kubernetes_provider import KubernetesProvider -from prowler.providers.m365.m365_provider import M365Provider -from prowler.providers.oraclecloud.oci_provider import OciProvider + +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.cloudflare.cloudflare_provider import CloudflareProvider + 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.openstack.openstack_provider import OpenstackProvider + from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider class CustomOAuth2Client(OAuth2Client): @@ -61,41 +75,90 @@ def merge_dicts(default_dict: dict, replacement_dict: dict) -> dict: def return_prowler_provider( provider: Provider, -) -> [ - AwsProvider +) -> ( + AlibabacloudProvider + | AwsProvider | AzureProvider + | CloudflareProvider | GcpProvider | GithubProvider + | IacProvider | KubernetesProvider | M365Provider - | OciProvider -]: + | MongodbatlasProvider + | OpenstackProvider + | OraclecloudProvider +): """Return the Prowler provider class based on the given provider type. Args: provider (Provider): The provider object containing the provider type and associated secrets. Returns: - AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider | OciProvider: The corresponding provider class. + AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class. Raises: ValueError: If the provider type specified in `provider.provider` is not supported. """ 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.OCI.value: - prowler_provider = OciProvider + 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 Provider.ProviderChoices.CLOUDFLARE.value: + from prowler.providers.cloudflare.cloudflare_provider import ( + CloudflareProvider, + ) + + prowler_provider = CloudflareProvider + case Provider.ProviderChoices.OPENSTACK.value: + from prowler.providers.openstack.openstack_provider import OpenstackProvider + + prowler_provider = OpenstackProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -132,10 +195,37 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "organizations": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.IAC.value: + # For IaC provider, uid contains the repository URL + # Extract the access token if present in the secret + prowler_provider_kwargs = { + "scan_repository_url": provider.uid, + } + if "access_token" in provider.secret.secret: + prowler_provider_kwargs["oauth_app_token"] = provider.secret.secret[ + "access_token" + ] + elif provider.provider == Provider.ProviderChoices.MONGODBATLAS.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "atlas_organization_id": provider.uid, + } + elif provider.provider == Provider.ProviderChoices.CLOUDFLARE.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "filter_accounts": [provider.uid], + } + elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: + # No extra kwargs needed: clouds_yaml_content and clouds_yaml_cloud from the + # secret are sufficient. Validating project_id (provider.uid) against the + # clouds.yaml is not feasible because not all auth methods include it and the + # Keystone API is unavailable on public clouds. + pass if mutelist_processor: mutelist_content = mutelist_processor.configuration.get("Mutelist", {}) - if mutelist_content: + # IaC provider doesn't support mutelist (uses Trivy's built-in logic) + if mutelist_content and provider.provider != Provider.ProviderChoices.IAC.value: prowler_provider_kwargs["mutelist_content"] = mutelist_content return prowler_provider_kwargs @@ -145,13 +235,18 @@ def initialize_prowler_provider( provider: Provider, mutelist_processor: Processor | None = None, ) -> ( - AwsProvider + AlibabacloudProvider + | AwsProvider | AzureProvider + | CloudflareProvider | GcpProvider | GithubProvider + | IacProvider | KubernetesProvider | M365Provider - | OciProvider + | MongodbatlasProvider + | OpenstackProvider + | OraclecloudProvider ): """Initialize a Prowler provider instance based on the given provider type. @@ -160,9 +255,8 @@ def initialize_prowler_provider( mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration. Returns: - AwsProvider | AzureProvider | GcpProvider | GithubProvider | KubernetesProvider | M365Provider | OciProvider: An instance of the corresponding provider class - (`AwsProvider`, `AzureProvider`, `GcpProvider`, `GithubProvider`, `KubernetesProvider`, `M365Provider` or `OciProvider`) initialized with the - provider's secrets. + AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class + initialized with the provider's secrets. """ prowler_provider = return_prowler_provider(provider) prowler_provider_kwargs = get_prowler_provider_kwargs(provider, mutelist_processor) @@ -185,9 +279,30 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: except Provider.secret.RelatedObjectDoesNotExist as secret_error: return Connection(is_connected=False, error=secret_error) - return prowler_provider.test_connection( - **prowler_provider_kwargs, provider_id=provider.uid, raise_on_exception=False - ) + # For IaC provider, construct the kwargs properly for test_connection + if provider.provider == Provider.ProviderChoices.IAC.value: + # Don't pass repository_url from secret, use scan_repository_url with the UID + iac_test_kwargs = { + "scan_repository_url": provider.uid, + "raise_on_exception": False, + } + # Add access_token if present in the secret + if "access_token" in prowler_provider_kwargs: + iac_test_kwargs["access_token"] = prowler_provider_kwargs["access_token"] + return prowler_provider.test_connection(**iac_test_kwargs) + elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: + openstack_kwargs = { + "clouds_yaml_content": prowler_provider_kwargs["clouds_yaml_content"], + "clouds_yaml_cloud": prowler_provider_kwargs["clouds_yaml_cloud"], + "raise_on_exception": False, + } + return prowler_provider.test_connection(**openstack_kwargs) + else: + return prowler_provider.test_connection( + **prowler_provider_kwargs, + provider_id=provider.uid, + raise_on_exception=False, + ) def prowler_integration_connection_test(integration: Integration) -> Connection: @@ -342,10 +457,28 @@ def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset): regions = sorted({region for region in aggregation["regions"] or [] if region}) resource_types = sorted(set(aggregation["resource_types"] or [])) + # Aggregate categories from findings + categories_set = set() + for categories_list in filtered_queryset.values_list("categories", flat=True): + if categories_list: + categories_set.update(categories_list) + categories = sorted(categories_set) + + # Aggregate groups from findings + groups = list( + filtered_queryset.exclude(resource_groups__isnull=True) + .exclude(resource_groups__exact="") + .values_list("resource_groups", flat=True) + .distinct() + .order_by("resource_groups") + ) + result = { "services": services, "regions": regions, "resource_types": resource_types, + "categories": categories, + "groups": groups, } serializer = FindingMetadataSerializer(data=result) diff --git a/api/src/backend/api/v1/serializer_utils/lighthouse.py b/api/src/backend/api/v1/serializer_utils/lighthouse.py index f7ecef32cc..e0274a7bbb 100644 --- a/api/src/backend/api/v1/serializer_utils/lighthouse.py +++ b/api/src/backend/api/v1/serializer_utils/lighthouse.py @@ -1,5 +1,6 @@ import re +from drf_spectacular.utils import extend_schema_field from rest_framework_json_api import serializers @@ -11,3 +12,289 @@ class OpenAICredentialsSerializer(serializers.Serializer): if not re.match(pattern, value or ""): raise serializers.ValidationError("Invalid OpenAI API key format.") return value + + def to_internal_value(self, data): + """Check for unknown fields before DRF filters them out.""" + if not isinstance(data, dict): + raise serializers.ValidationError( + {"non_field_errors": ["Credentials must be an object"]} + ) + + allowed_fields = set(self.fields.keys()) + provided_fields = set(data.keys()) + extra_fields = provided_fields - allowed_fields + + if extra_fields: + raise serializers.ValidationError( + { + "non_field_errors": [ + f"Unknown fields in credentials: {', '.join(sorted(extra_fields))}" + ] + } + ) + + return super().to_internal_value(data) + + +class BedrockCredentialsSerializer(serializers.Serializer): + """ + Serializer for AWS Bedrock credentials validation. + + Supports two authentication methods: + 1. AWS access key + secret key + 2. Bedrock API key (bearer token) + + In both cases, region is mandatory. + """ + + access_key_id = serializers.CharField(required=False, allow_blank=False) + secret_access_key = serializers.CharField(required=False, allow_blank=False) + api_key = serializers.CharField(required=False, allow_blank=False) + region = serializers.CharField() + + def validate_access_key_id(self, value: str) -> str: + """Validate AWS access key ID format (AKIA for long-term credentials).""" + pattern = r"^AKIA[0-9A-Z]{16}$" + if not re.match(pattern, value or ""): + raise serializers.ValidationError( + "Invalid AWS access key ID format. Must be AKIA followed by 16 alphanumeric characters." + ) + return value + + def validate_secret_access_key(self, value: str) -> str: + """Validate AWS secret access key format (40 base64 characters).""" + pattern = r"^[A-Za-z0-9/+=]{40}$" + if not re.match(pattern, value or ""): + raise serializers.ValidationError( + "Invalid AWS secret access key format. Must be 40 base64 characters." + ) + return value + + def validate_api_key(self, value: str) -> str: + """ + Validate Bedrock API key (bearer token). + """ + pattern = r"^ABSKQmVkcm9ja0FQSUtleS[A-Za-z0-9+/=]{110}$" + if not re.match(pattern, value or ""): + raise serializers.ValidationError("Invalid Bedrock API key format.") + return value + + def validate_region(self, value: str) -> str: + """Validate AWS region format.""" + pattern = r"^[a-z]{2}-[a-z]+-\d+$" + if not re.match(pattern, value or ""): + raise serializers.ValidationError( + "Invalid AWS region format. Expected format like 'us-east-1' or 'eu-west-2'." + ) + return value + + def validate(self, attrs): + """ + Enforce either: + - access_key_id + secret_access_key + region + OR + - api_key + region + """ + access_key_id = attrs.get("access_key_id") + secret_access_key = attrs.get("secret_access_key") + api_key = attrs.get("api_key") + region = attrs.get("region") + + errors = {} + + if not region: + errors["region"] = ["Region is required."] + + using_access_keys = bool(access_key_id or secret_access_key) + using_api_key = api_key is not None and api_key != "" + + if using_access_keys and using_api_key: + errors["non_field_errors"] = [ + "Provide either access key + secret key OR api key, not both." + ] + elif not using_access_keys and not using_api_key: + errors["non_field_errors"] = [ + "You must provide either access key + secret key OR api key." + ] + elif using_access_keys: + # Both access_key_id and secret_access_key must be present together + if not access_key_id: + errors.setdefault("access_key_id", []).append( + "AWS access key ID is required when using access key authentication." + ) + if not secret_access_key: + errors.setdefault("secret_access_key", []).append( + "AWS secret access key is required when using access key authentication." + ) + + if errors: + raise serializers.ValidationError(errors) + + return attrs + + def to_internal_value(self, data): + """Check for unknown fields before DRF filters them out.""" + if not isinstance(data, dict): + raise serializers.ValidationError( + {"non_field_errors": ["Credentials must be an object"]} + ) + + allowed_fields = set(self.fields.keys()) + provided_fields = set(data.keys()) + extra_fields = provided_fields - allowed_fields + + if extra_fields: + raise serializers.ValidationError( + { + "non_field_errors": [ + f"Unknown fields in credentials: {', '.join(sorted(extra_fields))}" + ] + } + ) + + return super().to_internal_value(data) + + +class BedrockCredentialsUpdateSerializer(BedrockCredentialsSerializer): + """ + Serializer for AWS Bedrock credentials during UPDATE operations. + + Inherits all validation logic from BedrockCredentialsSerializer but makes + all fields optional to support partial updates. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Make all fields optional for updates + for field in self.fields.values(): + field.required = False + + def validate(self, attrs): + """ + For updates, this serializer only checks individual fields. + It does NOT enforce the "either access keys OR api key" rule. + That rule is applied later, after merging with existing stored + credentials, in LighthouseProviderConfigUpdateSerializer. + """ + return attrs + + +class OpenAICompatibleCredentialsSerializer(serializers.Serializer): + """ + Minimal serializer for OpenAI-compatible credentials. + + Many OpenAI-compatible providers do not use the same key format as OpenAI. + We only require a non-empty API key string. Additional fields can be added later + without breaking existing configurations. + """ + + api_key = serializers.CharField() + + def validate_api_key(self, value: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise serializers.ValidationError("API key is required.") + return value.strip() + + def to_internal_value(self, data): + """Check for unknown fields before DRF filters them out.""" + if not isinstance(data, dict): + raise serializers.ValidationError( + {"non_field_errors": ["Credentials must be an object"]} + ) + + allowed_fields = set(self.fields.keys()) + provided_fields = set(data.keys()) + extra_fields = provided_fields - allowed_fields + + if extra_fields: + raise serializers.ValidationError( + { + "non_field_errors": [ + f"Unknown fields in credentials: {', '.join(sorted(extra_fields))}" + ] + } + ) + + return super().to_internal_value(data) + + +@extend_schema_field( + { + "oneOf": [ + { + "type": "object", + "title": "OpenAI Credentials", + "properties": { + "api_key": { + "type": "string", + "description": "OpenAI API key. Must start with 'sk-' followed by alphanumeric characters, " + "hyphens, or underscores.", + "pattern": "^sk-[\\w-]+$", + } + }, + "required": ["api_key"], + }, + { + "title": "AWS Bedrock Credentials", + "oneOf": [ + { + "title": "IAM Access Key Pair", + "type": "object", + "description": "Authenticate with AWS access key and secret key. Recommended when you manage IAM users or roles.", + "properties": { + "access_key_id": { + "type": "string", + "description": "AWS access key ID.", + "pattern": "^AKIA[0-9A-Z]{16}$", + }, + "secret_access_key": { + "type": "string", + "description": "AWS secret access key.", + "pattern": "^[A-Za-z0-9/+=]{40}$", + }, + "region": { + "type": "string", + "description": "AWS region identifier where Bedrock is available. Examples: us-east-1, " + "us-west-2, eu-west-1, ap-northeast-1.", + "pattern": "^[a-z]{2}-[a-z]+-\\d+$", + }, + }, + "required": ["access_key_id", "secret_access_key", "region"], + }, + { + "title": "Amazon Bedrock API Key", + "type": "object", + "description": "Authenticate with an Amazon Bedrock API key (bearer token). Region is still required.", + "properties": { + "api_key": { + "type": "string", + "description": "Amazon Bedrock API key (bearer token).", + }, + "region": { + "type": "string", + "description": "AWS region identifier where Bedrock is available. Examples: us-east-1, " + "us-west-2, eu-west-1, ap-northeast-1.", + "pattern": "^[a-z]{2}-[a-z]+-\\d+$", + }, + }, + "required": ["api_key", "region"], + }, + ], + }, + { + "type": "object", + "title": "OpenAI Compatible Credentials", + "properties": { + "api_key": { + "type": "string", + "description": "API key for OpenAI-compatible provider. The format varies by provider. " + "Note: The 'base_url' field (separate from credentials) is required when using this provider type.", + } + }, + "required": ["api_key"], + }, + ] + } +) +class LighthouseCredentialsField(serializers.JSONField): + pass diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 8a47865fbd..83ea942793 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -239,6 +239,21 @@ from rest_framework_json_api import serializers }, "required": ["github_app_id", "github_app_key"], }, + { + "type": "object", + "title": "IaC Repository Credentials", + "properties": { + "repository_url": { + "type": "string", + "description": "Repository URL to scan for IaC files.", + }, + "access_token": { + "type": "string", + "description": "Optional access token for private repositories.", + }, + }, + "required": ["repository_url"], + }, { "type": "object", "title": "Oracle Cloud Infrastructure (OCI) API Key Credentials", @@ -274,6 +289,105 @@ from rest_framework_json_api import serializers }, "required": ["user", "fingerprint", "tenancy", "region"], }, + { + "type": "object", + "title": "MongoDB Atlas API Key", + "properties": { + "atlas_public_key": { + "type": "string", + "description": "MongoDB Atlas API public key.", + }, + "atlas_private_key": { + "type": "string", + "description": "MongoDB Atlas API private key.", + }, + }, + "required": ["atlas_public_key", "atlas_private_key"], + }, + { + "type": "object", + "title": "Alibaba Cloud Static Credentials", + "properties": { + "access_key_id": { + "type": "string", + "description": "The Alibaba Cloud access key ID for authentication.", + }, + "access_key_secret": { + "type": "string", + "description": "The Alibaba Cloud access key secret for authentication.", + }, + "security_token": { + "type": "string", + "description": "The STS security token for temporary credentials (optional).", + }, + }, + "required": ["access_key_id", "access_key_secret"], + }, + { + "type": "object", + "title": "Alibaba Cloud RAM Role Assumption", + "properties": { + "role_arn": { + "type": "string", + "description": "The ARN of the RAM role to assume (e.g., acs:ram::1234567890123456:role/ProwlerRole).", + }, + "access_key_id": { + "type": "string", + "description": "The Alibaba Cloud access key ID of the RAM user that will assume the role.", + }, + "access_key_secret": { + "type": "string", + "description": "The Alibaba Cloud access key secret of the RAM user that will assume the role.", + }, + "role_session_name": { + "type": "string", + "description": "An identifier for the role session (optional, defaults to 'ProwlerSession').", + }, + }, + "required": ["role_arn", "access_key_id", "access_key_secret"], + }, + { + "type": "object", + "title": "Cloudflare API Token", + "properties": { + "api_token": { + "type": "string", + "description": "Cloudflare API Token for authentication (recommended).", + }, + }, + "required": ["api_token"], + }, + { + "type": "object", + "title": "Cloudflare API Key + Email", + "properties": { + "api_key": { + "type": "string", + "description": "Cloudflare Global API Key for authentication (legacy).", + }, + "api_email": { + "type": "string", + "format": "email", + "description": "Email address associated with the Cloudflare account.", + }, + }, + "required": ["api_key", "api_email"], + }, + { + "type": "object", + "title": "OpenStack clouds.yaml Credentials", + "properties": { + "clouds_yaml_content": { + "type": "string", + "description": "The full content of a clouds.yaml configuration file.", + }, + "clouds_yaml_cloud": { + "type": "string", + "description": "The name of the cloud to use from the clouds.yaml file.", + }, + }, + "required": ["clouds_yaml_content", "clouds_yaml_cloud"], + }, ] } ) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index adc79b7a12..d97a952bc3 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -21,6 +21,7 @@ from rest_framework_simplejwt.tokens import RefreshToken from api.db_router import MainRouter from api.exceptions import ConflictException from api.models import ( + AttackPathsScan, Finding, Integration, IntegrationProviderRelationship, @@ -31,6 +32,7 @@ from api.models import ( LighthouseProviderModels, LighthouseTenantConfiguration, Membership, + MuteRule, Processor, Provider, ProviderGroup, @@ -46,6 +48,7 @@ from api.models import ( StatusChoices, Task, TenantAPIKey, + ThreatScoreSnapshot, User, UserRoleRelationship, ) @@ -59,11 +62,53 @@ from api.v1.serializer_utils.integrations import ( S3ConfigSerializer, SecurityHubConfigSerializer, ) -from api.v1.serializer_utils.lighthouse import OpenAICredentialsSerializer +from api.v1.serializer_utils.lighthouse import ( + BedrockCredentialsSerializer, + BedrockCredentialsUpdateSerializer, + LighthouseCredentialsField, + OpenAICompatibleCredentialsSerializer, + OpenAICredentialsSerializer, +) from api.v1.serializer_utils.processors import ProcessorConfigField from api.v1.serializer_utils.providers import ProviderSecretField from prowler.lib.mutelist.mutelist import Mutelist +# Base + + +class BaseModelSerializerV1(serializers.ModelSerializer): + def get_root_meta(self, _resource, _many): + return {"version": "v1"} + + +class BaseSerializerV1(serializers.Serializer): + def get_root_meta(self, _resource, _many): + return {"version": "v1"} + + +class BaseWriteSerializer(BaseModelSerializerV1): + def validate(self, data): + if hasattr(self, "initial_data"): + initial_data = set(self.initial_data.keys()) - {"id", "type"} + unknown_keys = initial_data - set(self.fields.keys()) + if unknown_keys: + raise ValidationError(f"Invalid fields: {unknown_keys}") + return data + + +class RLSSerializer(BaseModelSerializerV1): + def create(self, validated_data): + tenant_id = self.context.get("tenant_id") + validated_data["tenant_id"] = tenant_id + return super().create(validated_data) + + +class StateEnumSerializerField(serializers.ChoiceField): + def __init__(self, **kwargs): + kwargs["choices"] = StateChoices.choices + super().__init__(**kwargs) + + # Tokens @@ -171,7 +216,7 @@ class TokenSocialLoginSerializer(BaseTokenSerializer): # TODO: Check if we can change the parent class to TokenRefreshSerializer from rest_framework_simplejwt.serializers -class TokenRefreshSerializer(serializers.Serializer): +class TokenRefreshSerializer(BaseSerializerV1): refresh = serializers.CharField() # Output token @@ -205,7 +250,7 @@ class TokenRefreshSerializer(serializers.Serializer): raise ValidationError({"refresh": "Invalid or expired token"}) -class TokenSwitchTenantSerializer(serializers.Serializer): +class TokenSwitchTenantSerializer(BaseSerializerV1): tenant_id = serializers.UUIDField( write_only=True, help_text="The tenant ID for which to request a new token." ) @@ -229,41 +274,10 @@ class TokenSwitchTenantSerializer(serializers.Serializer): return generate_tokens(user, tenant_id) -# Base - - -class BaseSerializerV1(serializers.ModelSerializer): - def get_root_meta(self, _resource, _many): - return {"version": "v1"} - - -class BaseWriteSerializer(BaseSerializerV1): - def validate(self, data): - if hasattr(self, "initial_data"): - initial_data = set(self.initial_data.keys()) - {"id", "type"} - unknown_keys = initial_data - set(self.fields.keys()) - if unknown_keys: - raise ValidationError(f"Invalid fields: {unknown_keys}") - return data - - -class RLSSerializer(BaseSerializerV1): - def create(self, validated_data): - tenant_id = self.context.get("tenant_id") - validated_data["tenant_id"] = tenant_id - return super().create(validated_data) - - -class StateEnumSerializerField(serializers.ChoiceField): - def __init__(self, **kwargs): - kwargs["choices"] = StateChoices.choices - super().__init__(**kwargs) - - # Users -class UserSerializer(BaseSerializerV1): +class UserSerializer(BaseModelSerializerV1): """ Serializer for the User model. """ @@ -394,7 +408,7 @@ class UserUpdateSerializer(BaseWriteSerializer): return super().update(instance, validated_data) -class RoleResourceIdentifierSerializer(serializers.Serializer): +class RoleResourceIdentifierSerializer(BaseSerializerV1): resource_type = serializers.CharField(source="type") id = serializers.UUIDField() @@ -577,7 +591,7 @@ class TaskSerializer(RLSSerializer, TaskBase): # Tenants -class TenantSerializer(BaseSerializerV1): +class TenantSerializer(BaseModelSerializerV1): """ Serializer for the Tenant model. """ @@ -589,7 +603,7 @@ class TenantSerializer(BaseSerializerV1): fields = ["id", "name", "memberships"] -class TenantIncludeSerializer(BaseSerializerV1): +class TenantIncludeSerializer(BaseModelSerializerV1): class Meta: model = Tenant fields = ["id", "name"] @@ -765,7 +779,7 @@ class ProviderGroupUpdateSerializer(ProviderGroupSerializer): return super().update(instance, validated_data) -class ProviderResourceIdentifierSerializer(serializers.Serializer): +class ProviderResourceIdentifierSerializer(BaseSerializerV1): resource_type = serializers.CharField(source="type") id = serializers.UUIDField() @@ -1102,7 +1116,7 @@ class ScanTaskSerializer(RLSSerializer): ] -class ScanReportSerializer(serializers.Serializer): +class ScanReportSerializer(BaseSerializerV1): id = serializers.CharField(source="scan") class Meta: @@ -1110,7 +1124,7 @@ class ScanReportSerializer(serializers.Serializer): fields = ["id"] -class ScanComplianceReportSerializer(serializers.Serializer): +class ScanComplianceReportSerializer(BaseSerializerV1): id = serializers.CharField(source="scan") name = serializers.CharField() @@ -1119,6 +1133,119 @@ class ScanComplianceReportSerializer(serializers.Serializer): fields = ["id", "name"] +class AttackPathsScanSerializer(RLSSerializer): + state = StateEnumSerializerField(read_only=True) + provider_alias = serializers.SerializerMethodField(read_only=True) + provider_type = serializers.SerializerMethodField(read_only=True) + provider_uid = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = AttackPathsScan + fields = [ + "id", + "state", + "progress", + "provider", + "provider_alias", + "provider_type", + "provider_uid", + "scan", + "task", + "inserted_at", + "started_at", + "completed_at", + "duration", + ] + + included_serializers = { + "provider": "api.v1.serializers.ProviderIncludeSerializer", + "scan": "api.v1.serializers.ScanIncludeSerializer", + "task": "api.v1.serializers.TaskSerializer", + } + + def get_provider_alias(self, obj): + provider = getattr(obj, "provider", None) + return provider.alias if provider else None + + def get_provider_type(self, obj): + provider = getattr(obj, "provider", None) + return provider.provider if provider else None + + def get_provider_uid(self, obj): + provider = getattr(obj, "provider", None) + return provider.uid if provider else None + + +class AttackPathsQueryAttributionSerializer(BaseSerializerV1): + text = serializers.CharField() + link = serializers.CharField() + + class JSONAPIMeta: + resource_name = "attack-paths-query-attributions" + + +class AttackPathsQueryParameterSerializer(BaseSerializerV1): + name = serializers.CharField() + label = serializers.CharField() + data_type = serializers.CharField(default="string") + description = serializers.CharField(allow_null=True, required=False) + placeholder = serializers.CharField(allow_null=True, required=False) + + class JSONAPIMeta: + resource_name = "attack-paths-query-parameters" + + +class AttackPathsQuerySerializer(BaseSerializerV1): + id = serializers.CharField() + name = serializers.CharField() + short_description = serializers.CharField() + description = serializers.CharField() + attribution = AttackPathsQueryAttributionSerializer(allow_null=True, required=False) + provider = serializers.CharField() + parameters = AttackPathsQueryParameterSerializer(many=True) + + class JSONAPIMeta: + resource_name = "attack-paths-queries" + + +class AttackPathsQueryRunRequestSerializer(BaseSerializerV1): + id = serializers.CharField() + parameters = serializers.DictField( + child=serializers.JSONField(), allow_empty=True, required=False + ) + + class JSONAPIMeta: + resource_name = "attack-paths-query-run-requests" + + +class AttackPathsNodeSerializer(BaseSerializerV1): + id = serializers.CharField() + labels = serializers.ListField(child=serializers.CharField()) + properties = serializers.DictField(child=serializers.JSONField()) + + class JSONAPIMeta: + resource_name = "attack-paths-query-result-nodes" + + +class AttackPathsRelationshipSerializer(BaseSerializerV1): + id = serializers.CharField() + label = serializers.CharField() + source = serializers.CharField() + target = serializers.CharField() + properties = serializers.DictField(child=serializers.JSONField()) + + class JSONAPIMeta: + resource_name = "attack-paths-query-result-relationships" + + +class AttackPathsQueryResultSerializer(BaseSerializerV1): + nodes = AttackPathsNodeSerializer(many=True) + relationships = AttackPathsRelationshipSerializer(many=True) + + class JSONAPIMeta: + resource_name = "attack-paths-query-results" + + class ResourceTagSerializer(RLSSerializer): """ Serializer for the ResourceTag model @@ -1159,11 +1286,19 @@ class ResourceSerializer(RLSSerializer): "findings", "failed_findings_count", "url", + "metadata", + "details", + "partition", + "groups", ] extra_kwargs = { "id": {"read_only": True}, "inserted_at": {"read_only": True}, "updated_at": {"read_only": True}, + "metadata": {"read_only": True}, + "details": {"read_only": True}, + "partition": {"read_only": True}, + "groups": {"read_only": True}, } included_serializers = { @@ -1220,11 +1355,15 @@ class ResourceIncludeSerializer(RLSSerializer): "service", "type_", "tags", + "details", + "partition", ] extra_kwargs = { "id": {"read_only": True}, "inserted_at": {"read_only": True}, "updated_at": {"read_only": True}, + "details": {"read_only": True}, + "partition": {"read_only": True}, } @extend_schema_field( @@ -1249,10 +1388,11 @@ class ResourceIncludeSerializer(RLSSerializer): return fields -class ResourceMetadataSerializer(serializers.Serializer): +class ResourceMetadataSerializer(BaseSerializerV1): services = serializers.ListField(child=serializers.CharField(), allow_empty=True) regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) types = serializers.ListField(child=serializers.CharField(), allow_empty=True) + groups = serializers.ListField(child=serializers.CharField(), allow_empty=True) # Temporarily disabled until we implement tag filtering in the UI # tags = serializers.JSONField(help_text="Tags are described as key-value pairs.") @@ -1278,6 +1418,8 @@ class FindingSerializer(RLSSerializer): "severity", "check_id", "check_metadata", + "categories", + "resource_groups", "raw_result", "inserted_at", "updated_at", @@ -1319,7 +1461,7 @@ class FindingIncludeSerializer(RLSSerializer): # To be removed when the related endpoint is removed as well -class FindingDynamicFilterSerializer(serializers.Serializer): +class FindingDynamicFilterSerializer(BaseSerializerV1): services = serializers.ListField(child=serializers.CharField(), allow_empty=True) regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) @@ -1327,12 +1469,16 @@ class FindingDynamicFilterSerializer(serializers.Serializer): resource_name = "finding-dynamic-filters" -class FindingMetadataSerializer(serializers.Serializer): +class FindingMetadataSerializer(BaseSerializerV1): services = serializers.ListField(child=serializers.CharField(), allow_empty=True) regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) resource_types = serializers.ListField( child=serializers.CharField(), allow_empty=True ) + categories = serializers.ListField(child=serializers.CharField(), allow_empty=True) + groups = serializers.ListField( + child=serializers.CharField(), allow_empty=True, required=False, default=list + ) # Temporarily disabled until we implement tag filtering in the UI # tags = serializers.JSONField(help_text="Tags are described as key-value pairs.") @@ -1355,18 +1501,47 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): serializer = GCPProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GITHUB.value: serializer = GithubProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.IAC.value: + serializer = IacProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.KUBERNETES.value: serializer = KubernetesProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.M365.value: serializer = M365ProviderSecret(data=secret) - elif provider_type == Provider.ProviderChoices.OCI.value: + elif provider_type == Provider.ProviderChoices.ORACLECLOUD.value: serializer = OracleCloudProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.MONGODBATLAS.value: + serializer = MongoDBAtlasProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.ALIBABACLOUD.value: + serializer = AlibabaCloudProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.CLOUDFLARE.value: + if "api_token" in secret: + serializer = CloudflareTokenProviderSecret(data=secret) + elif "api_key" in secret and "api_email" in secret: + serializer = CloudflareApiKeyProviderSecret(data=secret) + else: + raise serializers.ValidationError( + { + "secret": "Cloudflare credentials must include either 'api_token' " + "or both 'api_key' and 'api_email'." + } + ) + elif provider_type == Provider.ProviderChoices.OPENSTACK.value: + serializer = OpenStackCloudsYamlProviderSecret(data=secret) else: raise serializers.ValidationError( {"provider": f"Provider type not supported {provider_type}"} ) elif secret_type == ProviderSecret.TypeChoices.ROLE: - serializer = AWSRoleAssumptionProviderSecret(data=secret) + if provider_type == Provider.ProviderChoices.AWS.value: + serializer = AWSRoleAssumptionProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.ALIBABACLOUD.value: + serializer = AlibabaCloudRoleAssumptionProviderSecret(data=secret) + else: + raise serializers.ValidationError( + { + "secret_type": f"Role assumption not supported for provider type: {provider_type}" + } + ) elif secret_type == ProviderSecret.TypeChoices.SERVICE_ACCOUNT: serializer = GCPServiceAccountProviderSecret(data=secret) else: @@ -1457,6 +1632,14 @@ class GCPServiceAccountProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class MongoDBAtlasProviderSecret(serializers.Serializer): + atlas_public_key = serializers.CharField() + atlas_private_key = serializers.CharField() + + class Meta: + resource_name = "provider-secrets" + + class KubernetesProviderSecret(serializers.Serializer): kubeconfig_content = serializers.CharField() @@ -1474,6 +1657,14 @@ class GithubProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class IacProviderSecret(serializers.Serializer): + repository_url = serializers.CharField() + access_token = serializers.CharField(required=False) + + class Meta: + resource_name = "provider-secrets" + + class OracleCloudProviderSecret(serializers.Serializer): user = serializers.CharField() fingerprint = serializers.CharField() @@ -1487,6 +1678,57 @@ class OracleCloudProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class CloudflareTokenProviderSecret(serializers.Serializer): + api_token = serializers.CharField() + + class Meta: + resource_name = "provider-secrets" + + +class CloudflareApiKeyProviderSecret(serializers.Serializer): + api_key = serializers.CharField() + api_email = serializers.EmailField() + + class Meta: + resource_name = "provider-secrets" + + +class OpenStackCloudsYamlProviderSecret(serializers.Serializer): + clouds_yaml_content = serializers.CharField() + clouds_yaml_cloud = serializers.CharField() + + class Meta: + resource_name = "provider-secrets" + + +class AlibabaCloudProviderSecret(serializers.Serializer): + access_key_id = serializers.CharField() + access_key_secret = serializers.CharField() + security_token = serializers.CharField(required=False) + + class Meta: + resource_name = "provider-secrets" + + +class AlibabaCloudRoleAssumptionProviderSecret(serializers.Serializer): + role_arn = serializers.CharField( + help_text="Access Key ID of the RAM user that will assume the role" + ) + access_key_id = serializers.CharField( + help_text="Access Key ID of the RAM user that will assume the role" + ) + access_key_secret = serializers.CharField( + help_text="Access Key Secret of the RAM user that will assume the role" + ) + role_session_name = serializers.CharField( + required=False, + help_text="Session name for the assumed role session (optional, defaults to 'ProwlerSession')", + ) + + class Meta: + resource_name = "provider-secrets" + + class AWSRoleAssumptionProviderSecret(serializers.Serializer): role_arn = serializers.CharField() external_id = serializers.CharField() @@ -2001,7 +2243,7 @@ class RoleProviderGroupRelationshipSerializer(RLSSerializer, BaseWriteSerializer # Compliance overview -class ComplianceOverviewSerializer(serializers.Serializer): +class ComplianceOverviewSerializer(BaseSerializerV1): """ Serializer for compliance requirement status aggregated by compliance framework. @@ -2023,7 +2265,7 @@ class ComplianceOverviewSerializer(serializers.Serializer): resource_name = "compliance-overviews" -class ComplianceOverviewDetailSerializer(serializers.Serializer): +class ComplianceOverviewDetailSerializer(BaseSerializerV1): """ Serializer for detailed compliance requirement information. @@ -2052,7 +2294,7 @@ class ComplianceOverviewDetailThreatscoreSerializer(ComplianceOverviewDetailSeri total_findings = serializers.IntegerField() -class ComplianceOverviewAttributesSerializer(serializers.Serializer): +class ComplianceOverviewAttributesSerializer(BaseSerializerV1): id = serializers.CharField() compliance_name = serializers.CharField() framework_description = serializers.CharField() @@ -2066,7 +2308,7 @@ class ComplianceOverviewAttributesSerializer(serializers.Serializer): resource_name = "compliance-requirements-attributes" -class ComplianceOverviewMetadataSerializer(serializers.Serializer): +class ComplianceOverviewMetadataSerializer(BaseSerializerV1): regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) class JSONAPIMeta: @@ -2076,7 +2318,7 @@ class ComplianceOverviewMetadataSerializer(serializers.Serializer): # Overviews -class OverviewProviderSerializer(serializers.Serializer): +class OverviewProviderSerializer(BaseSerializerV1): id = serializers.CharField(source="provider") findings = serializers.SerializerMethodField(read_only=True) resources = serializers.SerializerMethodField(read_only=True) @@ -2084,9 +2326,6 @@ class OverviewProviderSerializer(serializers.Serializer): class JSONAPIMeta: resource_name = "providers-overview" - def get_root_meta(self, _resource, _many): - return {"version": "v1"} - @extend_schema_field( { "type": "object", @@ -2120,18 +2359,15 @@ class OverviewProviderSerializer(serializers.Serializer): } -class OverviewProviderCountSerializer(serializers.Serializer): +class OverviewProviderCountSerializer(BaseSerializerV1): id = serializers.CharField(source="provider") count = serializers.IntegerField() class JSONAPIMeta: resource_name = "providers-count-overview" - def get_root_meta(self, _resource, _many): - return {"version": "v1"} - -class OverviewFindingSerializer(serializers.Serializer): +class OverviewFindingSerializer(BaseSerializerV1): id = serializers.CharField(default="n/a") new = serializers.IntegerField() changed = serializers.IntegerField() @@ -2150,15 +2386,12 @@ class OverviewFindingSerializer(serializers.Serializer): class JSONAPIMeta: resource_name = "findings-overview" - def get_root_meta(self, _resource, _many): - return {"version": "v1"} - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["pass"] = self.fields.pop("_pass") -class OverviewSeveritySerializer(serializers.Serializer): +class OverviewSeveritySerializer(BaseSerializerV1): id = serializers.CharField(default="n/a") critical = serializers.IntegerField() high = serializers.IntegerField() @@ -2169,11 +2402,24 @@ class OverviewSeveritySerializer(serializers.Serializer): class JSONAPIMeta: resource_name = "findings-severity-overview" - def get_root_meta(self, _resource, _many): - return {"version": "v1"} + +class FindingsSeverityOverTimeSerializer(BaseSerializerV1): + """Serializer for daily findings severity trend data.""" + + id = serializers.DateField(source="date") + critical = serializers.IntegerField() + high = serializers.IntegerField() + medium = serializers.IntegerField() + low = serializers.IntegerField() + informational = serializers.IntegerField() + muted = serializers.IntegerField() + scan_ids = serializers.ListField(child=serializers.UUIDField()) + + class JSONAPIMeta: + resource_name = "findings-severity-over-time" -class OverviewServiceSerializer(serializers.Serializer): +class OverviewServiceSerializer(BaseSerializerV1): id = serializers.CharField(source="service") total = serializers.IntegerField() _pass = serializers.IntegerField() @@ -2187,6 +2433,84 @@ class OverviewServiceSerializer(serializers.Serializer): super().__init__(*args, **kwargs) self.fields["pass"] = self.fields.pop("_pass") + +class AttackSurfaceOverviewSerializer(BaseSerializerV1): + """Serializer for attack surface overview aggregations.""" + + id = serializers.CharField(source="attack_surface_type") + total_findings = serializers.IntegerField() + failed_findings = serializers.IntegerField() + muted_failed_findings = serializers.IntegerField() + + class JSONAPIMeta: + resource_name = "attack-surface-overviews" + + +class CategoryOverviewSerializer(BaseSerializerV1): + """Serializer for category overview aggregations.""" + + id = serializers.CharField(source="category") + total_findings = serializers.IntegerField() + failed_findings = serializers.IntegerField() + new_failed_findings = serializers.IntegerField() + severity = serializers.JSONField( + help_text="Severity breakdown: {informational, low, medium, high, critical}" + ) + + class JSONAPIMeta: + resource_name = "category-overviews" + + +class ResourceGroupOverviewSerializer(BaseSerializerV1): + """Serializer for resource group overview aggregations.""" + + id = serializers.CharField(source="resource_group") + total_findings = serializers.IntegerField() + failed_findings = serializers.IntegerField() + new_failed_findings = serializers.IntegerField() + resources_count = serializers.IntegerField() + severity = serializers.JSONField( + help_text="Severity breakdown: {informational, low, medium, high, critical}" + ) + + class JSONAPIMeta: + resource_name = "resource-group-overviews" + + +class ComplianceWatchlistOverviewSerializer(BaseSerializerV1): + """Serializer for compliance watchlist overview with FAIL-dominant aggregation.""" + + id = serializers.CharField(source="compliance_id") + compliance_id = serializers.CharField() + requirements_passed = serializers.IntegerField() + requirements_failed = serializers.IntegerField() + requirements_manual = serializers.IntegerField() + total_requirements = serializers.IntegerField() + + class JSONAPIMeta: + resource_name = "compliance-watchlist-overviews" + + +class OverviewRegionSerializer(serializers.Serializer): + id = serializers.SerializerMethodField() + provider_type = serializers.CharField() + region = serializers.CharField() + total = serializers.IntegerField() + _pass = serializers.IntegerField() + fail = serializers.IntegerField() + muted = serializers.IntegerField() + + class JSONAPIMeta: + resource_name = "regions-overview" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["pass"] = self.fields.pop("_pass") + + def get_id(self, obj): + """Generate unique ID from provider_type and region.""" + return f"{obj['provider_type']}:{obj['region']}" + def get_root_meta(self, _resource, _many): return {"version": "v1"} @@ -2194,7 +2518,7 @@ class OverviewServiceSerializer(serializers.Serializer): # Schedules -class ScheduleDailyCreateSerializer(serializers.Serializer): +class ScheduleDailyCreateSerializer(BaseSerializerV1): provider_id = serializers.UUIDField(required=True) class JSONAPIMeta: @@ -2530,7 +2854,7 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): return representation -class IntegrationJiraDispatchSerializer(serializers.Serializer): +class IntegrationJiraDispatchSerializer(BaseSerializerV1): """ Serializer for dispatching findings to JIRA integration. """ @@ -2693,14 +3017,14 @@ class ProcessorUpdateSerializer(BaseWriteSerializer): # SSO -class SamlInitiateSerializer(serializers.Serializer): +class SamlInitiateSerializer(BaseSerializerV1): email_domain = serializers.CharField() class JSONAPIMeta: resource_name = "saml-initiate" -class SamlMetadataSerializer(serializers.Serializer): +class SamlMetadataSerializer(BaseSerializerV1): class JSONAPIMeta: resource_name = "saml-meta" @@ -3075,7 +3399,12 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize Accepts credentials as JSON; stored encrypted via credentials_decoded. """ - credentials = serializers.JSONField(write_only=True, required=True) + credentials = LighthouseCredentialsField(write_only=True, required=True) + base_url = serializers.URLField( + required=False, + allow_null=True, + help_text="Base URL for the LLM provider API. Required for 'openai_compatible' provider type.", + ) class Meta: model = LighthouseProviderConfiguration @@ -3087,7 +3416,10 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize ] extra_kwargs = { "is_active": {"required": False}, - "base_url": {"required": False, "allow_null": True}, + "provider_type": { + "help_text": "LLM provider type. Determines which credential format to use. " + "See 'credentials' field documentation for provider-specific requirements." + }, } def create(self, validated_data): @@ -3110,6 +3442,7 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize def validate(self, attrs): provider_type = attrs.get("provider_type") credentials = attrs.get("credentials") or {} + base_url = attrs.get("base_url") if provider_type == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI: try: @@ -3122,6 +3455,35 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize e.detail[f"credentials/{key}"] = value del e.detail[key] raise e + elif ( + provider_type == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK + ): + try: + BedrockCredentialsSerializer(data=credentials).is_valid( + raise_exception=True + ) + except ValidationError as e: + details = e.detail.copy() + for key, value in details.items(): + e.detail[f"credentials/{key}"] = value + del e.detail[key] + raise e + elif ( + provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE + ): + if not base_url: + raise ValidationError({"base_url": "Base URL is required."}) + try: + OpenAICompatibleCredentialsSerializer(data=credentials).is_valid( + raise_exception=True + ) + except ValidationError as e: + details = e.detail.copy() + for key, value in details.items(): + e.detail[f"credentials/{key}"] = value + del e.detail[key] + raise e return super().validate(attrs) @@ -3131,7 +3493,12 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): Update serializer for LighthouseProviderConfiguration. """ - credentials = serializers.JSONField(write_only=True, required=False) + credentials = LighthouseCredentialsField(write_only=True, required=False) + base_url = serializers.URLField( + required=False, + allow_null=True, + help_text="Base URL for the LLM provider API. Required for 'openai_compatible' provider type.", + ) class Meta: model = LighthouseProviderConfiguration @@ -3145,7 +3512,6 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): extra_kwargs = { "id": {"read_only": True}, "provider_type": {"read_only": True}, - "base_url": {"required": False, "allow_null": True}, "is_active": {"required": False}, } @@ -3156,7 +3522,11 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): setattr(instance, attr, value) if credentials is not None: - instance.credentials_decoded = credentials + # Merge partial credentials with existing ones + # New values overwrite existing ones, but unspecified fields are preserved + existing_credentials = instance.credentials_decoded or {} + merged_credentials = {**existing_credentials, **credentials} + instance.credentials_decoded = merged_credentials instance.save() return instance @@ -3164,6 +3534,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): def validate(self, attrs): provider_type = getattr(self.instance, "provider_type", None) credentials = attrs.get("credentials", None) + base_url = attrs.get("base_url", None) if ( credentials is not None @@ -3180,6 +3551,78 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): e.detail[f"credentials/{key}"] = value del e.detail[key] raise e + elif ( + credentials is not None + and provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK + ): + # For updates, enforce that the authentication method (access keys vs API key) + # is immutable. To switch methods, the UI must delete and recreate the provider. + existing_credentials = ( + self.instance.credentials_decoded if self.instance else {} + ) or {} + + existing_uses_api_key = "api_key" in existing_credentials + existing_uses_access_keys = any( + k in existing_credentials + for k in ("access_key_id", "secret_access_key") + ) + + # First run field-level validation on the partial payload + try: + BedrockCredentialsUpdateSerializer(data=credentials).is_valid( + raise_exception=True + ) + except ValidationError as e: + details = e.detail.copy() + for key, value in details.items(): + e.detail[f"credentials/{key}"] = value + del e.detail[key] + raise e + + # Then enforce invariants about not changing the auth method + # If the existing config uses an API key, forbid introducing access keys. + if existing_uses_api_key and any( + k in credentials for k in ("access_key_id", "secret_access_key") + ): + raise ValidationError( + { + "credentials/non_field_errors": [ + "Cannot change Bedrock authentication method from API key " + "to access key via update. Delete and recreate the provider instead." + ] + } + ) + + # If the existing config uses access keys, forbid introducing an API key. + if existing_uses_access_keys and "api_key" in credentials: + raise ValidationError( + { + "credentials/non_field_errors": [ + "Cannot change Bedrock authentication method from access key " + "to API key via update. Delete and recreate the provider instead." + ] + } + ) + elif ( + credentials is not None + and provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE + ): + if base_url is None: + pass + elif not base_url: + raise ValidationError({"base_url": "Base URL cannot be empty."}) + try: + OpenAICompatibleCredentialsSerializer(data=credentials).is_valid( + raise_exception=True + ) + except ValidationError as e: + details = e.detail.copy() + for key, value in details.items(): + e.detail[f"credentials/{key}"] = value + del e.detail[key] + raise e return super().validate(attrs) @@ -3197,7 +3640,7 @@ class LighthouseTenantConfigSerializer(RLSSerializer): def get_url(self, obj): request = self.context.get("request") - return reverse("lighthouse-config", request=request) + return reverse("lighthouse-configurations", request=request) class Meta: model = LighthouseTenantConfiguration @@ -3345,3 +3788,265 @@ class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer): extra_kwargs = { "id": {"read_only": True}, } + + +# Mute Rules + + +class MuteRuleSerializer(RLSSerializer): + """ + Serializer for reading MuteRule instances. + """ + + finding_uids = serializers.ListField( + child=serializers.CharField(), + read_only=True, + help_text="List of finding UIDs that are muted by this rule", + ) + + class Meta: + model = MuteRule + fields = [ + "id", + "inserted_at", + "updated_at", + "name", + "reason", + "enabled", + "created_by", + "finding_uids", + ] + + included_serializers = { + "created_by": "api.v1.serializers.UserIncludeSerializer", + } + + +class MuteRuleCreateSerializer(RLSSerializer, BaseWriteSerializer): + """ + Serializer for creating new MuteRule instances. + + Accepts finding_ids in the request, converts them to UIDs, and stores in finding_uids. + """ + + finding_ids = serializers.ListField( + child=serializers.UUIDField(), + write_only=True, + required=True, + help_text="List of Finding IDs to mute (will be converted to UIDs)", + ) + finding_uids = serializers.ListField( + child=serializers.CharField(), + read_only=True, + help_text="List of finding UIDs that are muted by this rule", + ) + + class Meta: + model = MuteRule + fields = [ + "id", + "inserted_at", + "updated_at", + "name", + "reason", + "enabled", + "created_by", + "finding_ids", + "finding_uids", + ] + extra_kwargs = { + "id": {"read_only": True}, + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + "enabled": {"read_only": True}, + "created_by": {"read_only": True}, + } + + def validate_name(self, value): + """Validate that the name is unique within the tenant.""" + tenant_id = self.context.get("tenant_id") + if MuteRule.objects.filter(tenant_id=tenant_id, name=value).exists(): + raise ValidationError("A mute rule with this name already exists.") + return value + + def validate_finding_ids(self, value): + """Validate that all finding IDs exist and belong to the tenant.""" + if not value: + raise ValidationError("At least one finding_id must be provided.") + + tenant_id = self.context.get("tenant_id") + + # Check that all findings exist and belong to this tenant + findings = Finding.all_objects.filter(tenant_id=tenant_id, id__in=value) + found_ids = set(findings.values_list("id", flat=True)) + provided_ids = set(value) + + missing_ids = provided_ids - found_ids + if missing_ids: + raise ValidationError( + f"The following finding IDs do not exist or do not belong to your tenant: {missing_ids}" + ) + + return value + + def validate(self, data): + """Validate the entire mute rule, including overlap detection.""" + data = super().validate(data) + + tenant_id = self.context.get("tenant_id") + finding_ids = data.get("finding_ids", []) + + if not finding_ids: + return data + + # Convert finding IDs to UIDs (deduplicate in case multiple findings have same UID) + findings = Finding.all_objects.filter(id__in=finding_ids, tenant_id=tenant_id) + finding_uids = list(set(findings.values_list("uid", flat=True))) + + # Check for overlaps with existing enabled rules + existing_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True) + + for rule in existing_rules: + overlap = set(finding_uids) & set(rule.finding_uids) + if overlap: + raise ConflictException( + detail=f"The following finding UIDs are already muted by rule '{rule.name}': {overlap}" + ) + + # Store finding_uids in validated_data for create + data["finding_uids"] = finding_uids + + return data + + def create(self, validated_data): + """Create a new mute rule and set created_by.""" + # Remove finding_ids from validated_data (we've already converted to finding_uids) + validated_data.pop("finding_ids", None) + + # Set created_by to the current user + request = self.context.get("request") + if request and hasattr(request, "user"): + validated_data["created_by"] = request.user + + return super().create(validated_data) + + +class MuteRuleUpdateSerializer(BaseWriteSerializer): + """ + Serializer for updating MuteRule instances. + """ + + class Meta: + model = MuteRule + fields = [ + "id", + "name", + "reason", + "enabled", + ] + extra_kwargs = { + "id": {"read_only": True}, + "name": {"required": False}, + "reason": {"required": False}, + "enabled": {"required": False}, + } + + def validate_name(self, value): + """Validate that the name is unique within the tenant, excluding current instance.""" + tenant_id = self.context.get("tenant_id") + if ( + MuteRule.objects.filter(tenant_id=tenant_id, name=value) + .exclude(id=self.instance.id) + .exists() + ): + raise ValidationError("A mute rule with this name already exists.") + return value + + +# ThreatScore Snapshots + + +class ThreatScoreSnapshotSerializer(RLSSerializer): + """ + Serializer for ThreatScore snapshots. + Read-only serializer for retrieving historical ThreatScore metrics. + """ + + id = serializers.SerializerMethodField() + + class Meta: + model = ThreatScoreSnapshot + fields = [ + "id", + "inserted_at", + "scan", + "provider", + "compliance_id", + "overall_score", + "score_delta", + "section_scores", + "critical_requirements", + "total_requirements", + "passed_requirements", + "failed_requirements", + "manual_requirements", + "total_findings", + "passed_findings", + "failed_findings", + ] + extra_kwargs = { + "id": {"read_only": True}, + "inserted_at": {"read_only": True}, + "scan": {"read_only": True}, + "provider": {"read_only": True}, + "compliance_id": {"read_only": True}, + "overall_score": {"read_only": True}, + "score_delta": {"read_only": True}, + "section_scores": {"read_only": True}, + "critical_requirements": {"read_only": True}, + "total_requirements": {"read_only": True}, + "passed_requirements": {"read_only": True}, + "failed_requirements": {"read_only": True}, + "manual_requirements": {"read_only": True}, + "total_findings": {"read_only": True}, + "passed_findings": {"read_only": True}, + "failed_findings": {"read_only": True}, + } + + included_serializers = { + "scan": "api.v1.serializers.ScanIncludeSerializer", + "provider": "api.v1.serializers.ProviderIncludeSerializer", + } + + def get_id(self, obj): + if getattr(obj, "_aggregated", False): + return "n/a" + return str(obj.id) + + +# Resource Events Serializers + + +class ResourceEventSerializer(BaseSerializerV1): + """Serializer for resource events (CloudTrail modification history). + + NOTE: drf-spectacular auto-generates fields[resource-events] sparse fieldsets + parameter in the OpenAPI schema. This endpoint does not support sparse fieldsets. + """ + + id = serializers.CharField(source="event_id") + event_time = serializers.DateTimeField() + event_name = serializers.CharField() + event_source = serializers.CharField() + actor = serializers.CharField() + actor_uid = serializers.CharField(allow_null=True, required=False) + actor_type = serializers.CharField(allow_null=True, required=False) + source_ip_address = serializers.CharField(allow_null=True, required=False) + user_agent = serializers.CharField(allow_null=True, required=False) + request_data = serializers.JSONField(allow_null=True, required=False) + response_data = serializers.JSONField(allow_null=True, required=False) + error_code = serializers.CharField(allow_null=True, required=False) + error_message = serializers.CharField(allow_null=True, required=False) + + class Meta: + resource_name = "resource-events" diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index 87f55556be..840f027b42 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -4,6 +4,7 @@ from drf_spectacular.views import SpectacularRedocView from rest_framework_nested import routers from api.v1.views import ( + AttackPathsScanViewSet, ComplianceOverviewViewSet, CustomSAMLLoginView, CustomTokenObtainView, @@ -21,6 +22,7 @@ from api.v1.views import ( LighthouseProviderModelsViewSet, LighthouseTenantConfigViewSet, MembershipViewSet, + MuteRuleViewSet, OverviewViewSet, ProcessorViewSet, ProviderGroupProvidersRelationshipView, @@ -52,6 +54,9 @@ router.register(r"tenants", TenantViewSet, basename="tenant") router.register(r"providers", ProviderViewSet, basename="provider") router.register(r"provider-groups", ProviderGroupViewSet, basename="providergroup") router.register(r"scans", ScanViewSet, basename="scan") +router.register( + r"attack-paths-scans", AttackPathsScanViewSet, basename="attack-paths-scans" +) router.register(r"tasks", TaskViewSet, basename="task") router.register(r"resources", ResourceViewSet, basename="resource") router.register(r"findings", FindingViewSet, basename="finding") @@ -80,6 +85,7 @@ router.register( LighthouseProviderModelsViewSet, basename="lighthouse-models", ) +router.register(r"mute-rules", MuteRuleViewSet, basename="mute-rule") tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant") tenants_router.register( @@ -150,13 +156,12 @@ urlpatterns = [ ), name="provider_group-providers-relationship", ), - # Lighthouse tenant config as singleton endpoint path( "lighthouse/configuration", LighthouseTenantConfigViewSet.as_view( {"get": "list", "patch": "partial_update"} ), - name="lighthouse-config", + name="lighthouse-configurations", ), # API endpoint to start SAML SSO flow path( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 5762b6e320..0c61622d06 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -3,7 +3,10 @@ import glob import json import logging import os +from collections import defaultdict +from copy import deepcopy from datetime import datetime, timedelta, timezone +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from urllib.parse import urljoin import sentry_sdk @@ -24,9 +27,24 @@ from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.search import SearchQuery from django.db import transaction -from django.db.models import Count, F, Prefetch, Q, Subquery, Sum -from django.db.models.functions import Coalesce -from django.http import HttpResponse +from django.db.models import ( + Case, + Count, + DecimalField, + ExpressionWrapper, + F, + IntegerField, + Max, + Prefetch, + Q, + Subquery, + Sum, + Value, + When, + Window, +) +from django.db.models.functions import Coalesce, RowNumber +from django.http import HttpResponse, QueryDict from django.shortcuts import redirect from django.urls import reverse from django.utils.dateparse import parse_date @@ -56,8 +74,10 @@ from rest_framework.permissions import SAFE_METHODS from rest_framework_json_api.views import RelationshipView, Response from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from tasks.beat import schedule_provider_scan +from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.export import get_s3_client from tasks.tasks import ( + backfill_compliance_summaries_task, backfill_scan_resource_summaries_task, check_integration_connection_task, check_lighthouse_connection_task, @@ -66,10 +86,14 @@ from tasks.tasks import ( delete_provider_task, delete_tenant_task, jira_integration_task, + mute_historical_findings_task, perform_scan_task, refresh_lighthouse_provider_models_task, ) +from api.attack_paths import database as graph_database +from api.attack_paths import get_queries_for_provider, get_query_by_id +from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, @@ -77,10 +101,21 @@ from api.compliance import ( ) from api.db_router import MainRouter from api.db_utils import rls_transaction -from api.exceptions import TaskFailedException +from api.exceptions import ( + TaskFailedException, + UpstreamAccessDeniedError, + UpstreamAuthenticationError, + UpstreamInternalError, + UpstreamServiceUnavailableError, +) from api.filters import ( + AttackPathsScanFilter, + AttackSurfaceOverviewFilter, + CategoryOverviewFilter, ComplianceOverviewFilter, + ComplianceWatchlistFilter, CustomDjangoFilterBackend, + DailySeveritySummaryFilter, FindingFilter, IntegrationFilter, IntegrationJiraFindingsFilter, @@ -90,23 +125,29 @@ from api.filters import ( LighthouseProviderConfigFilter, LighthouseProviderModelsFilter, MembershipFilter, + MuteRuleFilter, ProcessorFilter, ProviderFilter, ProviderGroupFilter, ProviderSecretFilter, ResourceFilter, + ResourceGroupOverviewFilter, RoleFilter, ScanFilter, ScanSummaryFilter, ScanSummarySeverityFilter, - ServiceOverviewFilter, TaskFilter, TenantApiKeyFilter, TenantFilter, + ThreatScoreSnapshotFilter, UserFilter, ) from api.models import ( + AttackPathsScan, + AttackSurfaceOverview, + ComplianceOverviewSummary, ComplianceRequirementOverview, + DailySeveritySummary, Finding, Integration, Invitation, @@ -115,8 +156,10 @@ from api.models import ( LighthouseProviderModels, LighthouseTenantConfiguration, Membership, + MuteRule, Processor, Provider, + ProviderComplianceScore, ProviderGroup, ProviderGroupMembership, ProviderSecret, @@ -130,11 +173,15 @@ from api.models import ( SAMLDomainIndex, SAMLToken, Scan, + ScanCategorySummary, + ScanGroupSummary, ScanSummary, SeverityChoices, StateChoices, Task, TenantAPIKey, + TenantComplianceSummary, + ThreatScoreSnapshot, User, UserRoleRelationship, ) @@ -144,19 +191,28 @@ from api.rls import Tenant from api.utils import ( CustomOAuth2Client, get_findings_metadata_no_aggregations, + initialize_prowler_provider, validate_invitation, ) from api.uuid_utils import datetime_to_uuid7, uuid7_start from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin from api.v1.serializers import ( + AttackPathsQueryResultSerializer, + AttackPathsQueryRunRequestSerializer, + AttackPathsQuerySerializer, + AttackPathsScanSerializer, + AttackSurfaceOverviewSerializer, + CategoryOverviewSerializer, ComplianceOverviewAttributesSerializer, ComplianceOverviewDetailSerializer, ComplianceOverviewDetailThreatscoreSerializer, ComplianceOverviewMetadataSerializer, ComplianceOverviewSerializer, + ComplianceWatchlistOverviewSerializer, FindingDynamicFilterSerializer, FindingMetadataSerializer, FindingSerializer, + FindingsSeverityOverTimeSerializer, IntegrationCreateSerializer, IntegrationJiraDispatchSerializer, IntegrationSerializer, @@ -175,9 +231,13 @@ from api.v1.serializers import ( LighthouseTenantConfigSerializer, LighthouseTenantConfigUpdateSerializer, MembershipSerializer, + MuteRuleCreateSerializer, + MuteRuleSerializer, + MuteRuleUpdateSerializer, OverviewFindingSerializer, OverviewProviderCountSerializer, OverviewProviderSerializer, + OverviewRegionSerializer, OverviewServiceSerializer, OverviewSeveritySerializer, ProcessorCreateSerializer, @@ -193,6 +253,8 @@ from api.v1.serializers import ( ProviderSecretUpdateSerializer, ProviderSerializer, ProviderUpdateSerializer, + ResourceEventSerializer, + ResourceGroupOverviewSerializer, ResourceMetadataSerializer, ResourceSerializer, RoleCreateSerializer, @@ -212,6 +274,7 @@ from api.v1.serializers import ( TenantApiKeySerializer, TenantApiKeyUpdateSerializer, TenantSerializer, + ThreatScoreSnapshotSerializer, TokenRefreshSerializer, TokenSerializer, TokenSocialLoginSerializer, @@ -221,6 +284,13 @@ from api.v1.serializers import ( UserSerializer, UserUpdateSerializer, ) +from prowler.providers.aws.exceptions.exceptions import ( + AWSAssumeRoleError, + AWSCredentialsError, +) +from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import ( + CloudTrailTimeline, +) logger = logging.getLogger(BackendLogger.API) @@ -322,7 +392,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.15.0" + spectacular_settings.VERSION = "1.20.0" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -364,6 +434,10 @@ class SchemaView(SpectacularAPIView): "name": "Scan", "description": "Endpoints for triggering manual scans and viewing scan results.", }, + { + "name": "Attack Paths", + "description": "Endpoints for Attack Paths scan status and executing Attack Paths queries.", + }, { "name": "Schedule", "description": "Endpoints for managing scan schedules, allowing configuration of automated " @@ -414,6 +488,12 @@ class SchemaView(SpectacularAPIView): "description": "Endpoints for API keys management. These can be used as an alternative to JWT " "authorization.", }, + { + "name": "Mute Rules", + "description": "Endpoints for simple mute rules management. These can be used as an alternative to the" + " Mutelist Processor if you need to mute specific findings across your tenant with a " + "specific reason.", + }, ] return super().get(request, *args, **kwargs) @@ -683,27 +763,40 @@ class TenantFinishACSView(FinishACSView): .tenant ) - # Check if tenant has only one user with MANAGE_ACCOUNT role - users_with_manage_account = ( + role_name = ( + extra.get("userType", ["no_permissions"])[0].strip() + if extra.get("userType") + else "no_permissions" + ) + role = ( + Role.objects.using(MainRouter.admin_db) + .filter(name=role_name, tenant=tenant) + .first() + ) + + # Only skip mapping if it would remove the last MANAGE_ACCOUNT user + remaining_manage_account_users = ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(role__manage_account=True, tenant_id=tenant.id) + .exclude(user_id=user_id) .values("user") .distinct() .count() ) + user_has_manage_account = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id) + .exists() + ) + role_manage_account = role.manage_account if role else False + would_remove_last_manage_account = ( + user_has_manage_account + and remaining_manage_account_users == 0 + and not role_manage_account + ) - # Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT - if users_with_manage_account != 1: - role_name = ( - extra.get("userType", ["no_permissions"])[0].strip() - if extra.get("userType") - else "no_permissions" - ) - try: - role = Role.objects.using(MainRouter.admin_db).get( - name=role_name, tenant=tenant - ) - except Role.DoesNotExist: + if not would_remove_last_manage_account: + if role is None: role = Role.objects.using(MainRouter.admin_db).create( name=role_name, tenant=tenant, @@ -1628,6 +1721,44 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): ), }, ), + ens=extend_schema( + tags=["Scan"], + summary="Retrieve ENS RD2022 compliance report", + description="Download ENS RD2022 compliance report (e.g., 'ens_rd2022_aws') as a PDF file.", + request=None, + responses={ + 200: OpenApiResponse( + description="PDF file containing the ENS compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 401: OpenApiResponse( + description="API key missing or user not Authenticated" + ), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="The scan has no ENS reports, or the ENS report generation task has not started yet" + ), + }, + ), + nis2=extend_schema( + tags=["Scan"], + summary="Retrieve NIS2 compliance report", + description="Download NIS2 compliance report (Directive (EU) 2022/2555) as a PDF file.", + request=None, + responses={ + 200: OpenApiResponse( + description="PDF file containing the NIS2 compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 401: OpenApiResponse( + description="API key missing or user not Authenticated" + ), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="The scan has no NIS2 reports, or the NIS2 report generation task has not started yet" + ), + }, + ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") @@ -1687,6 +1818,12 @@ class ScanViewSet(BaseRLSViewSet): elif self.action == "threatscore": if hasattr(self, "response_serializer_class"): return self.response_serializer_class + elif self.action == "ens": + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class + elif self.action == "nis2": + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -1940,6 +2077,7 @@ class ScanViewSet(BaseRLSViewSet): if running_resp: return running_resp + # TODO: add detailed response if the compliance framework is not supported for the provider if not scan.output_location: return Response( { @@ -1968,6 +2106,85 @@ class ScanViewSet(BaseRLSViewSet): content, filename = loader return self._serve_file(content, filename, "application/pdf") + @action( + detail=True, + methods=["get"], + url_name="ens", + ) + def ens(self, request, pk=None): + scan = self.get_object() + running_resp = self._get_task_status(scan) + if running_resp: + return running_resp + + # TODO: add detailed response if the compliance framework is not supported for the provider + if not scan.output_location: + return Response( + { + "detail": "The scan has no reports, or the ENS report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) + + if scan.output_location.startswith("s3://"): + bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") + key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") + prefix = os.path.join( + os.path.dirname(key_prefix), + "ens", + "*_ens_report.pdf", + ) + loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + else: + base = os.path.dirname(scan.output_location) + pattern = os.path.join(base, "ens", "*_ens_report.pdf") + loader = self._load_file(pattern, s3=False) + + if isinstance(loader, Response): + return loader + + content, filename = loader + return self._serve_file(content, filename, "application/pdf") + + @action( + detail=True, + methods=["get"], + url_name="nis2", + ) + def nis2(self, request, pk=None): + scan = self.get_object() + running_resp = self._get_task_status(scan) + if running_resp: + return running_resp + + if not scan.output_location: + return Response( + { + "detail": "The scan has no reports, or the NIS2 report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) + + if scan.output_location.startswith("s3://"): + bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") + key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") + prefix = os.path.join( + os.path.dirname(key_prefix), + "nis2", + "*_nis2_report.pdf", + ) + loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + else: + base = os.path.dirname(scan.output_location) + pattern = os.path.join(base, "nis2", "*_nis2_report.pdf") + loader = self._load_file(pattern, s3=False) + + if isinstance(loader, Response): + return loader + + content, filename = loader + return self._serve_file(content, filename, "application/pdf") + def create(self, request, *args, **kwargs): input_serializer = self.get_serializer(data=request.data) input_serializer.is_valid(raise_exception=True) @@ -1980,10 +2197,16 @@ class ScanViewSet(BaseRLSViewSet): "scan_id": str(scan.id), "provider_id": str(scan.provider_id), # Disabled for now - # checks_to_execute=scan.scanner_args.get("checks_to_execute"), + # checks_to_execute=scan.scanner_args.get("checks_to_execute") }, ) + attack_paths_db_utils.create_attack_paths_scan( + tenant_id=self.request.tenant_id, + scan_id=str(scan.id), + provider_id=str(scan.provider_id), + ) + prowler_task = Task.objects.get(id=task.id) scan.task_id = task.id scan.save(update_fields=["task_id"]) @@ -2064,6 +2287,188 @@ class TaskViewSet(BaseRLSViewSet): ) +@extend_schema_view( + list=extend_schema( + tags=["Attack Paths"], + summary="List Attack Paths scans", + description="Retrieve Attack Paths scans for the tenant with support for filtering, ordering, and pagination.", + ), + retrieve=extend_schema( + tags=["Attack Paths"], + summary="Retrieve Attack Paths scan details", + description="Fetch full details for a specific Attack Paths scan.", + ), + attack_paths_queries=extend_schema( + tags=["Attack Paths"], + summary="List Attack Paths queries", + description="Retrieve the catalog of Attack Paths queries available for this Attack Paths scan.", + responses={ + 200: OpenApiResponse(AttackPathsQuerySerializer(many=True)), + 404: OpenApiResponse( + description="No queries found for the selected provider" + ), + }, + ), + run_attack_paths_query=extend_schema( + tags=["Attack Paths"], + summary="Execute an Attack Paths query", + description="Execute the selected Attack Paths query against the Attack Paths graph and return the resulting subgraph.", + request=AttackPathsQueryRunRequestSerializer, + responses={ + 200: OpenApiResponse(AttackPathsQueryResultSerializer), + 400: OpenApiResponse( + description="Bad request (e.g., Unknown Attack Paths query for the selected provider)" + ), + 404: OpenApiResponse( + description="No Attack Paths found for the given query and parameters" + ), + 500: OpenApiResponse( + description="Attack Paths query execution failed due to a database error" + ), + }, + ), +) +class AttackPathsScanViewSet(BaseRLSViewSet): + queryset = AttackPathsScan.objects.all() + serializer_class = AttackPathsScanSerializer + http_method_names = ["get", "post"] + filterset_class = AttackPathsScanFilter + ordering = ["-inserted_at"] + ordering_fields = [ + "inserted_at", + "started_at", + ] + # RBAC required permissions + required_permissions = [Permissions.MANAGE_SCANS] + + def set_required_permissions(self): + if self.request.method in SAFE_METHODS: + self.required_permissions = [] + + else: + self.required_permissions = [Permissions.MANAGE_SCANS] + + def get_serializer_class(self): + if self.action == "run_attack_paths_query": + return AttackPathsQueryRunRequestSerializer + + return super().get_serializer_class() + + def get_queryset(self): + user_roles = get_role(self.request.user) + base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id) + + if user_roles.unlimited_visibility: + queryset = base_queryset + + else: + queryset = base_queryset.filter(provider__in=get_providers(user_roles)) + + return queryset.select_related("provider", "scan", "task") + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + latest_per_provider = queryset.annotate( + latest_scan_rank=Window( + expression=RowNumber(), + partition_by=[F("provider_id")], + order_by=[F("inserted_at").desc()], + ) + ).filter(latest_scan_rank=1) + + page = self.paginate_queryset(latest_per_provider) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(latest_per_provider, many=True) + return Response(serializer.data) + + @extend_schema(exclude=True) + def create(self, request, *args, **kwargs): + raise MethodNotAllowed(method="POST") + + @extend_schema(exclude=True) + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(method="DELETE") + + @action( + detail=True, + methods=["get"], + url_path="queries", + url_name="queries", + ) + def attack_paths_queries(self, request, pk=None): + attack_paths_scan = self.get_object() + queries = get_queries_for_provider(attack_paths_scan.provider.provider) + + if not queries: + return Response( + {"detail": "No queries found for the selected provider"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = AttackPathsQuerySerializer(queries, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action( + detail=True, + methods=["post"], + url_path="queries/run", + url_name="queries-run", + ) + def run_attack_paths_query(self, request, pk=None): + attack_paths_scan = self.get_object() + + if attack_paths_scan.state != StateChoices.COMPLETED: + raise ValidationError( + { + "detail": "The Attack Paths scan must be completed before running Attack Paths queries" + } + ) + + if not attack_paths_scan.graph_database: + logger.error( + f"The Attack Paths Scan {attack_paths_scan.id} does not reference a graph database" + ) + return Response( + {"detail": "The Attack Paths scan does not reference a graph database"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + payload = attack_paths_views_helpers.normalize_run_payload(request.data) + serializer = AttackPathsQueryRunRequestSerializer(data=payload) + serializer.is_valid(raise_exception=True) + + query_definition = get_query_by_id(serializer.validated_data["id"]) + if ( + query_definition is None + or query_definition.provider != attack_paths_scan.provider.provider + ): + raise ValidationError( + {"id": "Unknown Attack Paths query for the selected provider"} + ) + + parameters = attack_paths_views_helpers.prepare_query_parameters( + query_definition, + serializer.validated_data.get("parameters", {}), + attack_paths_scan.provider.uid, + ) + + graph = attack_paths_views_helpers.execute_attack_paths_query( + attack_paths_scan, query_definition, parameters + ) + graph_database.clear_cache(attack_paths_scan.graph_database) + + status_code = status.HTTP_200_OK + if not graph.get("nodes"): + status_code = status.HTTP_404_NOT_FOUND + + response_serializer = AttackPathsQueryResultSerializer(graph) + return Response(response_serializer.data, status=status_code) + + @extend_schema_view( list=extend_schema( tags=["Resource"], @@ -2122,6 +2527,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): http_method_names = ["get"] filterset_class = ResourceFilter ordering = ["-failed_findings_count", "-updated_at"] + + # Events endpoint constants (currently AWS-only, limited to 90 days by CloudTrail Event History) + EVENTS_DEFAULT_LOOKBACK_DAYS = 90 + EVENTS_MIN_LOOKBACK_DAYS = 1 + EVENTS_MAX_LOOKBACK_DAYS = 90 + # Page size controls how many events CloudTrail returns (prepares for API pagination) + EVENTS_DEFAULT_PAGE_SIZE = 50 + EVENTS_MIN_PAGE_SIZE = 1 + EVENTS_MAX_PAGE_SIZE = 50 # CloudTrail lookup_events max is 50 + # Allowed query parameters for the events endpoint + EVENTS_ALLOWED_PARAMS = frozenset( + {"lookback_days", "page[size]", "include_read_events"} + ) + ordering_fields = [ "provider_uid", "uid", @@ -2197,6 +2616,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): def get_serializer_class(self): if self.action in ["metadata", "metadata_latest"]: return ResourceMetadataSerializer + if self.action == "events": + return ResourceEventSerializer return super().get_serializer_class() def get_filterset_class(self): @@ -2205,8 +2626,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): return ResourceFilter def filter_queryset(self, queryset): - # Do not apply filters when retrieving specific resource - if self.action == "retrieve": + # Do not apply filters when retrieving specific resource or events + if self.action in ["retrieve", "events"]: return queryset return super().filter_queryset(queryset) @@ -2356,10 +2777,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): .order_by("resource_type") ) + # Get groups from Resource model (flatten ArrayField) + all_groups = Resource.objects.filter( + tenant_id=tenant_id, + groups__isnull=False, + ).values_list("groups", flat=True) + groups = sorted( + set(g for groups_list in all_groups if groups_list for g in groups_list) + ) + result = { "services": services, "regions": regions, "types": resource_types, + "groups": groups, } serializer = self.get_serializer(data=result) @@ -2416,16 +2847,243 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): .order_by("resource_type") ) + # Get groups from Resource model for resources in latest scans (flatten ArrayField) + all_groups = Resource.objects.filter( + tenant_id=tenant_id, + groups__isnull=False, + ).values_list("groups", flat=True) + groups = sorted( + set(g for groups_list in all_groups if groups_list for g in groups_list) + ) + result = { "services": services, "regions": regions, "types": resource_types, + "groups": groups, } serializer = self.get_serializer(data=result) serializer.is_valid(raise_exception=True) return Response(serializer.data) + @extend_schema( + tags=["Resource"], + summary="Get events for a resource", + description=( + "Retrieve events showing modification history for a resource. " + "Returns who modified the resource and when. Currently only available for AWS resources.\n\n" + "**Note:** Some events may not appear due to CloudTrail indexing limitations. " + "Not all AWS API calls record the resource identifier in a searchable format." + ), + parameters=[ + OpenApiParameter( + name="lookback_days", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of days to look back (default: 90, min: 1, max: 90).", + required=False, + ), + OpenApiParameter( + name="page[size]", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Maximum number of events to return (default: 50, min: 1, max: 50).", + required=False, + ), + OpenApiParameter( + name="include_read_events", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description=( + "Include read-only events (Describe*, Get*, List*, etc.). " + "Default: false. Set to true to include all events." + ), + required=False, + ), + # NOTE: drf-spectacular auto-generates page[number] and fields[resource-events] + # parameters. This endpoint does not support pagination (results are limited by + # page[size] only) nor sparse fieldsets. + ], + responses={ + 200: ResourceEventSerializer(many=True), + 400: OpenApiResponse(description="Invalid provider or parameters"), + 500: OpenApiResponse(description="Unexpected error retrieving events"), + 502: OpenApiResponse( + description="Provider credentials invalid, expired, or lack required permissions" + ), + 503: OpenApiResponse(description="Provider service unavailable"), + }, + ) + @action( + detail=True, + methods=["get"], + url_name="events", + filter_backends=[], # Disable filters - we're calling external API, not filtering queryset + ) + def events(self, request, pk=None): + """Get events for a resource.""" + resource = self.get_object() + + # Validate query parameters - reject unknown parameters + for param in request.query_params.keys(): + if param not in self.EVENTS_ALLOWED_PARAMS: + raise ValidationError( + [ + { + "detail": f"invalid parameter '{param}'", + "status": "400", + "source": {"parameter": param}, + "code": "invalid", + } + ] + ) + + # Validate provider - currently only AWS CloudTrail is supported + if resource.provider.provider != Provider.ProviderChoices.AWS: + raise ValidationError( + [ + { + "detail": "Events are only available for AWS resources", + "status": "400", + "source": {"pointer": "/data/attributes/provider"}, + "code": "invalid_provider", + } + ] + ) + + # Validate and parse lookback_days from query params + lookback_days_str = request.query_params.get("lookback_days") + if lookback_days_str is None: + lookback_days = self.EVENTS_DEFAULT_LOOKBACK_DAYS + else: + try: + lookback_days = int(lookback_days_str) + except (ValueError, TypeError): + raise ValidationError( + [ + { + "detail": "lookback_days must be a valid integer", + "status": "400", + "source": {"parameter": "lookback_days"}, + "code": "invalid", + } + ] + ) + + if not ( + self.EVENTS_MIN_LOOKBACK_DAYS + <= lookback_days + <= self.EVENTS_MAX_LOOKBACK_DAYS + ): + raise ValidationError( + [ + { + "detail": ( + f"lookback_days must be between {self.EVENTS_MIN_LOOKBACK_DAYS} " + f"and {self.EVENTS_MAX_LOOKBACK_DAYS}" + ), + "status": "400", + "source": {"parameter": "lookback_days"}, + "code": "out_of_range", + } + ] + ) + + # Validate and parse page[size] from query params (JSON:API pagination) + page_size_str = request.query_params.get("page[size]") + if page_size_str is None: + page_size = self.EVENTS_DEFAULT_PAGE_SIZE + else: + try: + page_size = int(page_size_str) + except (ValueError, TypeError): + raise ValidationError( + [ + { + "detail": "page[size] must be a valid integer", + "status": "400", + "source": {"parameter": "page[size]"}, + "code": "invalid", + } + ] + ) + + if not ( + self.EVENTS_MIN_PAGE_SIZE <= page_size <= self.EVENTS_MAX_PAGE_SIZE + ): + raise ValidationError( + [ + { + "detail": ( + f"page[size] must be between {self.EVENTS_MIN_PAGE_SIZE} " + f"and {self.EVENTS_MAX_PAGE_SIZE}" + ), + "status": "400", + "source": {"parameter": "page[size]"}, + "code": "out_of_range", + } + ] + ) + + # Parse include_read_events (default: false) + include_read_events = ( + request.query_params.get("include_read_events", "").lower() == "true" + ) + + try: + # Initialize Prowler provider using existing utility + prowler_provider = initialize_prowler_provider(resource.provider) + + # Get the boto3 session from the Prowler provider + session = prowler_provider._session.current_session + + # Create timeline service (currently only AWS/CloudTrail is supported) + timeline_service = CloudTrailTimeline( + session=session, + lookback_days=lookback_days, + max_results=page_size, + write_events_only=not include_read_events, + ) + + # Get timeline events + events = timeline_service.get_resource_timeline( + region=resource.region, + resource_uid=resource.uid, + ) + + serializer = ResourceEventSerializer(events, many=True) + return Response(serializer.data) + + except NoCredentialsError: + # 502 because this is an upstream auth failure, not API auth failure + raise UpstreamAuthenticationError( + detail="Credentials not found for this provider. Please reconnect the provider." + ) + except AWSAssumeRoleError: + # AssumeRole failed - usually IAM permission issue (not authorized to sts:AssumeRole) + raise UpstreamAccessDeniedError( + detail="Cannot assume role for this provider. Check IAM Role permissions and trust relationship." + ) + except AWSCredentialsError: + # Handles expired tokens, invalid keys, profile not found, etc. + raise UpstreamAuthenticationError() + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + # AccessDenied is expected when credentials lack permissions - don't log as error + if error_code in ("AccessDenied", "AccessDeniedException"): + raise UpstreamAccessDeniedError() + + # Unexpected ClientErrors should be logged for debugging + logger.error( + f"Provider API error retrieving events: {str(e)}", + exc_info=True, + ) + raise UpstreamServiceUnavailableError() + except Exception as e: + sentry_sdk.capture_exception(e) + raise UpstreamInternalError(detail="Failed to retrieve events") + @extend_schema_view( list=extend_schema( @@ -2595,12 +3253,15 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id) scan_based_filters = {} + category_scan_filters = {} # Filters for ScanCategorySummary if scans := query_params.get("filter[scan__in]") or query_params.get( "filter[scan]" ): - queryset = queryset.filter(scan_id__in=scans.split(",")) - scan_based_filters = {"id__in": scans.split(",")} + scan_ids_list = scans.split(",") + queryset = queryset.filter(scan_id__in=scan_ids_list) + scan_based_filters = {"id__in": scan_ids_list} + category_scan_filters = {"scan_id__in": scan_ids_list} else: exact = query_params.get("filter[inserted_at]") gte = query_params.get("filter[inserted_at__gte]") @@ -2644,6 +3305,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): scan_based_filters = { key.lstrip("scan_"): value for key, value in date_filters.items() } + category_scan_filters = date_filters # ToRemove: Temporary fallback mechanism if not queryset.exists(): @@ -2690,10 +3352,31 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): .order_by("resource_type") ) + # Get categories from ScanCategorySummary using same scan filters + categories = list( + ScanCategorySummary.objects.filter( + tenant_id=tenant_id, **category_scan_filters + ) + .values_list("category", flat=True) + .distinct() + .order_by("category") + ) + + # Fallback to finding aggregation if no ScanCategorySummary exists + if not categories: + categories_set = set() + for categories_list in filtered_queryset.values_list( + "categories", flat=True + ): + if categories_list: + categories_set.update(categories_list) + categories = sorted(categories_set) + result = { "services": services, "regions": regions, "resource_types": resource_types, + "categories": categories, } serializer = self.get_serializer(data=result) @@ -2798,10 +3481,48 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): .order_by("resource_type") ) + # Get categories from ScanCategorySummary for latest scans + categories = list( + ScanCategorySummary.objects.filter( + tenant_id=tenant_id, + scan_id__in=latest_scans_queryset.values_list("id", flat=True), + ) + .values_list("category", flat=True) + .distinct() + .order_by("category") + ) + + # Fallback to finding aggregation if no ScanCategorySummary exists + if not categories: + filtered_queryset = self.filter_queryset(self.get_queryset()).filter( + tenant_id=tenant_id, + scan_id__in=latest_scans_queryset.values_list("id", flat=True), + ) + categories_set = set() + for categories_list in filtered_queryset.values_list( + "categories", flat=True + ): + if categories_list: + categories_set.update(categories_list) + categories = sorted(categories_set) + + # Get groups from ScanGroupSummary for latest scans + groups = list( + ScanGroupSummary.objects.filter( + tenant_id=tenant_id, + scan_id__in=latest_scans_queryset.values_list("id", flat=True), + ) + .values_list("resource_group", flat=True) + .distinct() + .order_by("resource_group") + ) + result = { "services": services, "regions": regions, "resource_types": resource_types, + "categories": categories, + "groups": groups, } serializer = self.get_serializer(data=result) @@ -3366,33 +4087,57 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): def retrieve(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET") - def list(self, request, *args, **kwargs): - scan_id = request.query_params.get("filter[scan_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] - ) - try: - if task := self.get_task_response_if_running( - task_name="scan-compliance-overviews", - task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, - raise_on_not_found=False, - ): - return task - except TaskFailedException: - return Response( - {"detail": "Task failed to generate compliance overview data."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - queryset = self.filter_queryset(self.filter_queryset(self.get_queryset())) + def _compliance_summaries_queryset(self, scan_id): + """Return pre-aggregated summaries constrained by RBAC visibility.""" + role = get_role(self.request.user) + unlimited_visibility = getattr( + role, Permissions.UNLIMITED_VISIBILITY.value, False + ) + summaries = ComplianceOverviewSummary.objects.filter( + tenant_id=self.request.tenant_id, + scan_id=scan_id, + ) + if not unlimited_visibility: + providers = Provider.all_objects.filter( + provider_groups__in=role.provider_groups.all() + ).distinct() + summaries = summaries.filter(scan__provider__in=providers) + + return summaries + + def _get_compliance_template(self, *, provider=None, scan_id=None): + """Return the compliance template for the given provider or scan.""" + if provider is None and scan_id is not None: + try: + scan = Scan.all_objects.select_related("provider").get(pk=scan_id) + except Scan.DoesNotExist: + raise ValidationError( + [ + { + "detail": "Scan not found", + "status": 404, + "source": {"pointer": "filter[scan_id]"}, + "code": "not_found", + } + ] + ) + provider = scan.provider + + if not provider: + return {} + + return PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(provider.provider, {}) + + def _aggregate_compliance_overview(self, queryset, template_metadata=None): + """ + Aggregate requirement rows into compliance overview dictionaries. + + Args: + queryset: ComplianceRequirementOverview queryset already filtered. + template_metadata: Optional dict mapping compliance_id -> metadata. + """ + template_metadata = template_metadata or {} requirement_status_subquery = queryset.values( "compliance_id", "requirement_id" ).annotate( @@ -3402,13 +4147,15 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ) compliance_data = {} - framework_info = {} - - for item in queryset.values("compliance_id", "framework", "version").distinct(): - framework_info[item["compliance_id"]] = { + fallback_metadata = { + item["compliance_id"]: { "framework": item["framework"], "version": item["version"], } + for item in queryset.values( + "compliance_id", "framework", "version" + ).distinct() + } for item in requirement_status_subquery: compliance_id = item["compliance_id"] @@ -3420,32 +4167,36 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): else: req_status = "MANUAL" - if compliance_id not in compliance_data: - compliance_data[compliance_id] = { + compliance_status = compliance_data.setdefault( + compliance_id, + { "total_requirements": 0, "requirements_passed": 0, "requirements_failed": 0, "requirements_manual": 0, - } + }, + ) - compliance_data[compliance_id]["total_requirements"] += 1 + compliance_status["total_requirements"] += 1 if req_status == "PASS": - compliance_data[compliance_id]["requirements_passed"] += 1 + compliance_status["requirements_passed"] += 1 elif req_status == "FAIL": - compliance_data[compliance_id]["requirements_failed"] += 1 + compliance_status["requirements_failed"] += 1 else: - compliance_data[compliance_id]["requirements_manual"] += 1 + compliance_status["requirements_manual"] += 1 response_data = [] for compliance_id, data in compliance_data.items(): - framework = framework_info.get(compliance_id, {}) + template = template_metadata.get(compliance_id, {}) + fallback = fallback_metadata.get(compliance_id, {}) response_data.append( { "id": compliance_id, "compliance_id": compliance_id, - "framework": framework.get("framework", ""), - "version": framework.get("version", ""), + "framework": template.get("framework") + or fallback.get("framework", ""), + "version": template.get("version") or fallback.get("version", ""), "requirements_passed": data["requirements_passed"], "requirements_failed": data["requirements_failed"], "requirements_manual": data["requirements_manual"], @@ -3453,6 +4204,104 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): } ) + serializer = self.get_serializer(response_data, many=True) + return serializer.data + + def _task_response_if_running(self, scan_id): + """Check for an in-progress task only when no compliance data exists.""" + try: + return self.get_task_response_if_running( + task_name="scan-compliance-overviews", + task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, + raise_on_not_found=False, + ) + except TaskFailedException: + return Response( + {"detail": "Task failed to generate compliance overview data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def _list_with_region_filter(self, scan_id, region_filter): + """ + Fall back to detailed ComplianceRequirementOverview query when region filter is applied. + This uses the original aggregation logic across filtered regions. + """ + regions = region_filter.split(",") if "," in region_filter else [region_filter] + queryset = self.filter_queryset(self.get_queryset()).filter( + scan_id=scan_id, + region__in=regions, + ) + + data = self._aggregate_compliance_overview(queryset) + if data: + return Response(data) + + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response + + return Response(data) + + def _list_without_region_aggregation(self, scan_id): + """ + Fall back aggregation when compliance summaries don't exist yet. + Aggregates ComplianceRequirementOverview data across ALL regions. + """ + queryset = self.filter_queryset(self.get_queryset()).filter(scan_id=scan_id) + compliance_template = self._get_compliance_template(scan_id=scan_id) + data = self._aggregate_compliance_overview( + queryset, template_metadata=compliance_template + ) + if data: + return Response(data) + + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response + + return Response(data) + + def list(self, request, *args, **kwargs): + scan_id = request.query_params.get("filter[scan_id]") + + # Specific scan requested - use optimized summaries with region support + region_filter = request.query_params.get( + "filter[region]" + ) or request.query_params.get("filter[region__in]") + + if region_filter: + # Fall back to detailed query with region filtering + return self._list_with_region_filter(scan_id, region_filter) + + summaries = list(self._compliance_summaries_queryset(scan_id)) + if not summaries: + # Trigger async backfill for next time + backfill_compliance_summaries_task.delay( + tenant_id=self.request.tenant_id, scan_id=scan_id + ) + # Use fallback aggregation for this request + return self._list_without_region_aggregation(scan_id) + + # Get compliance template for provider to enrich with framework/version + compliance_template = self._get_compliance_template(scan_id=scan_id) + + # Convert to response format with framework/version enrichment + response_data = [] + for summary in summaries: + compliance_metadata = compliance_template.get(summary.compliance_id, {}) + response_data.append( + { + "id": summary.compliance_id, + "compliance_id": summary.compliance_id, + "framework": compliance_metadata.get("framework", ""), + "version": compliance_metadata.get("version", ""), + "requirements_passed": summary.requirements_passed, + "requirements_failed": summary.requirements_failed, + "requirements_manual": summary.requirements_manual, + "total_requirements": summary.total_requirements, + } + ) + serializer = self.get_serializer(response_data, many=True) return Response(serializer.data) @@ -3470,18 +4319,6 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): } ] ) - try: - if task := self.get_task_response_if_running( - task_name="scan-compliance-overviews", - task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, - raise_on_not_found=False, - ): - return task - except TaskFailedException: - return Response( - {"detail": "Task failed to generate compliance overview data."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) regions = list( self.get_queryset() .filter(scan_id=scan_id) @@ -3491,6 +4328,15 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ) result = {"regions": regions} + if regions: + serializer = self.get_serializer(data=result) + serializer.is_valid(raise_exception=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response + serializer = self.get_serializer(data=result) serializer.is_valid(raise_exception=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -3523,18 +4369,6 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): } ] ) - try: - if task := self.get_task_response_if_running( - task_name="scan-compliance-overviews", - task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, - raise_on_not_found=False, - ): - return task - except TaskFailedException: - return Response( - {"detail": "Task failed to generate compliance overview data."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) filtered_queryset = self.filter_queryset(self.get_queryset()) all_requirements = filtered_queryset.values( @@ -3594,6 +4428,13 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): requirements_summary, many=True ) + if requirements_summary: + return Response(serializer.data, status=status.HTTP_200_OK) + + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response + return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_name="attributes") @@ -3612,20 +4453,11 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ) provider_type = None - try: - sample_requirement = ( - self.get_queryset().filter(compliance_id=compliance_id).first() - ) - - if sample_requirement: - provider_type = sample_requirement.scan.provider.provider - except Exception: - pass # 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 @@ -3723,11 +4555,71 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): summary="Get findings data by service", description=( "Retrieve an aggregated summary of findings grouped by service. The response includes the total count " - "of findings for each service, as long as there are at least one finding for that service. At least " - "one of the `inserted_at` filters must be provided." + "of findings for each service, as long as there are at least one finding for that service." ), filters=True, ), + regions=extend_schema( + summary="Get findings data by region", + description=( + "Retrieve an aggregated summary of findings grouped by region. The response includes the total, passed, " + "failed, and muted findings for each region based on the latest completed scans per provider. " + "Standard overview filters (inserted_at, provider filters, region filters, etc.) are supported." + ), + filters=True, + ), + findings_severity_timeseries=extend_schema( + summary="Get findings severity data over time", + description=( + "Retrieve daily aggregated findings data grouped by severity levels over a date range. " + "Returns one data point per day with counts of failed findings by severity (critical, high, " + "medium, low, informational) and muted findings. Days without scans are filled forward with " + "the most recent known values. Use date_from (required) and date_to filters to specify the range." + ), + filters=True, + ), + attack_surface=extend_schema( + summary="Get attack surface overview", + description="Retrieve aggregated attack surface metrics from latest completed scans per provider.", + tags=["Overview"], + filters=True, + responses={200: AttackSurfaceOverviewSerializer(many=True)}, + ), + categories=extend_schema( + summary="Get category overview", + description=( + "Retrieve aggregated category metrics from latest completed scans per provider. " + "Returns one row per category with total, failed, and new failed findings counts, " + "plus a severity breakdown showing failed findings per severity level. " + ), + tags=["Overview"], + filters=True, + responses={200: CategoryOverviewSerializer(many=True)}, + ), + resource_groups=extend_schema( + summary="Get resource group overview", + description=( + "Retrieve aggregated resource group metrics from latest completed scans per provider. " + "Returns one row per resource group with total, failed, and new failed findings counts, " + "plus a severity breakdown showing failed findings per severity level, " + "and a count of distinct resources evaluated per group." + ), + tags=["Overview"], + filters=True, + responses={200: ResourceGroupOverviewSerializer(many=True)}, + ), + compliance_watchlist=extend_schema( + summary="Get compliance watchlist overview", + description=( + "Retrieve compliance metrics with FAIL-dominant aggregation. " + "Without filters: uses pre-aggregated TenantComplianceSummary. " + "With provider filters: queries ProviderComplianceScore with FAIL-dominant logic " + "where any FAIL in a requirement marks it as failed." + ), + tags=["Overview"], + filters=True, + responses={200: ComplianceWatchlistOverviewSerializer(many=True)}, + ), ) @method_decorator(CACHE_DECORATOR, name="list") class OverviewViewSet(BaseRLSViewSet): @@ -3745,7 +4637,16 @@ class OverviewViewSet(BaseRLSViewSet): if not role.unlimited_visibility: self.allowed_providers = providers - return ScanSummary.all_objects.filter(tenant_id=self.request.tenant_id) + tenant_id = self.request.tenant_id + + # Return appropriate queryset per action + if self.action == "findings_severity_timeseries": + qs = DailySeveritySummary.objects.filter(tenant_id=tenant_id) + if hasattr(self, "allowed_providers"): + qs = qs.filter(provider_id__in=self.allowed_providers) + return qs + + return ScanSummary.all_objects.filter(tenant_id=tenant_id) def get_serializer_class(self): if self.action == "providers": @@ -3756,21 +4657,51 @@ class OverviewViewSet(BaseRLSViewSet): return OverviewFindingSerializer elif self.action == "findings_severity": return OverviewSeveritySerializer + elif self.action == "findings_severity_timeseries": + return FindingsSeverityOverTimeSerializer elif self.action == "services": return OverviewServiceSerializer + elif self.action == "regions": + return OverviewRegionSerializer + elif self.action == "threatscore": + return ThreatScoreSnapshotSerializer + elif self.action == "attack_surface": + return AttackSurfaceOverviewSerializer + elif self.action == "categories": + return CategoryOverviewSerializer + elif self.action == "resource_groups": + return ResourceGroupOverviewSerializer + elif self.action == "compliance_watchlist": + return ComplianceWatchlistOverviewSerializer return super().get_serializer_class() def get_filterset_class(self): if self.action == "providers": return None - elif self.action == "findings": + elif self.action in ["findings", "services", "regions"]: return ScanSummaryFilter elif self.action == "findings_severity": return ScanSummarySeverityFilter - elif self.action == "services": - return ServiceOverviewFilter + elif self.action == "findings_severity_timeseries": + return DailySeveritySummaryFilter + elif self.action == "categories": + return CategoryOverviewFilter + elif self.action == "resource_groups": + return ResourceGroupOverviewFilter + elif self.action == "attack_surface": + return AttackSurfaceOverviewFilter + elif self.action == "compliance_watchlist": + return ComplianceWatchlistFilter return None + def filter_queryset(self, queryset): + # Skip OrderingFilter for findings_severity_timeseries (no inserted_at field) + if self.action == "findings_severity_timeseries": + return CustomDjangoFilterBackend().filter_queryset( + self.request, queryset, self + ) + return super().filter_queryset(queryset) + @extend_schema(exclude=True) def list(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET") @@ -3779,6 +4710,122 @@ class OverviewViewSet(BaseRLSViewSet): def retrieve(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET") + def _get_latest_scans_queryset(self): + """ + Get filtered queryset for the latest completed scans per provider. + + Returns: + Filtered ScanSummary queryset with latest scan IDs applied. + """ + tenant_id = self.request.tenant_id + queryset = self.get_queryset() + filtered_queryset = self.filter_queryset(queryset) + provider_filter = ( + {"provider__in": self.allowed_providers} + if hasattr(self, "allowed_providers") + else {} + ) + + latest_scan_ids = ( + Scan.all_objects.filter( + tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter + ) + .order_by("provider_id", "-inserted_at") + .distinct("provider_id") + .values_list("id", flat=True) + ) + + return filtered_queryset.filter( + tenant_id=tenant_id, scan_id__in=latest_scan_ids + ) + + def _normalize_jsonapi_params(self, query_params, exclude_keys=None): + """Convert JSON:API filter params (filter[X]) to flat params (X).""" + exclude_keys = exclude_keys or set() + normalized = QueryDict(mutable=True) + for key, values in query_params.lists(): + normalized_key = ( + key[7:-1] if key.startswith("filter[") and key.endswith("]") else key + ) + if normalized_key not in exclude_keys: + normalized.setlist(normalized_key, values) + return normalized + + def _ensure_allowed_providers(self): + """Populate allowed providers for RBAC-aware queries once per request.""" + if getattr(self, "_providers_initialized", False): + return + self.get_queryset() + self._providers_initialized = True + + def _get_provider_filter(self, provider_field="provider"): + self._ensure_allowed_providers() + if hasattr(self, "allowed_providers"): + return {f"{provider_field}__in": self.allowed_providers} + return {} + + def _apply_provider_filter(self, queryset, provider_field="provider"): + provider_filter = self._get_provider_filter(provider_field) + if provider_filter: + return queryset.filter(**provider_filter) + return queryset + + def _apply_filterset(self, queryset, filterset_class, exclude_keys=None): + normalized_params = self._normalize_jsonapi_params( + self.request.query_params, exclude_keys=set(exclude_keys or []) + ) + filterset = filterset_class(normalized_params, queryset=queryset) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + return filterset.qs + + def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None): + provider_filter = self._get_provider_filter() + queryset = Scan.all_objects.filter( + tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter + ) + if provider_filters: + queryset = queryset.filter(**provider_filters) + return ( + queryset.order_by("provider_id", "-inserted_at") + .distinct("provider_id") + .values_list("id", flat=True) + ) + + def _extract_provider_filters_from_params(self): + """Extract and validate provider filters from query params.""" + params = self.request.query_params + filters = {} + valid_provider_types = {c[0] for c in Provider.ProviderChoices.choices} + + provider_id = params.get("filter[provider_id]") + if provider_id: + filters["provider_id"] = provider_id + + provider_id_in = params.get("filter[provider_id__in]") + if provider_id_in: + filters["provider_id__in"] = provider_id_in.split(",") + + provider_type = params.get("filter[provider_type]") + if provider_type: + if provider_type not in valid_provider_types: + raise ValidationError( + {"provider_type": f"Invalid choice: {provider_type}"} + ) + filters["provider__provider"] = provider_type + + provider_type_in = params.get("filter[provider_type__in]") + if provider_type_in: + types = provider_type_in.split(",") + invalid = [t for t in types if t not in valid_provider_types] + if invalid: + raise ValidationError( + {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} + ) + filters["provider__provider__in"] = types + + return filters + @action(detail=False, methods=["get"], url_name="providers") def providers(self, request): tenant_id = self.request.tenant_id @@ -3871,26 +4918,7 @@ class OverviewViewSet(BaseRLSViewSet): @action(detail=False, methods=["get"], url_name="findings") def findings(self, request): - tenant_id = self.request.tenant_id - queryset = self.get_queryset() - filtered_queryset = self.filter_queryset(queryset) - provider_filter = ( - {"provider__in": self.allowed_providers} - if hasattr(self, "allowed_providers") - else {} - ) - - latest_scan_ids = ( - Scan.all_objects.filter( - tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter - ) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) - filtered_queryset = filtered_queryset.filter( - tenant_id=tenant_id, scan_id__in=latest_scan_ids - ) + filtered_queryset = self._get_latest_scans_queryset() aggregated_totals = filtered_queryset.aggregate( _pass=Sum("_pass") or 0, @@ -3917,37 +4945,14 @@ class OverviewViewSet(BaseRLSViewSet): @action(detail=False, methods=["get"], url_name="findings_severity") def findings_severity(self, request): - tenant_id = self.request.tenant_id - - # Load only required fields - queryset = self.get_queryset().only( - "tenant_id", "scan_id", "severity", "fail", "_pass", "total" - ) - - filtered_queryset = self.filter_queryset(queryset) - provider_filter = ( - {"provider__in": self.allowed_providers} - if hasattr(self, "allowed_providers") - else {} - ) - - latest_scan_ids = ( - Scan.all_objects.filter( - tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter - ) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) - filtered_queryset = filtered_queryset.filter( - tenant_id=tenant_id, scan_id__in=latest_scan_ids - ) + filtered_queryset = self._get_latest_scans_queryset() # The filter will have added a status_count annotation if any status filter was used if "status_count" in filtered_queryset.query.annotations: sum_expression = Sum("status_count") else: - sum_expression = Sum("total") + # Exclude muted findings by default + sum_expression = Sum(F("_pass") + F("fail")) severity_counts = ( filtered_queryset.values("severity") @@ -3965,26 +4970,7 @@ class OverviewViewSet(BaseRLSViewSet): @action(detail=False, methods=["get"], url_name="services") def services(self, request): - tenant_id = self.request.tenant_id - queryset = self.get_queryset() - filtered_queryset = self.filter_queryset(queryset) - provider_filter = ( - {"provider__in": self.allowed_providers} - if hasattr(self, "allowed_providers") - else {} - ) - - latest_scan_ids = ( - Scan.all_objects.filter( - tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter - ) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) - filtered_queryset = filtered_queryset.filter( - tenant_id=tenant_id, scan_id__in=latest_scan_ids - ) + filtered_queryset = self._get_latest_scans_queryset() services_data = ( filtered_queryset.values("service") @@ -3999,6 +4985,728 @@ class OverviewViewSet(BaseRLSViewSet): return Response(serializer.data, status=status.HTTP_200_OK) + @action(detail=False, methods=["get"], url_name="regions") + def regions(self, request): + filtered_queryset = self._get_latest_scans_queryset() + + regions_data = ( + filtered_queryset.annotate(provider_type=F("scan__provider__provider")) + .values("provider_type", "region") + .annotate(_pass=Sum("_pass")) + .annotate(fail=Sum("fail")) + .annotate(muted=Sum("muted")) + .annotate(total=Sum("total")) + .order_by("provider_type", "region") + ) + + serializer = self.get_serializer(regions_data, many=True) + + return Response(serializer.data, status=status.HTTP_200_OK) + + @action( + detail=False, + methods=["get"], + url_path="findings_severity/timeseries", + url_name="findings_severity_timeseries", + ) + def findings_severity_timeseries(self, request): + """ + Daily severity trends for charts. Uses DailySeveritySummary pre-aggregation. + Requires date_from filter. + """ + # Get queryset with RBAC, provider, and date filters applied + # Date validation is handled by DailySeveritySummaryFilter + daily_qs = self.filter_queryset(self.get_queryset()) + + date_from = request._date_from + date_to = request._date_to + + if not daily_qs.exists(): + # No data matches filters - return zeros + result = self._generate_zero_result(date_from, date_to) + serializer = self.get_serializer(result, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Fetch all data for fill-forward logic + daily_summaries = list( + daily_qs.order_by("provider_id", "-date").values( + "provider_id", + "scan_id", + "date", + "critical", + "high", + "medium", + "low", + "informational", + "muted", + ) + ) + + if not daily_summaries: + result = self._generate_zero_result(date_from, date_to) + serializer = self.get_serializer(result, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Build provider_data: {provider_id: [(date, data), ...]} sorted by date desc + provider_data = defaultdict(list) + for summary in daily_summaries: + provider_data[summary["provider_id"]].append(summary) + + # For each day, find the latest data per provider and sum values + result = [] + current_date = date_from + while current_date <= date_to: + day_totals = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "informational": 0, + "muted": 0, + } + day_scan_ids = [] + + for provider_id, summaries in provider_data.items(): + # Find the latest data for this provider <= current_date + for summary in summaries: # Already sorted by date desc + if summary["date"] <= current_date: + day_totals["critical"] += summary["critical"] or 0 + day_totals["high"] += summary["high"] or 0 + day_totals["medium"] += summary["medium"] or 0 + day_totals["low"] += summary["low"] or 0 + day_totals["informational"] += summary["informational"] or 0 + day_totals["muted"] += summary["muted"] or 0 + day_scan_ids.append(summary["scan_id"]) + break # Found the latest data for this provider + + result.append( + {"date": current_date, "scan_ids": day_scan_ids, **day_totals} + ) + current_date += timedelta(days=1) + + serializer = self.get_serializer(result, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def _generate_zero_result(self, date_from, date_to): + """Generate a list of zero-filled results for each date in range.""" + result = [] + current_date = date_from + zero_values = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "informational": 0, + "muted": 0, + "scan_ids": [], + } + while current_date <= date_to: + result.append({"date": current_date, **zero_values}) + current_date += timedelta(days=1) + return result + + @extend_schema( + summary="Get ThreatScore snapshots", + description=( + "Retrieve ThreatScore metrics. By default, returns the latest snapshot for each provider. " + "Use snapshot_id to retrieve a specific historical snapshot." + ), + tags=["Overview"], + parameters=[ + OpenApiParameter( + name="snapshot_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Retrieve a specific snapshot by ID. If not provided, returns latest snapshots.", + ), + OpenApiParameter( + name="provider_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Filter by specific provider ID", + ), + OpenApiParameter( + name="provider_id__in", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by multiple provider IDs (comma-separated UUIDs)", + ), + OpenApiParameter( + name="provider_type", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by provider type (aws, azure, gcp, etc.)", + ), + OpenApiParameter( + name="provider_type__in", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by multiple provider types (comma-separated)", + ), + ], + ) + @action(detail=False, methods=["get"], url_name="threatscore") + def threatscore(self, request): + """ + Get ThreatScore snapshots. + + Default behavior: Returns the latest snapshot for each provider. + With snapshot_id: Returns the specific snapshot requested. + """ + tenant_id = self.request.tenant_id + snapshot_id = request.query_params.get("snapshot_id") + + # Base queryset with RLS + base_queryset = self._apply_provider_filter( + ThreatScoreSnapshot.objects.filter(tenant_id=tenant_id) + ) + + # Case 1: Specific snapshot requested + if snapshot_id: + try: + snapshot = base_queryset.get(id=snapshot_id) + serializer = ThreatScoreSnapshotSerializer( + snapshot, context={"request": request} + ) + return Response(serializer.data, status=status.HTTP_200_OK) + except ThreatScoreSnapshot.DoesNotExist: + raise NotFound(detail="ThreatScore snapshot not found") + + # Case 2: Latest snapshot per provider (default) + # Apply filters manually: this @action is outside the standard list endpoint flow, + # so DRF's filter backends don't execute and we must flatten JSON:API params ourselves. + filtered_queryset = self._apply_filterset( + base_queryset, ThreatScoreSnapshotFilter, exclude_keys={"snapshot_id"} + ) + + # Get distinct provider IDs from filtered queryset + # Pick the latest snapshot per provider using Postgres DISTINCT ON pattern. + # This avoids issuing one query per provider (N+1) when the filtered dataset is large. + latest_snapshot_ids = list( + filtered_queryset.order_by("provider_id", "-inserted_at") + .distinct("provider_id") + .values_list("id", flat=True) + ) + latest_snapshot_map = { + snapshot.id: snapshot + for snapshot in filtered_queryset.filter(id__in=latest_snapshot_ids) + } + latest_snapshots = [ + latest_snapshot_map[snapshot_id] + for snapshot_id in latest_snapshot_ids + if snapshot_id in latest_snapshot_map + ] + + if len(latest_snapshots) <= 1: + serializer = ThreatScoreSnapshotSerializer( + latest_snapshots, many=True, context={"request": request} + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + snapshot_ids = [ + snapshot.id for snapshot in latest_snapshots if snapshot and snapshot.id + ] + aggregated_snapshot = self._build_threatscore_overview_snapshot( + snapshot_ids, tenant_id + ) + serializer = ThreatScoreSnapshotSerializer( + [aggregated_snapshot], many=True, context={"request": request} + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + def _build_threatscore_overview_snapshot(self, snapshot_ids, tenant_id): + """ + Aggregate the latest snapshots into a single overview snapshot for the tenant. + """ + if not snapshot_ids: + raise ValueError( + "Snapshot id list cannot be empty when aggregating threatscore overview" + ) + + base_queryset = ThreatScoreSnapshot.objects.filter( + tenant_id=tenant_id, id__in=snapshot_ids + ) + + annotated_queryset = ( + base_queryset.annotate( + active_requirements=ExpressionWrapper( + F("total_requirements") - F("manual_requirements"), + output_field=IntegerField(), + ) + ) + .annotate( + weight=Case( + When(total_findings__gt=0, then=F("total_findings")), + When( + active_requirements__gt=0, + then=F("active_requirements"), + ), + default=Value(1, output_field=IntegerField()), + output_field=IntegerField(), + ) + ) + .order_by() + ) + + aggregated_metrics = annotated_queryset.aggregate( + total_requirements=Sum("total_requirements"), + passed_requirements=Sum("passed_requirements"), + failed_requirements=Sum("failed_requirements"), + manual_requirements=Sum("manual_requirements"), + total_findings=Sum("total_findings"), + passed_findings=Sum("passed_findings"), + failed_findings=Sum("failed_findings"), + weighted_overall_sum=Sum( + ExpressionWrapper( + F("overall_score") * F("weight"), + output_field=DecimalField(max_digits=14, decimal_places=4), + ) + ), + overall_weight=Sum("weight"), + unweighted_overall_sum=Sum("overall_score"), + weighted_delta_sum=Sum( + Case( + When( + score_delta__isnull=False, + then=ExpressionWrapper( + F("score_delta") * F("weight"), + output_field=DecimalField(max_digits=14, decimal_places=4), + ), + ), + default=Value( + Decimal("0"), + output_field=DecimalField(max_digits=14, decimal_places=4), + ), + output_field=DecimalField(max_digits=14, decimal_places=4), + ) + ), + delta_weight=Sum( + Case( + When(score_delta__isnull=False, then=F("weight")), + default=Value(0, output_field=IntegerField()), + output_field=IntegerField(), + ) + ), + provider_count=Count("id"), + latest_inserted_at=Max("inserted_at"), + ) + + total_requirements = aggregated_metrics["total_requirements"] or 0 + passed_requirements = aggregated_metrics["passed_requirements"] or 0 + failed_requirements = aggregated_metrics["failed_requirements"] or 0 + manual_requirements = aggregated_metrics["manual_requirements"] or 0 + total_findings = aggregated_metrics["total_findings"] or 0 + passed_findings = aggregated_metrics["passed_findings"] or 0 + failed_findings = aggregated_metrics["failed_findings"] or 0 + + weighted_overall_sum = aggregated_metrics["weighted_overall_sum"] + if weighted_overall_sum is None: + weighted_overall_sum = Decimal("0") + unweighted_overall_sum = aggregated_metrics["unweighted_overall_sum"] + if unweighted_overall_sum is None: + unweighted_overall_sum = Decimal("0") + + overall_weight = aggregated_metrics["overall_weight"] or 0 + provider_count = aggregated_metrics["provider_count"] or 0 + + weighted_delta_sum = aggregated_metrics["weighted_delta_sum"] + if weighted_delta_sum is None: + weighted_delta_sum = Decimal("0") + delta_weight = aggregated_metrics["delta_weight"] or 0 + + if overall_weight > 0: + overall_score = (weighted_overall_sum / Decimal(overall_weight)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + elif provider_count > 0: + overall_score = (unweighted_overall_sum / Decimal(provider_count)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + else: + overall_score = Decimal("0.00") + + if delta_weight > 0: + score_delta = (weighted_delta_sum / Decimal(delta_weight)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + else: + score_delta = None + + section_weighted_sums = defaultdict(lambda: Decimal("0")) + section_weights = defaultdict(lambda: Decimal("0")) + + combined_critical_requirements = {} + + snapshots_with_weight = list(annotated_queryset) + + for snapshot in snapshots_with_weight: + weight_value = getattr(snapshot, "weight", None) + try: + weight_decimal = Decimal(weight_value) + except (InvalidOperation, TypeError): + weight_decimal = Decimal("1") + if weight_decimal <= 0: + weight_decimal = Decimal("1") + + section_scores = snapshot.section_scores or {} + for section, score in section_scores.items(): + try: + score_decimal = Decimal(str(score)) + except (InvalidOperation, TypeError): + continue + section_weighted_sums[section] += score_decimal * weight_decimal + section_weights[section] += weight_decimal + + for requirement in snapshot.critical_requirements or []: + key = requirement.get("requirement_id") or requirement.get("title") + if not key: + continue + existing = combined_critical_requirements.get(key) + + def requirement_sort_key(item): + return ( + item.get("risk_level") or 0, + item.get("weight") or 0, + ) + + if existing is None or requirement_sort_key( + requirement + ) > requirement_sort_key(existing): + combined_critical_requirements[key] = deepcopy(requirement) + + aggregated_section_scores = {} + for section, total in section_weighted_sums.items(): + weight_total = section_weights[section] + if weight_total > 0: + aggregated_section_scores[section] = str( + (total / weight_total).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ) + + aggregated_section_scores = dict(sorted(aggregated_section_scores.items())) + + aggregated_critical_requirements = sorted( + combined_critical_requirements.values(), + key=lambda item: ( + item.get("risk_level") or 0, + item.get("weight") or 0, + ), + reverse=True, + ) + + aggregated_snapshot = ThreatScoreSnapshot( + tenant_id=tenant_id, + scan=None, + provider=None, + compliance_id="prowler_threatscore_overview", + overall_score=overall_score, + score_delta=score_delta, + section_scores=aggregated_section_scores, + critical_requirements=aggregated_critical_requirements, + total_requirements=total_requirements, + passed_requirements=passed_requirements, + failed_requirements=failed_requirements, + manual_requirements=manual_requirements, + total_findings=total_findings, + passed_findings=passed_findings, + failed_findings=failed_findings, + ) + + latest_inserted_at = aggregated_metrics["latest_inserted_at"] + if latest_inserted_at is not None: + aggregated_snapshot.inserted_at = latest_inserted_at + + aggregated_snapshot._aggregated = True + + return aggregated_snapshot + + @action( + detail=False, + methods=["get"], + url_name="attack-surface", + url_path="attack-surfaces", + ) + def attack_surface(self, request): + tenant_id = request.tenant_id + latest_scan_ids = self._latest_scan_ids_for_allowed_providers(tenant_id) + + base_queryset = AttackSurfaceOverview.objects.filter( + tenant_id=tenant_id, scan_id__in=latest_scan_ids + ) + filtered_queryset = self._apply_filterset( + base_queryset, AttackSurfaceOverviewFilter + ) + + aggregation = filtered_queryset.values("attack_surface_type").annotate( + total_findings=Coalesce(Sum("total_findings"), 0), + failed_findings=Coalesce(Sum("failed_findings"), 0), + muted_failed_findings=Coalesce(Sum("muted_failed_findings"), 0), + ) + + results = { + attack_surface_type: { + "total_findings": 0, + "failed_findings": 0, + "muted_failed_findings": 0, + } + for attack_surface_type in AttackSurfaceOverview.AttackSurfaceTypeChoices.values + } + for item in aggregation: + results[item["attack_surface_type"]] = { + "total_findings": item["total_findings"], + "failed_findings": item["failed_findings"], + "muted_failed_findings": item["muted_failed_findings"], + } + + response_data = [ + {"attack_surface_type": key, **value} for key, value in results.items() + ] + + return Response( + self.get_serializer(response_data, many=True).data, + status=status.HTTP_200_OK, + ) + + @action(detail=False, methods=["get"], url_name="categories") + def categories(self, request): + tenant_id = request.tenant_id + provider_filters = self._extract_provider_filters_from_params() + latest_scan_ids = self._latest_scan_ids_for_allowed_providers( + tenant_id, provider_filters + ) + + base_queryset = ScanCategorySummary.objects.filter( + tenant_id=tenant_id, scan_id__in=latest_scan_ids + ) + provider_filter_keys = { + "provider_id", + "provider_id__in", + "provider_type", + "provider_type__in", + } + filtered_queryset = self._apply_filterset( + base_queryset, CategoryOverviewFilter, exclude_keys=provider_filter_keys + ) + + aggregation = ( + filtered_queryset.values("category", "severity") + .annotate( + total=Coalesce(Sum("total_findings"), 0), + failed=Coalesce(Sum("failed_findings"), 0), + new_failed=Coalesce(Sum("new_failed_findings"), 0), + ) + .order_by("category", "severity") + ) + + category_data = defaultdict( + lambda: { + "total_findings": 0, + "failed_findings": 0, + "new_failed_findings": 0, + "severity": { + "informational": 0, + "low": 0, + "medium": 0, + "high": 0, + "critical": 0, + }, + } + ) + + for row in aggregation: + cat = row["category"] + sev = row["severity"] + category_data[cat]["total_findings"] += row["total"] + category_data[cat]["failed_findings"] += row["failed"] + category_data[cat]["new_failed_findings"] += row["new_failed"] + if sev in category_data[cat]["severity"]: + category_data[cat]["severity"][sev] = row["failed"] + + response_data = [ + {"category": cat, **data} for cat, data in sorted(category_data.items()) + ] + + return Response( + self.get_serializer(response_data, many=True).data, + status=status.HTTP_200_OK, + ) + + @action( + detail=False, + methods=["get"], + url_name="resource-groups", + url_path="resource-groups", + ) + def resource_groups(self, request): + tenant_id = request.tenant_id + provider_filters = self._extract_provider_filters_from_params() + latest_scan_ids = self._latest_scan_ids_for_allowed_providers( + tenant_id, provider_filters + ) + + base_queryset = ScanGroupSummary.objects.filter( + tenant_id=tenant_id, scan_id__in=latest_scan_ids + ) + provider_filter_keys = { + "provider_id", + "provider_id__in", + "provider_type", + "provider_type__in", + } + filtered_queryset = self._apply_filterset( + base_queryset, + ResourceGroupOverviewFilter, + exclude_keys=provider_filter_keys, + ) + + aggregation = ( + filtered_queryset.values("resource_group", "severity") + .annotate( + total=Coalesce(Sum("total_findings"), 0), + failed=Coalesce(Sum("failed_findings"), 0), + new_failed=Coalesce(Sum("new_failed_findings"), 0), + ) + .order_by("resource_group", "severity") + ) + + # Get resource_group-level resources_count: + # 1. Max per (scan, resource_group) to deduplicate within-scan severity rows + # 2. Sum across scans for cross-provider aggregation + scan_resource_group_resources = filtered_queryset.values( + "scan_id", "resource_group" + ).annotate(resources=Coalesce(Max("resources_count"), 0)) + resources_by_resource_group = defaultdict(int) + for row in scan_resource_group_resources: + resources_by_resource_group[row["resource_group"]] += row["resources"] + + resource_group_data = defaultdict( + lambda: { + "total_findings": 0, + "failed_findings": 0, + "new_failed_findings": 0, + "resources_count": 0, + "severity": { + "informational": 0, + "low": 0, + "medium": 0, + "high": 0, + "critical": 0, + }, + } + ) + + for row in aggregation: + grp = row["resource_group"] + sev = row["severity"] + resource_group_data[grp]["total_findings"] += row["total"] + resource_group_data[grp]["failed_findings"] += row["failed"] + resource_group_data[grp]["new_failed_findings"] += row["new_failed"] + if sev in resource_group_data[grp]["severity"]: + resource_group_data[grp]["severity"][sev] = row["failed"] + + # Set resources_count from resource_group-level aggregation + for grp in resource_group_data: + resource_group_data[grp]["resources_count"] = ( + resources_by_resource_group.get(grp, 0) + ) + + response_data = [ + {"resource_group": grp, **data} + for grp, data in sorted(resource_group_data.items()) + ] + + return Response( + self.get_serializer(response_data, many=True).data, + status=status.HTTP_200_OK, + ) + + @action( + detail=False, + methods=["get"], + url_name="compliance-watchlist", + url_path="compliance-watchlist", + ) + def compliance_watchlist(self, request): + """ + Get compliance watchlist overview with FAIL-dominant aggregation. + + Without filters: uses pre-aggregated TenantComplianceSummary (~70 rows). + With provider filters: queries ProviderComplianceScore with FAIL-dominant logic. + """ + tenant_id = request.tenant_id + rbac_filter = self._get_provider_filter() + query_params = request.query_params + + has_provider_filter = any( + key.startswith("filter[provider") for key in query_params.keys() + ) + has_rbac_restriction = bool(rbac_filter) + + if not has_provider_filter and not has_rbac_restriction: + response_data = list( + TenantComplianceSummary.objects.filter(tenant_id=tenant_id) + .values( + "compliance_id", + "requirements_passed", + "requirements_failed", + "requirements_manual", + "total_requirements", + ) + .order_by("compliance_id") + ) + else: + base_queryset = ProviderComplianceScore.objects.filter( + tenant_id=tenant_id, **rbac_filter + ) + + filtered_queryset = self._apply_filterset( + base_queryset, ComplianceWatchlistFilter + ) + + aggregation = ( + filtered_queryset.values("compliance_id", "requirement_id") + .annotate( + has_fail=Sum( + Case(When(requirement_status="FAIL", then=1), default=0) + ), + has_manual=Sum( + Case(When(requirement_status="MANUAL", then=1), default=0) + ), + ) + .values("compliance_id", "requirement_id", "has_fail", "has_manual") + ) + + compliance_data = defaultdict( + lambda: { + "requirements_passed": 0, + "requirements_failed": 0, + "requirements_manual": 0, + "total_requirements": 0, + } + ) + + for row in aggregation: + cid = row["compliance_id"] + compliance_data[cid]["total_requirements"] += 1 + + if row["has_fail"] and row["has_fail"] > 0: + compliance_data[cid]["requirements_failed"] += 1 + elif row["has_manual"] and row["has_manual"] > 0: + compliance_data[cid]["requirements_manual"] += 1 + else: + compliance_data[cid]["requirements_passed"] += 1 + + response_data = [ + {"compliance_id": cid, **data} + for cid, data in sorted(compliance_data.items()) + ] + + return Response( + self.get_serializer(response_data, many=True).data, + status=status.HTTP_200_OK, + ) + @extend_schema(tags=["Schedule"]) @extend_schema_view( @@ -4311,28 +6019,30 @@ class LighthouseConfigViewSet(BaseRLSViewSet): @extend_schema_view( list=extend_schema( tags=["Lighthouse AI"], - summary="List all LLM provider configs", + summary="List all LLM provider configurations", description="Retrieve all LLM provider configurations for the current tenant", ), retrieve=extend_schema( tags=["Lighthouse AI"], - summary="Retrieve LLM provider config", + summary="Retrieve LLM provider configuration", description="Get details for a specific provider configuration in the current tenant.", ), create=extend_schema( tags=["Lighthouse AI"], - summary="Create LLM provider config", - description="Create a per-tenant configuration for an LLM provider. Only one configuration per provider type is allowed per tenant.", + summary="Create LLM provider configuration", + description="Create a per-tenant configuration for an LLM provider. Only one configuration per provider type " + "is allowed per tenant.", ), partial_update=extend_schema( tags=["Lighthouse AI"], - summary="Update LLM provider config", + summary="Update LLM provider configuration", description="Partially update a provider configuration (e.g., base_url, is_active).", ), destroy=extend_schema( tags=["Lighthouse AI"], - summary="Delete LLM provider config", - description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion.", + summary="Delete LLM provider configuration", + description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared " + "during deletion.", ), ) class LighthouseProviderConfigViewSet(BaseRLSViewSet): @@ -4397,16 +6107,6 @@ class LighthouseProviderConfigViewSet(BaseRLSViewSet): @action(detail=True, methods=["post"], url_name="connection") def connection(self, request, pk=None): instance = self.get_object() - if ( - instance.provider_type - != LighthouseProviderConfiguration.LLMProviderChoices.OPENAI - ): - return Response( - data={ - "errors": [{"detail": "Only 'openai' provider supported in MVP"}] - }, - status=status.HTTP_400_BAD_REQUEST, - ) with transaction.atomic(): task = check_lighthouse_provider_connection_task.delay( @@ -4428,7 +6128,7 @@ class LighthouseProviderConfigViewSet(BaseRLSViewSet): @extend_schema( tags=["Lighthouse AI"], summary="Refresh LLM models catalog", - description="Fetch available models for this provider configuration and upsert into catalog.", + description="Fetch available models for this provider configuration and upsert into catalog. Supports OpenAI, OpenAI-compatible, and AWS Bedrock providers.", request=None, responses={202: OpenApiResponse(response=TaskSerializer)}, ) @@ -4440,16 +6140,6 @@ class LighthouseProviderConfigViewSet(BaseRLSViewSet): ) def refresh_models(self, request, pk=None): instance = self.get_object() - if ( - instance.provider_type - != LighthouseProviderConfiguration.LLMProviderChoices.OPENAI - ): - return Response( - data={ - "errors": [{"detail": "Only 'openai' provider supported in MVP"}] - }, - status=status.HTTP_400_BAD_REQUEST, - ) with transaction.atomic(): task = refresh_lighthouse_provider_models_task.delay( @@ -4686,7 +6376,7 @@ class TenantApiKeyViewSet(BaseRLSViewSet): @extend_schema(exclude=True) def destroy(self, request, *args, **kwargs): - raise MethodNotAllowed(method="DESTROY") + raise MethodNotAllowed(method="DELETE") @action(detail=True, methods=["delete"]) def revoke(self, request, *args, **kwargs): @@ -4705,3 +6395,95 @@ class TenantApiKeyViewSet(BaseRLSViewSet): serializer = self.get_serializer(instance) return Response(data=serializer.data, status=status.HTTP_200_OK) + + +# MuteRules +@extend_schema_view( + list=extend_schema( + tags=["Mute Rules"], + summary="List all mute rules", + description="Retrieve a list of all mute rules with filtering options.", + ), + retrieve=extend_schema( + tags=["Mute Rules"], + summary="Retrieve a mute rule", + description="Fetch detailed information about a specific mute rule by ID.", + ), + create=extend_schema( + tags=["Mute Rules"], + summary="Create a new mute rule", + description="Create a new mute rule by providing finding IDs, name, and reason. " + "The rule will immediately mute the selected findings and launch a background task " + "to mute all historical findings with matching UIDs.", + request=MuteRuleCreateSerializer, + ), + partial_update=extend_schema( + tags=["Mute Rules"], + summary="Partially update a mute rule", + description="Update certain fields of an existing mute rule (e.g., name, reason, enabled).", + request=MuteRuleUpdateSerializer, + responses={200: MuteRuleSerializer}, + ), + destroy=extend_schema( + tags=["Mute Rules"], + summary="Delete a mute rule", + description="Remove a mute rule from the system. Note: Previously muted findings remain muted.", + ), +) +class MuteRuleViewSet(BaseRLSViewSet): + queryset = MuteRule.objects.all() + serializer_class = MuteRuleSerializer + filterset_class = MuteRuleFilter + http_method_names = ["get", "post", "patch", "delete"] + search_fields = ["name", "reason"] + ordering = ["-inserted_at"] + ordering_fields = [ + "name", + "enabled", + "inserted_at", + "updated_at", + ] + required_permissions = [Permissions.MANAGE_SCANS] + + def get_queryset(self): + queryset = MuteRule.objects.filter(tenant_id=self.request.tenant_id) + return queryset.select_related("created_by") + + def get_serializer_class(self): + if self.action == "create": + return MuteRuleCreateSerializer + elif self.action == "partial_update": + return MuteRuleUpdateSerializer + return super().get_serializer_class() + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Create the mute rule + mute_rule = serializer.save() + + tenant_id = str(request.tenant_id) + finding_ids = request.data.get("finding_ids", []) + + # Immediately mute the selected findings + Finding.all_objects.filter( + id__in=finding_ids, tenant_id=tenant_id, muted=False + ).update( + muted=True, + muted_at=mute_rule.inserted_at, + muted_reason=mute_rule.reason, + ) + + # Launch background task for historical muting + with transaction.atomic(): + mute_historical_findings_task.apply_async( + kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)} + ) + + # Return the created mute rule + serializer = self.get_serializer(mute_rule) + return Response( + data=serializer.data, + status=status.HTTP_201_CREATED, + ) diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index b3a0ab4b68..aaa1b1c386 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -1,6 +1,7 @@ import warnings from celery import Celery, Task + from config.env import env # Suppress specific warnings from django-rest-auth: https://github.com/iMerica/dj-rest-auth/issues/684 diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 80b96952d7..c9e1b4750f 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -276,7 +276,7 @@ FINDINGS_MAX_DAYS_IN_RANGE = env.int("DJANGO_FINDINGS_MAX_DAYS_IN_RANGE", 7) DJANGO_TMP_OUTPUT_DIRECTORY = env.str( "DJANGO_TMP_OUTPUT_DIRECTORY", "/tmp/prowler_api_output" ) -DJANGO_FINDINGS_BATCH_SIZE = env.str("DJANGO_FINDINGS_BATCH_SIZE", 1000) +DJANGO_FINDINGS_BATCH_SIZE = env.int("DJANGO_FINDINGS_BATCH_SIZE", 1000) DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID = env.str("DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID", "") diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 12c3c384c7..9c83557b77 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -36,6 +36,20 @@ DATABASES = { "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, + "admin_replica": { + "ENGINE": "psqlextra.backend", + "NAME": env("POSTGRES_REPLICA_DB", default=default_db_name), + "USER": env("POSTGRES_ADMIN_USER", default="prowler"), + "PASSWORD": env("POSTGRES_ADMIN_PASSWORD", default="S3cret"), + "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), + "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), + }, + "neo4j": { + "HOST": env.str("NEO4J_HOST", "neo4j"), + "PORT": env.str("NEO4J_PORT", "7687"), + "USER": env.str("NEO4J_USER", "neo4j"), + "PASSWORD": env.str("NEO4J_PASSWORD", "neo4j_password"), + }, } DATABASES["default"] = DATABASES["prowler_user"] diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index 5a4cc73044..b2769237fc 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -37,6 +37,20 @@ DATABASES = { "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, + "admin_replica": { + "ENGINE": "psqlextra.backend", + "NAME": env("POSTGRES_REPLICA_DB", default=default_db_name), + "USER": env("POSTGRES_ADMIN_USER"), + "PASSWORD": env("POSTGRES_ADMIN_PASSWORD"), + "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), + "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), + }, + "neo4j": { + "HOST": env.str("NEO4J_HOST"), + "PORT": env.str("NEO4J_PORT"), + "USER": env.str("NEO4J_USER"), + "PASSWORD": env.str("NEO4J_PASSWORD"), + }, } DATABASES["default"] = DATABASES["prowler_user"] diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py index 5289f067fa..75779f5a68 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -18,6 +18,10 @@ DATABASES = { DATABASE_ROUTERS = [] TESTING = True +# Override page size for testing to a value only slightly above the current fixture count. +# We explicitly set PAGE_SIZE to 15 (round number just above fixture) to avoid masking pagination bugs, while not setting it excessively high. +# If you add more providers to the fixture, please review that the total value is below the current one and update this value if needed. +REST_FRAMEWORK["PAGE_SIZE"] = 15 # noqa: F405 SECRETS_ENCRYPTION_KEY = "ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0=" # DRF Simple API Key settings diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 98666c08de..6b8c554258 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -5,6 +5,9 @@ IGNORED_EXCEPTIONS = [ # Provider is not connected due to credentials errors "is not connected", "ProviderConnectionError", + # Provider was deleted during a scan + "ProviderDeletedException", + "violates foreign key constraint", # Authentication Errors from AWS "InvalidToken", "AccessDeniedException", diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 3042e98c2c..e2c22e5112 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -1,5 +1,6 @@ import logging from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -11,10 +12,20 @@ from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status from rest_framework.test import APIClient -from tasks.jobs.backfill import backfill_resource_scan_summaries +from tasks.jobs.backfill import ( + backfill_resource_scan_summaries, + backfill_scan_category_summaries, + backfill_scan_resource_group_summaries, +) +from api.attack_paths import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) from api.db_utils import rls_transaction from api.models import ( + AttackPathsScan, + AttackSurfaceOverview, ComplianceOverview, ComplianceRequirementOverview, Finding, @@ -23,8 +34,10 @@ from api.models import ( Invitation, LighthouseConfiguration, Membership, + MuteRule, Processor, Provider, + ProviderComplianceScore, ProviderGroup, ProviderSecret, Resource, @@ -34,11 +47,14 @@ from api.models import ( SAMLConfiguration, SAMLDomainIndex, Scan, + ScanCategorySummary, + ScanGroupSummary, ScanSummary, StateChoices, StatusChoices, Task, TenantAPIKey, + TenantComplianceSummary, User, UserRoleRelationship, ) @@ -158,22 +174,20 @@ def create_test_user_rbac_no_roles(django_db_setup, django_db_blocker, tenants_f @pytest.fixture(scope="function") -def create_test_user_rbac_limited(django_db_setup, django_db_blocker): +def create_test_user_rbac_limited(django_db_setup, django_db_blocker, tenants_fixture): with django_db_blocker.unblock(): user = User.objects.create_user( name="testing_limited", email="rbac_limited@rbac.com", password=TEST_PASSWORD, ) - tenant = Tenant.objects.create( - name="Tenant Test", - ) + tenant = tenants_fixture[0] Membership.objects.create( user=user, tenant=tenant, role=Membership.RoleChoices.OWNER, ) - Role.objects.create( + role = Role.objects.create( name="limited", tenant_id=tenant.id, manage_users=False, @@ -186,7 +200,7 @@ def create_test_user_rbac_limited(django_db_setup, django_db_blocker): ) UserRoleRelationship.objects.create( user=user, - role=Role.objects.get(name="limited"), + role=role, tenant_id=tenant.id, ) return user @@ -500,13 +514,49 @@ def providers_fixture(tenants_fixture): tenant_id=tenant.id, ) provider7 = Provider.objects.create( - provider="oci", + provider="oraclecloud", uid="ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda", alias="oci_testing", tenant_id=tenant.id, ) + provider8 = Provider.objects.create( + provider="mongodbatlas", + uid="64b1d3c0e4b03b1234567890", + alias="mongodbatlas_testing", + tenant_id=tenant.id, + ) + provider9 = Provider.objects.create( + provider="alibabacloud", + uid="1234567890123456", + alias="alibabacloud_testing", + tenant_id=tenant.id, + ) + provider10 = Provider.objects.create( + provider="cloudflare", + uid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + alias="cloudflare_testing", + tenant_id=tenant.id, + ) + provider11 = Provider.objects.create( + provider="openstack", + uid="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + alias="openstack_testing", + tenant_id=tenant.id, + ) - return provider1, provider2, provider3, provider4, provider5, provider6, provider7 + return ( + provider1, + provider2, + provider3, + provider4, + provider5, + provider6, + provider7, + provider8, + provider9, + provider10, + provider11, + ) @pytest.fixture @@ -709,6 +759,7 @@ def resources_fixture(providers_fixture): region="us-east-1", service="ec2", type="prowler-test", + groups=["compute"], ) resource1.upsert_or_delete_tags(tags) @@ -721,6 +772,7 @@ def resources_fixture(providers_fixture): region="eu-west-1", service="s3", type="prowler-test", + groups=["storage"], ) resource2.upsert_or_delete_tags(tags) @@ -732,6 +784,7 @@ def resources_fixture(providers_fixture): region="us-east-1", service="ec2", type="test", + groups=["compute"], ) tags = [ @@ -1092,8 +1145,8 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture): region="region1", _pass=1, fail=0, - muted=0, - total=1, + muted=2, + total=3, new=1, changed=0, unchanged=0, @@ -1101,7 +1154,7 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture): fail_changed=0, pass_new=1, pass_changed=0, - muted_new=0, + muted_new=2, muted_changed=0, scan=scan, ) @@ -1114,8 +1167,8 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture): region="region2", _pass=0, fail=1, - muted=1, - total=2, + muted=3, + total=4, new=2, changed=0, unchanged=0, @@ -1123,7 +1176,7 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture): fail_changed=0, pass_new=0, pass_changed=0, - muted_new=1, + muted_new=3, muted_changed=0, scan=scan, ) @@ -1136,8 +1189,8 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture): region="region1", _pass=1, fail=0, - muted=0, - total=1, + muted=1, + total=2, new=1, changed=0, unchanged=0, @@ -1145,7 +1198,7 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture): fail_changed=0, pass_new=1, pass_changed=0, - muted_new=0, + muted_new=1, muted_changed=0, scan=scan, ) @@ -1204,7 +1257,7 @@ def lighthouse_config_fixture(authenticated_client, tenants_fixture): return LighthouseConfiguration.objects.create( tenant_id=tenants_fixture[0].id, name="OpenAI", - api_key_decoded="sk-test1234567890T3BlbkFJtest1234567890", + api_key_decoded="sk-fake-test-key-for-unit-testing-only", model="gpt-4o", temperature=0, max_tokens=4000, @@ -1254,6 +1307,115 @@ def latest_scan_finding(authenticated_client, providers_fixture, resources_fixtu return finding +@pytest.fixture(scope="function") +def findings_with_categories(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource = resources_fixture[0] + + finding = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_with_categories_1", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="genai_check", + check_metadata={"CheckId": "genai_check"}, + categories=["gen-ai", "security"], + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id)) + return finding + + +@pytest.fixture(scope="function") +def findings_with_multiple_categories(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource1, resource2 = resources_fixture[:2] + + finding1 = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_multi_cat_1", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="genai_check", + check_metadata={"CheckId": "genai_check"}, + categories=["gen-ai", "security"], + first_seen_at="2024-01-02T00:00:00Z", + ) + finding1.add_resources([resource1]) + + finding2 = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_multi_cat_2", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status 2", + impact=Severity.high, + impact_extended="test impact 2", + severity=Severity.high, + raw_result={"status": Status.FAIL}, + check_id="iam_check", + check_metadata={"CheckId": "iam_check"}, + categories=["iam", "security"], + first_seen_at="2024-01-02T00:00:00Z", + ) + finding2.add_resources([resource2]) + + backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id)) + return finding1, finding2 + + +@pytest.fixture(scope="function") +def latest_scan_finding_with_categories( + authenticated_client, providers_fixture, resources_fixture +): + provider = providers_fixture[0] + tenant_id = str(providers_fixture[0].tenant_id) + resource = resources_fixture[0] + scan = Scan.objects.create( + name="latest completed scan with categories", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + finding = Finding.objects.create( + tenant_id=tenant_id, + uid="latest_finding_with_categories", + scan=scan, + delta="new", + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="genai_iam_check", + check_metadata={"CheckId": "genai_iam_check"}, + categories=["gen-ai", "iam"], + resource_groups="ai_ml", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + backfill_resource_scan_summaries(tenant_id, str(scan.id)) + backfill_scan_category_summaries(tenant_id, str(scan.id)) + backfill_scan_resource_group_summaries(tenant_id, str(scan.id)) + return finding + + @pytest.fixture(scope="function") def latest_scan_resource(authenticated_client, providers_fixture): provider = providers_fixture[0] @@ -1425,10 +1587,375 @@ def api_keys_fixture(tenants_fixture, create_test_user): return [api_key1, api_key2, api_key3] +@pytest.fixture +def mute_rules_fixture(tenants_fixture, create_test_user, findings_fixture): + """Create test mute rules for testing.""" + tenant = tenants_fixture[0] + user = create_test_user + + # Create two mute rules: one enabled, one disabled + mute_rule1 = MuteRule.objects.create( + tenant_id=tenant.id, + name="Test Rule 1", + reason="Security exception for testing", + enabled=True, + created_by=user, + finding_uids=[findings_fixture[0].uid], + ) + + mute_rule2 = MuteRule.objects.create( + tenant_id=tenant.id, + name="Test Rule 2", + reason="Compliance exception approved", + enabled=False, + created_by=user, + finding_uids=[findings_fixture[1].uid], + ) + + return mute_rule1, mute_rule2 + + +@pytest.fixture +def create_attack_paths_scan(): + """Factory fixture to create Attack Paths scans for tests.""" + + def _create( + provider, + *, + scan=None, + state=StateChoices.COMPLETED, + progress=0, + graph_database="tenant-db", + **extra_fields, + ): + scan_instance = scan or Scan.objects.create( + name=extra_fields.pop("scan_name", "Attack Paths Supporting Scan"), + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=extra_fields.pop("scan_state", StateChoices.COMPLETED), + tenant_id=provider.tenant_id, + ) + + payload = { + "tenant_id": provider.tenant_id, + "provider": provider, + "scan": scan_instance, + "state": state, + "progress": progress, + "graph_database": graph_database, + } + payload.update(extra_fields) + + return AttackPathsScan.objects.create(**payload) + + return _create + + +@pytest.fixture +def attack_paths_query_definition_factory(): + """Factory fixture for building Attack Paths query definitions.""" + + def _create(**overrides): + cast_type = overrides.pop("cast_type", str) + parameters = overrides.pop( + "parameters", + [ + AttackPathsQueryParameterDefinition( + name="limit", + label="Limit", + cast=cast_type, + ) + ], + ) + definition_payload = { + "id": "aws-test", + "name": "Attack Paths Test Query", + "short_description": "Synthetic short description for tests.", + "description": "Synthetic Attack Paths definition for tests.", + "provider": "aws", + "cypher": "RETURN 1", + "parameters": parameters, + } + definition_payload.update(overrides) + return AttackPathsQueryDefinition(**definition_payload) + + return _create + + +@pytest.fixture +def attack_paths_graph_stub_classes(): + """Provide lightweight graph element stubs for Attack Paths serialization tests.""" + + class AttackPathsNativeValue: + def __init__(self, value): + self._value = value + + def to_native(self): + return self._value + + class AttackPathsNode: + def __init__(self, element_id, labels, properties): + self.element_id = element_id + self.labels = labels + self._properties = properties + + class AttackPathsRelationship: + def __init__(self, element_id, rel_type, start_node, end_node, properties): + self.element_id = element_id + self.type = rel_type + self.start_node = start_node + self.end_node = end_node + self._properties = properties + + return SimpleNamespace( + NativeValue=AttackPathsNativeValue, + Node=AttackPathsNode, + Relationship=AttackPathsRelationship, + ) + + +@pytest.fixture +def create_attack_surface_overview(): + def _create(tenant, scan, attack_surface_type, total=10, failed=5, muted_failed=2): + return AttackSurfaceOverview.objects.create( + tenant=tenant, + scan=scan, + attack_surface_type=attack_surface_type, + total_findings=total, + failed_findings=failed, + muted_failed_findings=muted_failed, + ) + + return _create + + +@pytest.fixture +def create_scan_category_summary(): + def _create( + tenant, + scan, + category, + severity, + total_findings=10, + failed_findings=5, + new_failed_findings=2, + ): + return ScanCategorySummary.objects.create( + tenant=tenant, + scan=scan, + category=category, + severity=severity, + total_findings=total_findings, + failed_findings=failed_findings, + new_failed_findings=new_failed_findings, + ) + + return _create + + +@pytest.fixture(scope="function") +def findings_with_group(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource = resources_fixture[0] + + finding = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_with_group_1", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="storage_check", + check_metadata={"CheckId": "storage_check"}, + resource_groups="storage", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id)) + return finding + + +@pytest.fixture(scope="function") +def findings_with_multiple_groups(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource1, resource2 = resources_fixture[:2] + + finding1 = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_multi_grp_1", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="storage_check", + check_metadata={"CheckId": "storage_check"}, + resource_groups="storage", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding1.add_resources([resource1]) + + finding2 = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_multi_grp_2", + scan=scan, + delta=None, + status=Status.FAIL, + status_extended="test status 2", + impact=Severity.high, + impact_extended="test impact 2", + severity=Severity.high, + raw_result={"status": Status.FAIL}, + check_id="security_check", + check_metadata={"CheckId": "security_check"}, + resource_groups="security", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding2.add_resources([resource2]) + + backfill_resource_scan_summaries(str(scan.tenant_id), str(scan.id)) + return finding1, finding2 + + +@pytest.fixture +def create_scan_resource_group_summary(): + def _create( + tenant, + scan, + resource_group, + severity, + total_findings=10, + failed_findings=5, + new_failed_findings=2, + resources_count=3, + ): + return ScanGroupSummary.objects.create( + tenant=tenant, + scan=scan, + resource_group=resource_group, + severity=severity, + total_findings=total_findings, + failed_findings=failed_findings, + new_failed_findings=new_failed_findings, + resources_count=resources_count, + ) + + return _create + + def get_authorization_header(access_token: str) -> dict: return {"Authorization": f"Bearer {access_token}"} +@pytest.fixture +def provider_compliance_scores_fixture( + tenants_fixture, providers_fixture, scans_fixture +): + """Create ProviderComplianceScore entries for compliance watchlist tests.""" + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + scan1, _, scan3 = scans_fixture + + scan1.completed_at = datetime.now(timezone.utc) - timedelta(hours=1) + scan1.save() + scan3.state = StateChoices.COMPLETED + scan3.completed_at = datetime.now(timezone.utc) + scan3.save() + + scores = [ + ProviderComplianceScore.objects.create( + tenant_id=tenant.id, + provider=provider1, + scan=scan1, + compliance_id="aws_cis_2.0", + requirement_id="req_1", + requirement_status=StatusChoices.PASS, + scan_completed_at=scan1.completed_at, + ), + ProviderComplianceScore.objects.create( + tenant_id=tenant.id, + provider=provider1, + scan=scan1, + compliance_id="aws_cis_2.0", + requirement_id="req_2", + requirement_status=StatusChoices.FAIL, + scan_completed_at=scan1.completed_at, + ), + ProviderComplianceScore.objects.create( + tenant_id=tenant.id, + provider=provider1, + scan=scan1, + compliance_id="aws_cis_2.0", + requirement_id="req_3", + requirement_status=StatusChoices.MANUAL, + scan_completed_at=scan1.completed_at, + ), + ProviderComplianceScore.objects.create( + tenant_id=tenant.id, + provider=provider2, + scan=scan3, + compliance_id="aws_cis_2.0", + requirement_id="req_1", + requirement_status=StatusChoices.FAIL, + scan_completed_at=scan3.completed_at, + ), + ProviderComplianceScore.objects.create( + tenant_id=tenant.id, + provider=provider2, + scan=scan3, + compliance_id="aws_cis_2.0", + requirement_id="req_2", + requirement_status=StatusChoices.PASS, + scan_completed_at=scan3.completed_at, + ), + ProviderComplianceScore.objects.create( + tenant_id=tenant.id, + provider=provider1, + scan=scan1, + compliance_id="gdpr_aws", + requirement_id="gdpr_req_1", + requirement_status=StatusChoices.PASS, + scan_completed_at=scan1.completed_at, + ), + ] + + return scores + + +@pytest.fixture +def tenant_compliance_summary_fixture(tenants_fixture): + """Create TenantComplianceSummary entries for compliance watchlist tests.""" + tenant = tenants_fixture[0] + + summaries = [ + TenantComplianceSummary.objects.create( + tenant_id=tenant.id, + compliance_id="aws_cis_2.0", + requirements_passed=1, + requirements_failed=2, + requirements_manual=1, + total_requirements=4, + ), + TenantComplianceSummary.objects.create( + tenant_id=tenant.id, + compliance_id="gdpr_aws", + requirements_passed=5, + requirements_failed=0, + requirements_manual=2, + total_requirements=7, + ), + ] + + return summaries + + def pytest_collection_modifyitems(items): """Ensure test_rbac.py is executed first.""" items.sort(key=lambda item: 0 if "test_rbac.py" in item.nodeid else 1) diff --git a/api/src/backend/tasks/assets/img/ens_logo.png b/api/src/backend/tasks/assets/img/ens_logo.png new file mode 100644 index 0000000000..c3e6433f31 Binary files /dev/null and b/api/src/backend/tasks/assets/img/ens_logo.png differ diff --git a/api/src/backend/tasks/assets/img/nis2_logo.png b/api/src/backend/tasks/assets/img/nis2_logo.png new file mode 100644 index 0000000000..53de990258 Binary files /dev/null and b/api/src/backend/tasks/assets/img/nis2_logo.png differ diff --git a/api/src/backend/tasks/beat.py b/api/src/backend/tasks/beat.py index a7795e6909..e9eb9c9309 100644 --- a/api/src/backend/tasks/beat.py +++ b/api/src/backend/tasks/beat.py @@ -7,6 +7,7 @@ from tasks.tasks import perform_scheduled_scan_task from api.db_utils import rls_transaction from api.exceptions import ConflictException from api.models import Provider, Scan, StateChoices +from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils def schedule_provider_scan(provider_instance: Provider): @@ -39,6 +40,12 @@ def schedule_provider_scan(provider_instance: Provider): scheduled_at=datetime.now(timezone.utc), ) + attack_paths_db_utils.create_attack_paths_scan( + tenant_id=tenant_id, + scan_id=str(scheduled_scan.id), + provider_id=provider_id, + ) + # Schedule the task periodic_task_instance = PeriodicTask.objects.create( interval=schedule, @@ -61,4 +68,5 @@ def schedule_provider_scan(provider_instance: Provider): "tenant_id": str(provider_instance.tenant_id), "provider_id": provider_id, }, + countdown=5, # Avoid race conditions between the worker and the database ) diff --git a/api/src/backend/tasks/jobs/attack_paths/__init__.py b/api/src/backend/tasks/jobs/attack_paths/__init__.py new file mode 100644 index 0000000000..8fb57bc907 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/__init__.py @@ -0,0 +1,7 @@ +from tasks.jobs.attack_paths.db_utils import can_provider_run_attack_paths_scan +from tasks.jobs.attack_paths.scan import run as attack_paths_scan + +__all__ = [ + "attack_paths_scan", + "can_provider_run_attack_paths_scan", +] diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py new file mode 100644 index 0000000000..9242946181 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -0,0 +1,253 @@ +# Portions of this file are based on code from the Cartography project +# (https://github.com/cartography-cncf/cartography), which is licensed under the Apache 2.0 License. + +from typing import Any + +import aioboto3 +import boto3 +import neo4j + +from cartography.config import Config as CartographyConfig +from cartography.intel import aws as cartography_aws +from celery.utils.log import get_task_logger + +from api.models import ( + AttackPathsScan as ProwlerAPIAttackPathsScan, + Provider as ProwlerAPIProvider, +) +from prowler.providers.common.provider import Provider as ProwlerSDKProvider +from tasks.jobs.attack_paths import db_utils, utils + +logger = get_task_logger(__name__) + + +def start_aws_ingestion( + neo4j_session: neo4j.Session, + cartography_config: CartographyConfig, + prowler_api_provider: ProwlerAPIProvider, + prowler_sdk_provider: ProwlerSDKProvider, + attack_paths_scan: ProwlerAPIAttackPathsScan, +) -> dict[str, dict[str, str]]: + """ + Code based on Cartography, specifically on `cartography.intel.aws.__init__.py`. + + For the scan progress updates: + - The caller of this function (`tasks.jobs.attack_paths.scan.run`) has set it to 2. + - When the control returns to the caller, it will be set to 95. + """ + + # Initialize variables common to all jobs + common_job_parameters = { + "UPDATE_TAG": cartography_config.update_tag, + "permission_relationships_file": cartography_config.permission_relationships_file, + "aws_guardduty_severity_threshold": cartography_config.aws_guardduty_severity_threshold, + "aws_cloudtrail_management_events_lookback_hours": cartography_config.aws_cloudtrail_management_events_lookback_hours, + "experimental_aws_inspector_batch": cartography_config.experimental_aws_inspector_batch, + } + + boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider) + regions: list[str] = list(prowler_sdk_provider._enabled_regions) + requested_syncs = list(cartography_aws.RESOURCE_FUNCTIONS.keys()) + + sync_args = cartography_aws._build_aws_sync_kwargs( + neo4j_session, + boto3_session, + regions, + prowler_api_provider.uid, + cartography_config.update_tag, + common_job_parameters, + ) + + # Starting with sync functions + logger.info(f"Syncing organizations for AWS account {prowler_api_provider.uid}") + cartography_aws.organizations.sync( + neo4j_session, + {prowler_api_provider.alias: prowler_api_provider.uid}, + cartography_config.update_tag, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 3) + + # Adding an extra field + common_job_parameters["AWS_ID"] = prowler_api_provider.uid + + cartography_aws._autodiscover_accounts( + neo4j_session, + boto3_session, + prowler_api_provider.uid, + cartography_config.update_tag, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 4) + + failed_syncs = sync_aws_account( + prowler_api_provider, requested_syncs, sync_args, attack_paths_scan + ) + + if "permission_relationships" in requested_syncs: + logger.info( + f"Syncing function permission_relationships for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"](**sync_args) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 88) + + if "resourcegroupstaggingapi" in requested_syncs: + logger.info( + f"Syncing function resourcegroupstaggingapi for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.RESOURCE_FUNCTIONS["resourcegroupstaggingapi"](**sync_args) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 89) + + logger.info( + f"Syncing ec2_iaminstanceprofile scoped analysis for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.run_scoped_analysis_job( + "aws_ec2_iaminstanceprofile.json", + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 90) + + logger.info( + f"Syncing lambda_ecr analysis for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.run_analysis_job( + "aws_lambda_ecr.json", + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 91) + + logger.info(f"Syncing metadata for AWS account {prowler_api_provider.uid}") + cartography_aws.merge_module_sync_metadata( + neo4j_session, + group_type="AWSAccount", + group_id=prowler_api_provider.uid, + synced_type="AWSAccount", + update_tag=cartography_config.update_tag, + stat_handler=cartography_aws.stat_handler, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 92) + + # Removing the added extra field + del common_job_parameters["AWS_ID"] + + logger.info(f"Syncing cleanup_job for AWS account {prowler_api_provider.uid}") + cartography_aws.run_cleanup_job( + "aws_post_ingestion_principals_cleanup.json", + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 93) + + logger.info(f"Syncing analysis for AWS account {prowler_api_provider.uid}") + cartography_aws._perform_aws_analysis( + requested_syncs, neo4j_session, common_job_parameters + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 94) + + return failed_syncs + + +def get_boto3_session( + prowler_api_provider: ProwlerAPIProvider, prowler_sdk_provider: ProwlerSDKProvider +) -> boto3.Session: + boto3_session = prowler_sdk_provider.session.current_session + + aws_accounts_from_session = cartography_aws.organizations.get_aws_account_default( + boto3_session + ) + if not aws_accounts_from_session: + raise Exception( + "No valid AWS credentials could be found. No AWS accounts can be synced." + ) + + aws_account_id_from_session = list(aws_accounts_from_session.values())[0] + if prowler_api_provider.uid != aws_account_id_from_session: + raise Exception( + f"Provider {prowler_api_provider.uid} doesn't match AWS account {aws_account_id_from_session}." + ) + + if boto3_session.region_name is None: + global_region = prowler_sdk_provider.get_global_region() + boto3_session._session.set_config_variable("region", global_region) + + return boto3_session + + +def get_aioboto3_session(boto3_session: boto3.Session) -> aioboto3.Session: + return aioboto3.Session(botocore_session=boto3_session._session) + + +def sync_aws_account( + prowler_api_provider: ProwlerAPIProvider, + requested_syncs: list[str], + sync_args: dict[str, Any], + attack_paths_scan: ProwlerAPIAttackPathsScan, +) -> dict[str, str]: + current_progress = 4 # `cartography_aws._autodiscover_accounts` + max_progress = ( + 87 # `cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"]` - 1 + ) + n_steps = ( + len(requested_syncs) - 2 + ) # Excluding `permission_relationships` and `resourcegroupstaggingapi` + progress_step = (max_progress - current_progress) / n_steps + + failed_syncs = {} + + for func_name in requested_syncs: + if func_name in cartography_aws.RESOURCE_FUNCTIONS: + logger.info( + f"Syncing function {func_name} for AWS account {prowler_api_provider.uid}" + ) + + # Updating progress, not really the right place but good enough + current_progress += progress_step + db_utils.update_attack_paths_scan_progress( + attack_paths_scan, int(current_progress) + ) + + try: + # `ecr:image_layers` uses `aioboto3_session` instead of `boto3_session` + if func_name == "ecr:image_layers": + cartography_aws.RESOURCE_FUNCTIONS[func_name]( + neo4j_session=sync_args.get("neo4j_session"), + aioboto3_session=get_aioboto3_session( + sync_args.get("boto3_session") + ), + regions=sync_args.get("regions"), + current_aws_account_id=sync_args.get("current_aws_account_id"), + update_tag=sync_args.get("update_tag"), + common_job_parameters=sync_args.get("common_job_parameters"), + ) + + # Skip permission relationships and tags for now because they rely on data already being in the graph + elif func_name in [ + "permission_relationships", + "resourcegroupstaggingapi", + ]: + continue + + else: + cartography_aws.RESOURCE_FUNCTIONS[func_name](**sync_args) + + except Exception as e: + exception_message = utils.stringify_exception( + e, f"Exception for AWS sync function: {func_name}" + ) + failed_syncs[func_name] = exception_message + + logger.warning( + f"Caught exception syncing function {func_name} from AWS account {prowler_api_provider.uid}. We " + "are continuing on to the next AWS sync function.", + ) + + continue + + else: + raise ValueError( + f'AWS sync function "{func_name}" was specified but does not exist. Did you misspell it?' + ) + + return failed_syncs diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py new file mode 100644 index 0000000000..af8094e172 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from typing import Callable + +from config.env import env + +from tasks.jobs.attack_paths import aws + + +# Batch size for Neo4j operations +BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000) + +# Neo4j internal labels (Prowler-specific, not provider-specific) +# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources. +# - `ProviderResource`: Added to ALL synced nodes for provider isolation and drop/query ops. +# - `Internet`: Singleton node representing external internet access for exposed-resource queries. +PROWLER_FINDING_LABEL = "ProwlerFinding" +PROVIDER_RESOURCE_LABEL = "ProviderResource" +INTERNET_NODE_LABEL = "Internet" + + +@dataclass(frozen=True) +class ProviderConfig: + """Configuration for a cloud provider's Attack Paths integration.""" + + name: str + root_node_label: str # e.g., "AWSAccount" + uid_field: str # e.g., "arn" + # Label for resources connected to the account node, enabling indexed finding lookups. + resource_label: str # e.g., "AWSResource" + ingestion_function: Callable + + +# Provider Configurations +# ----------------------- + +AWS_CONFIG = ProviderConfig( + name="aws", + root_node_label="AWSAccount", + uid_field="arn", + resource_label="AWSResource", + ingestion_function=aws.start_aws_ingestion, +) + +PROVIDER_CONFIGS: dict[str, ProviderConfig] = { + "aws": AWS_CONFIG, +} + +# Labels added by Prowler that should be filtered from API responses +# Derived from provider configs + common internal labels +INTERNAL_LABELS: list[str] = [ + "Tenant", + PROVIDER_RESOURCE_LABEL, + # Add all provider-specific resource labels + *[config.resource_label for config in PROVIDER_CONFIGS.values()], +] + + +# Provider Config Accessors +# ------------------------- + + +def is_provider_available(provider_type: str) -> bool: + """Check if a provider type is available for Attack Paths scans.""" + return provider_type in PROVIDER_CONFIGS + + +def get_cartography_ingestion_function(provider_type: str) -> Callable | None: + """Get the Cartography ingestion function for a provider type.""" + config = PROVIDER_CONFIGS.get(provider_type) + return config.ingestion_function if config else None + + +def get_root_node_label(provider_type: str) -> str: + """Get the root node label for a provider type (e.g., AWSAccount).""" + config = PROVIDER_CONFIGS.get(provider_type) + return config.root_node_label if config else "UnknownProviderAccount" + + +def get_node_uid_field(provider_type: str) -> str: + """Get the UID field for a provider type (e.g., arn for AWS).""" + config = PROVIDER_CONFIGS.get(provider_type) + return config.uid_field if config else "UnknownProviderUID" + + +def get_provider_resource_label(provider_type: str) -> str: + """Get the resource label for a provider type (e.g., `AWSResource`).""" + config = PROVIDER_CONFIGS.get(provider_type) + return config.resource_label if config else "UnknownProviderResource" diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py new file mode 100644 index 0000000000..f3b53a48c9 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -0,0 +1,171 @@ +from datetime import datetime, timezone +from typing import Any + +from cartography.config import Config as CartographyConfig + +from api.db_utils import rls_transaction +from api.models import ( + AttackPathsScan as ProwlerAPIAttackPathsScan, + Provider as ProwlerAPIProvider, + StateChoices, +) +from tasks.jobs.attack_paths.config import is_provider_available + + +def can_provider_run_attack_paths_scan(tenant_id: str, provider_id: int) -> bool: + with rls_transaction(tenant_id): + prowler_api_provider = ProwlerAPIProvider.objects.get(id=provider_id) + + return is_provider_available(prowler_api_provider.provider) + + +def create_attack_paths_scan( + tenant_id: str, + scan_id: str, + provider_id: int, +) -> ProwlerAPIAttackPathsScan | None: + if not can_provider_run_attack_paths_scan(tenant_id, provider_id): + return None + + with rls_transaction(tenant_id): + attack_paths_scan = ProwlerAPIAttackPathsScan.objects.create( + tenant_id=tenant_id, + provider_id=provider_id, + scan_id=scan_id, + state=StateChoices.SCHEDULED, + started_at=datetime.now(tz=timezone.utc), + ) + attack_paths_scan.save() + + return attack_paths_scan + + +def retrieve_attack_paths_scan( + tenant_id: str, + scan_id: str, +) -> ProwlerAPIAttackPathsScan | None: + try: + with rls_transaction(tenant_id): + attack_paths_scan = ProwlerAPIAttackPathsScan.objects.get( + scan_id=scan_id, + ) + + return attack_paths_scan + + except ProwlerAPIAttackPathsScan.DoesNotExist: + return None + + +def starting_attack_paths_scan( + attack_paths_scan: ProwlerAPIAttackPathsScan, + task_id: str, + cartography_config: CartographyConfig, +) -> None: + with rls_transaction(attack_paths_scan.tenant_id): + attack_paths_scan.task_id = task_id + attack_paths_scan.state = StateChoices.EXECUTING + attack_paths_scan.started_at = datetime.now(tz=timezone.utc) + attack_paths_scan.update_tag = cartography_config.update_tag + attack_paths_scan.graph_database = cartography_config.neo4j_database + + attack_paths_scan.save( + update_fields=[ + "task_id", + "state", + "started_at", + "update_tag", + "graph_database", + ] + ) + + +def finish_attack_paths_scan( + attack_paths_scan: ProwlerAPIAttackPathsScan, + state: StateChoices, + ingestion_exceptions: dict[str, Any], +) -> None: + with rls_transaction(attack_paths_scan.tenant_id): + now = datetime.now(tz=timezone.utc) + duration = ( + int((now - attack_paths_scan.started_at).total_seconds()) + if attack_paths_scan.started_at + else 0 + ) + + attack_paths_scan.state = state + attack_paths_scan.progress = 100 + attack_paths_scan.completed_at = now + attack_paths_scan.duration = duration + attack_paths_scan.ingestion_exceptions = ingestion_exceptions + + attack_paths_scan.save( + update_fields=[ + "state", + "progress", + "completed_at", + "duration", + "ingestion_exceptions", + ] + ) + + +def update_attack_paths_scan_progress( + attack_paths_scan: ProwlerAPIAttackPathsScan, + progress: int, +) -> None: + with rls_transaction(attack_paths_scan.tenant_id): + attack_paths_scan.progress = progress + attack_paths_scan.save(update_fields=["progress"]) + + +def get_old_attack_paths_scans( + tenant_id: str, + provider_id: str, + attack_paths_scan_id: str, +) -> list[ProwlerAPIAttackPathsScan]: + """ + An `old_attack_paths_scan` is any `completed` Attack Paths scan for the same provider, + with its graph database not deleted, excluding the current Attack Paths scan. + """ + + with rls_transaction(tenant_id): + completed_scans_qs = ( + ProwlerAPIAttackPathsScan.objects.filter( + provider_id=provider_id, + state=StateChoices.COMPLETED, + is_graph_database_deleted=False, + ) + .exclude(id=attack_paths_scan_id) + .all() + ) + + return list(completed_scans_qs) + + +def update_old_attack_paths_scan( + old_attack_paths_scan: ProwlerAPIAttackPathsScan, +) -> None: + with rls_transaction(old_attack_paths_scan.tenant_id): + old_attack_paths_scan.is_graph_database_deleted = True + old_attack_paths_scan.save(update_fields=["is_graph_database_deleted"]) + + +def fail_attack_paths_scan( + tenant_id: str, + scan_id: str, + error: str, +) -> None: + """ + Mark the `AttackPathsScan` row as `FAILED` unless it's already `COMPLETED` or `FAILED`. + Used as a safety net when the Celery task fails outside the job's own error handling. + """ + attack_paths_scan = retrieve_attack_paths_scan(tenant_id, scan_id) + if attack_paths_scan and attack_paths_scan.state not in ( + StateChoices.COMPLETED, + StateChoices.FAILED, + ): + finish_attack_paths_scan( + attack_paths_scan, + StateChoices.FAILED, + {"global_error": error}, + ) diff --git a/api/src/backend/tasks/jobs/attack_paths/findings.py b/api/src/backend/tasks/jobs/attack_paths/findings.py new file mode 100644 index 0000000000..b4534fb8de --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/findings.py @@ -0,0 +1,355 @@ +""" +Prowler findings ingestion into Neo4j graph. + +This module handles: +- Adding resource labels to Cartography nodes for efficient lookups +- Loading Prowler findings into the graph +- Linking findings to resources +- Cleaning up stale findings +""" + +from collections import defaultdict +from dataclasses import asdict, dataclass, fields +from typing import Any, Generator +from uuid import UUID + +import neo4j + +from cartography.config import Config as CartographyConfig +from celery.utils.log import get_task_logger + +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Finding as FindingModel +from api.models import Provider, ResourceFindingMapping +from prowler.config import config as ProwlerConfig +from tasks.jobs.attack_paths.config import ( + BATCH_SIZE, + get_node_uid_field, + get_provider_resource_label, + get_root_node_label, +) +from tasks.jobs.attack_paths.indexes import IndexType, create_indexes +from tasks.jobs.attack_paths.queries import ( + ADD_RESOURCE_LABEL_TEMPLATE, + CLEANUP_FINDINGS_TEMPLATE, + INSERT_FINDING_TEMPLATE, + render_cypher_template, +) + +logger = get_task_logger(__name__) + + +# Type Definitions +# ----------------- + +# Maps dataclass field names to Django ORM query field names +_DB_FIELD_MAP: dict[str, str] = { + "check_title": "check_metadata__checktitle", +} + + +@dataclass(slots=True) +class Finding: + """ + Finding data for Neo4j ingestion. + + Can be created from a Django .values() query result using from_db_record(). + """ + + id: str + uid: str + inserted_at: str + updated_at: str + first_seen_at: str + scan_id: str + delta: str + status: str + status_extended: str + severity: str + check_id: str + check_title: str + muted: bool + muted_reason: str | None + resource_uid: str | None = None + + @classmethod + def get_db_query_fields(cls) -> tuple[str, ...]: + """Get field names for Django .values() query.""" + return tuple( + _DB_FIELD_MAP.get(f.name, f.name) + for f in fields(cls) + if f.name != "resource_uid" + ) + + @classmethod + def from_db_record(cls, record: dict[str, Any], resource_uid: str) -> "Finding": + """Create a Finding from a Django .values() query result.""" + return cls( + id=str(record["id"]), + uid=record["uid"], + inserted_at=record["inserted_at"], + updated_at=record["updated_at"], + first_seen_at=record["first_seen_at"], + scan_id=str(record["scan_id"]), + delta=record["delta"], + status=record["status"], + status_extended=record["status_extended"], + severity=record["severity"], + check_id=str(record["check_id"]), + check_title=record["check_metadata__checktitle"], + muted=record["muted"], + muted_reason=record["muted_reason"], + resource_uid=resource_uid, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to dict for Neo4j ingestion.""" + return asdict(self) + + +# Public API +# ---------- + + +def create_findings_indexes(neo4j_session: neo4j.Session) -> None: + """Create indexes for Prowler findings and resource lookups.""" + create_indexes(neo4j_session, IndexType.FINDINGS) + + +def analysis( + neo4j_session: neo4j.Session, + prowler_api_provider: Provider, + scan_id: str, + config: CartographyConfig, +) -> None: + """ + Main entry point for Prowler findings analysis. + + Adds resource labels, loads findings, and cleans up stale data. + """ + add_resource_label( + neo4j_session, prowler_api_provider.provider, str(prowler_api_provider.uid) + ) + findings_data = stream_findings_with_resources(prowler_api_provider, scan_id) + load_findings(neo4j_session, findings_data, prowler_api_provider, config) + cleanup_findings(neo4j_session, prowler_api_provider, config) + + +def add_resource_label( + neo4j_session: neo4j.Session, provider_type: str, provider_uid: str +) -> int: + """ + Add a common resource label to all nodes connected to the provider account. + + This enables index usage for resource lookups in the findings query, + since Cartography nodes don't have a common parent label. + + Returns the total number of nodes labeled. + """ + query = render_cypher_template( + ADD_RESOURCE_LABEL_TEMPLATE, + { + "__ROOT_LABEL__": get_root_node_label(provider_type), + "__RESOURCE_LABEL__": get_provider_resource_label(provider_type), + }, + ) + + logger.info( + f"Adding {get_provider_resource_label(provider_type)} label to all resources for {provider_uid}" + ) + + total_labeled = 0 + labeled_count = 1 + + while labeled_count > 0: + result = neo4j_session.run( + query, + {"provider_uid": provider_uid, "batch_size": BATCH_SIZE}, + ) + labeled_count = result.single().get("labeled_count", 0) + total_labeled += labeled_count + + if labeled_count > 0: + logger.info( + f"Labeled {total_labeled} nodes with {get_provider_resource_label(provider_type)}" + ) + + return total_labeled + + +def load_findings( + neo4j_session: neo4j.Session, + findings_batches: Generator[list[Finding], None, None], + prowler_api_provider: Provider, + config: CartographyConfig, +) -> None: + """Load Prowler findings into the graph, linking them to resources.""" + query = render_cypher_template( + INSERT_FINDING_TEMPLATE, + { + "__ROOT_NODE_LABEL__": get_root_node_label(prowler_api_provider.provider), + "__NODE_UID_FIELD__": get_node_uid_field(prowler_api_provider.provider), + "__RESOURCE_LABEL__": get_provider_resource_label( + prowler_api_provider.provider + ), + }, + ) + + parameters = { + "provider_uid": str(prowler_api_provider.uid), + "last_updated": config.update_tag, + "prowler_version": ProwlerConfig.prowler_version, + } + + batch_num = 0 + total_records = 0 + for batch in findings_batches: + batch_num += 1 + batch_size = len(batch) + total_records += batch_size + + parameters["findings_data"] = [f.to_dict() for f in batch] + + logger.info(f"Loading findings batch {batch_num} ({batch_size} records)") + neo4j_session.run(query, parameters) + + logger.info(f"Finished loading {total_records} records in {batch_num} batches") + + +def cleanup_findings( + neo4j_session: neo4j.Session, + prowler_api_provider: Provider, + config: CartographyConfig, +) -> None: + """Remove stale findings (classic Cartography behaviour).""" + parameters = { + "provider_uid": str(prowler_api_provider.uid), + "last_updated": config.update_tag, + "batch_size": BATCH_SIZE, + } + + batch = 1 + deleted_count = 1 + while deleted_count > 0: + logger.info(f"Cleaning findings batch {batch}") + + result = neo4j_session.run(CLEANUP_FINDINGS_TEMPLATE, parameters) + + deleted_count = result.single().get("deleted_findings_count", 0) + batch += 1 + + +# Findings Streaming (Generator-based) +# ------------------------------------- + + +def stream_findings_with_resources( + prowler_api_provider: Provider, + scan_id: str, +) -> Generator[list[Finding], None, None]: + """ + Stream findings with their associated resources in batches. + + Uses keyset pagination for efficient traversal of large datasets. + Memory efficient: yields one batch at a time, never holds all findings in memory. + """ + logger.info( + f"Starting findings stream for scan {scan_id} " + f"(tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}" + ) + + tenant_id = prowler_api_provider.tenant_id + for batch in _paginate_findings(tenant_id, scan_id): + enriched = _enrich_batch_with_resources(batch, tenant_id) + if enriched: + yield enriched + + logger.info(f"Finished streaming findings for scan {scan_id}") + + +def _paginate_findings( + tenant_id: str, + scan_id: str, +) -> Generator[list[dict[str, Any]], None, None]: + """ + Paginate through findings using keyset pagination. + + Each iteration fetches one batch within its own RLS transaction, + preventing long-held database connections. + """ + last_id = None + iteration = 0 + + while True: + iteration += 1 + batch = _fetch_findings_batch(tenant_id, scan_id, last_id) + + logger.info(f"Iteration #{iteration}: fetched {len(batch)} findings") + + if not batch: + break + + last_id = batch[-1]["id"] + yield batch + + +def _fetch_findings_batch( + tenant_id: str, + scan_id: str, + after_id: UUID | None, +) -> list[dict[str, Any]]: + """ + Fetch a single batch of findings from the database. + + Uses read replica and RLS-scoped transaction. + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Use all_objects to avoid the ActiveProviderManager's implicit JOIN + # through Scan -> Provider (to check is_deleted=False). + # The provider is already validated as active in this context. + qs = FindingModel.all_objects.filter(scan_id=scan_id).order_by("id") + + if after_id is not None: + qs = qs.filter(id__gt=after_id) + + return list(qs.values(*Finding.get_db_query_fields())[:BATCH_SIZE]) + + +# Batch Enrichment +# ----------------- + + +def _enrich_batch_with_resources( + findings_batch: list[dict[str, Any]], + tenant_id: str, +) -> list[Finding]: + """ + Enrich findings with their resource UIDs. + + One finding with N resources becomes N output records. + Findings without resources are skipped. + """ + finding_ids = [f["id"] for f in findings_batch] + resource_map = _build_finding_resource_map(finding_ids, tenant_id) + + return [ + Finding.from_db_record(finding, resource_uid) + for finding in findings_batch + for resource_uid in resource_map.get(finding["id"], []) + ] + + +def _build_finding_resource_map( + finding_ids: list[UUID], tenant_id: str +) -> dict[UUID, list[str]]: + """Build mapping from finding_id to list of resource UIDs.""" + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + resource_mappings = ResourceFindingMapping.objects.filter( + finding_id__in=finding_ids + ).values_list("finding_id", "resource__uid") + + result = defaultdict(list) + for finding_id, resource_uid in resource_mappings: + result[finding_id].append(resource_uid) + return result diff --git a/api/src/backend/tasks/jobs/attack_paths/indexes.py b/api/src/backend/tasks/jobs/attack_paths/indexes.py new file mode 100644 index 0000000000..9ccd8cab04 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/indexes.py @@ -0,0 +1,67 @@ +from enum import Enum + +import neo4j + +from cartography.client.core.tx import run_write_query +from celery.utils.log import get_task_logger + +from tasks.jobs.attack_paths.config import ( + INTERNET_NODE_LABEL, + PROWLER_FINDING_LABEL, + PROVIDER_RESOURCE_LABEL, +) + +logger = get_task_logger(__name__) + + +class IndexType(Enum): + """Types of indexes that can be created.""" + + FINDINGS = "findings" + SYNC = "sync" + + +# Indexes for Prowler findings and resource lookups +FINDINGS_INDEX_STATEMENTS = [ + # Resources indexes for quick Prowler Finding lookups + "CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:AWSResource) ON (n.arn);", + "CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:AWSResource) ON (n.id);", + # Prowler Finding indexes + f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);", + f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);", + f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);", + f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);", + # Internet node index for MERGE lookups + f"CREATE INDEX internet_id IF NOT EXISTS FOR (n:{INTERNET_NODE_LABEL}) ON (n.id);", +] + +# Indexes for provider resource sync operations +SYNC_INDEX_STATEMENTS = [ + f"CREATE INDEX provider_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.provider_element_id);", + f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.provider_id);", +] + + +def create_indexes(neo4j_session: neo4j.Session, index_type: IndexType) -> None: + """ + Create indexes for the specified type. + + Args: + `neo4j_session`: The Neo4j session to use + `index_type`: The type of indexes to create (FINDINGS or SYNC) + """ + if index_type == IndexType.FINDINGS: + logger.info("Creating indexes for Prowler Findings node types") + for statement in FINDINGS_INDEX_STATEMENTS: + run_write_query(neo4j_session, statement) + + elif index_type == IndexType.SYNC: + logger.info("Ensuring ProviderResource indexes exist") + for statement in SYNC_INDEX_STATEMENTS: + neo4j_session.run(statement) + + +def create_all_indexes(neo4j_session: neo4j.Session) -> None: + """Create all indexes (both findings and sync).""" + create_indexes(neo4j_session, IndexType.FINDINGS) + create_indexes(neo4j_session, IndexType.SYNC) diff --git a/api/src/backend/tasks/jobs/attack_paths/internet.py b/api/src/backend/tasks/jobs/attack_paths/internet.py new file mode 100644 index 0000000000..83517bc903 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/internet.py @@ -0,0 +1,67 @@ +""" +Internet node enrichment for Attack Paths graph. + +Creates a real Internet node and CAN_ACCESS relationships to +internet-exposed resources (EC2Instance, LoadBalancer, LoadBalancerV2) +in the temporary scan database before sync. +""" + +import neo4j + +from cartography.config import Config as CartographyConfig +from celery.utils.log import get_task_logger + +from api.models import Provider +from prowler.config import config as ProwlerConfig +from tasks.jobs.attack_paths.config import get_root_node_label +from tasks.jobs.attack_paths.queries import ( + CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE, + CREATE_INTERNET_NODE, + render_cypher_template, +) + +logger = get_task_logger(__name__) + + +def analysis( + neo4j_session: neo4j.Session, + prowler_api_provider: Provider, + config: CartographyConfig, +) -> int: + """ + Create Internet node and CAN_ACCESS relationships to exposed resources. + + Args: + neo4j_session: Active Neo4j session (temp database). + prowler_api_provider: The Prowler API provider instance. + config: Cartography configuration with update_tag. + + Returns: + Number of CAN_ACCESS relationships created. + """ + provider_uid = str(prowler_api_provider.uid) + + parameters = { + "provider_uid": provider_uid, + "last_updated": config.update_tag, + "prowler_version": ProwlerConfig.prowler_version, + } + + logger.info(f"Creating Internet node for provider {provider_uid}") + neo4j_session.run(CREATE_INTERNET_NODE, parameters) + + query = render_cypher_template( + CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE, + {"__ROOT_LABEL__": get_root_node_label(prowler_api_provider.provider)}, + ) + + logger.info( + f"Creating CAN_ACCESS relationships from Internet to exposed resources for {provider_uid}" + ) + result = neo4j_session.run(query, parameters) + relationships_merged = result.single().get("relationships_merged", 0) + + logger.info( + f"Created {relationships_merged} CAN_ACCESS relationships for provider {provider_uid}" + ) + return relationships_merged diff --git a/api/src/backend/tasks/jobs/attack_paths/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py new file mode 100644 index 0000000000..75ef9ec5b3 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/queries.py @@ -0,0 +1,166 @@ +# Cypher query templates for Attack Paths operations +from tasks.jobs.attack_paths.config import ( + INTERNET_NODE_LABEL, + PROWLER_FINDING_LABEL, + PROVIDER_RESOURCE_LABEL, +) + + +def render_cypher_template(template: str, replacements: dict[str, str]) -> str: + """ + Render a Cypher query template by replacing placeholders. + + Placeholders use `__DOUBLE_UNDERSCORE__` format to avoid conflicts + with Cypher syntax. + """ + query = template + for placeholder, value in replacements.items(): + query = query.replace(placeholder, value) + return query + + +# Findings queries (used by findings.py) +# --------------------------------------- + +ADD_RESOURCE_LABEL_TEMPLATE = """ + MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r) + WHERE NOT r:__ROOT_LABEL__ AND NOT r:__RESOURCE_LABEL__ + WITH r LIMIT $batch_size + SET r:__RESOURCE_LABEL__ + RETURN COUNT(r) AS labeled_count +""" + +INSERT_FINDING_TEMPLATE = f""" + MATCH (account:__ROOT_NODE_LABEL__ {{id: $provider_uid}}) + UNWIND $findings_data AS finding_data + + OPTIONAL MATCH (account)-->(resource_by_uid:__RESOURCE_LABEL__) + WHERE resource_by_uid.__NODE_UID_FIELD__ = finding_data.resource_uid + WITH account, finding_data, resource_by_uid + + OPTIONAL MATCH (account)-->(resource_by_id:__RESOURCE_LABEL__) + 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:{PROWLER_FINDING_LABEL} {{id: finding_data.id}}) + ON CREATE SET + finding.id = finding_data.id, + finding.uid = finding_data.uid, + finding.inserted_at = finding_data.inserted_at, + finding.updated_at = finding_data.updated_at, + finding.first_seen_at = finding_data.first_seen_at, + finding.scan_id = finding_data.scan_id, + finding.delta = finding_data.delta, + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.severity = finding_data.severity, + finding.check_id = finding_data.check_id, + finding.check_title = finding_data.check_title, + finding.muted = finding_data.muted, + finding.muted_reason = finding_data.muted_reason, + finding.provider_uid = $provider_uid, + finding.firstseen = timestamp(), + finding.lastupdated = $last_updated, + finding._module_name = 'cartography:prowler', + finding._module_version = $prowler_version + ON MATCH SET + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.lastupdated = $last_updated + + MERGE (resource)-[rel:HAS_FINDING]->(finding) + ON CREATE SET + rel.provider_uid = $provider_uid, + rel.firstseen = timestamp(), + rel.lastupdated = $last_updated, + rel._module_name = 'cartography:prowler', + rel._module_version = $prowler_version + ON MATCH SET + rel.lastupdated = $last_updated +""" + +CLEANUP_FINDINGS_TEMPLATE = f""" + MATCH (finding:{PROWLER_FINDING_LABEL} {{provider_uid: $provider_uid}}) + WHERE finding.lastupdated < $last_updated + + WITH finding LIMIT $batch_size + + DETACH DELETE finding + + RETURN COUNT(finding) AS deleted_findings_count +""" + +# Internet queries (used by internet.py) +# --------------------------------------- + +CREATE_INTERNET_NODE = f""" + MERGE (internet:{INTERNET_NODE_LABEL} {{id: 'Internet'}}) + ON CREATE SET + internet.name = 'Internet', + internet.firstseen = timestamp(), + internet.lastupdated = $last_updated, + internet._module_name = 'cartography:prowler', + internet._module_version = $prowler_version + ON MATCH SET + internet.lastupdated = $last_updated +""" + +CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE = f""" + MATCH (account:__ROOT_LABEL__ {{id: $provider_uid}})-->(resource) + WHERE resource.exposed_internet = true + WITH resource + MATCH (internet:{INTERNET_NODE_LABEL} {{id: 'Internet'}}) + MERGE (internet)-[r:CAN_ACCESS]->(resource) + ON CREATE SET + r.firstseen = timestamp(), + r.lastupdated = $last_updated, + r._module_name = 'cartography:prowler', + r._module_version = $prowler_version + ON MATCH SET + r.lastupdated = $last_updated + RETURN COUNT(r) AS relationships_merged +""" + +# Sync queries (used by sync.py) +# ------------------------------- + +NODE_FETCH_QUERY = """ + MATCH (n) + WHERE id(n) > $last_id + RETURN id(n) AS internal_id, + elementId(n) AS element_id, + labels(n) AS labels, + properties(n) AS props + ORDER BY internal_id + LIMIT $batch_size +""" + +RELATIONSHIPS_FETCH_QUERY = """ + MATCH ()-[r]->() + WHERE id(r) > $last_id + RETURN id(r) AS internal_id, + type(r) AS rel_type, + elementId(startNode(r)) AS start_element_id, + elementId(endNode(r)) AS end_element_id, + properties(r) AS props + ORDER BY internal_id + LIMIT $batch_size +""" + +NODE_SYNC_TEMPLATE = """ + UNWIND $rows AS row + MERGE (n:__NODE_LABELS__ {provider_element_id: row.provider_element_id}) + SET n += row.props + SET n.provider_id = $provider_id +""" + +RELATIONSHIP_SYNC_TEMPLATE = f""" + UNWIND $rows AS row + MATCH (s:{PROVIDER_RESOURCE_LABEL} {{provider_element_id: row.start_element_id}}) + MATCH (t:{PROVIDER_RESOURCE_LABEL} {{provider_element_id: row.end_element_id}}) + MERGE (s)-[r:__REL_TYPE__ {{provider_element_id: row.provider_element_id}}]->(t) + SET r += row.props + SET r.provider_id = $provider_id +""" diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py new file mode 100644 index 0000000000..759804b0a4 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -0,0 +1,244 @@ +import logging +import time + +from typing import Any + +from cartography.config import Config as CartographyConfig +from cartography.intel import analysis as cartography_analysis +from cartography.intel import create_indexes as cartography_create_indexes +from cartography.intel import ontology as cartography_ontology +from celery.utils.log import get_task_logger + +from api.attack_paths import database as graph_database +from api.db_utils import rls_transaction +from api.models import ( + Provider as ProwlerAPIProvider, + StateChoices, +) +from api.utils import initialize_prowler_provider +from tasks.jobs.attack_paths import db_utils, findings, internet, sync, utils +from tasks.jobs.attack_paths.config import get_cartography_ingestion_function + +# Without this Celery goes crazy with Cartography logging +logging.getLogger("cartography").setLevel(logging.ERROR) +logging.getLogger("neo4j").propagate = False + +logger = get_task_logger(__name__) + + +def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: + """ + Code based on Cartography, specifically on `cartography.cli.main`, `cartography.cli.CLI.main`, + `cartography.sync.run_with_config` and `cartography.sync.Sync.run`. + """ + ingestion_exceptions = {} # This will hold any exceptions raised during ingestion + + # Prowler necessary objects + with rls_transaction(tenant_id): + prowler_api_provider = ProwlerAPIProvider.objects.get(scan__pk=scan_id) + prowler_sdk_provider = initialize_prowler_provider(prowler_api_provider) + + # Attack Paths Scan necessary objects + cartography_ingestion_function = get_cartography_ingestion_function( + prowler_api_provider.provider + ) + attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id) + + # Checks before starting the scan + if not cartography_ingestion_function: + ingestion_exceptions = { + "global_error": f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans" + } + if attack_paths_scan: + db_utils.finish_attack_paths_scan( + attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions + ) + + logger.warning( + f"Provider {prowler_api_provider.provider} is not supported for Attack Paths scans" + ) + return ingestion_exceptions + + else: + if not attack_paths_scan: + logger.warning( + f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then" + ) + attack_paths_scan = db_utils.create_attack_paths_scan( + tenant_id, scan_id, prowler_api_provider.id + ) + + tmp_database_name = graph_database.get_database_name( + attack_paths_scan.id, temporary=True + ) + tenant_database_name = graph_database.get_database_name( + prowler_api_provider.tenant_id + ) + + # While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object + tmp_cartography_config = CartographyConfig( + neo4j_uri=graph_database.get_uri(), + neo4j_database=tmp_database_name, + update_tag=int(time.time()), + ) + tenant_cartography_config = CartographyConfig( + neo4j_uri=tmp_cartography_config.neo4j_uri, + neo4j_database=tenant_database_name, + update_tag=tmp_cartography_config.update_tag, + ) + + # Starting the Attack Paths scan + db_utils.starting_attack_paths_scan( + attack_paths_scan, task_id, tenant_cartography_config + ) + + try: + logger.info( + f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}" + ) + + graph_database.create_database(tmp_cartography_config.neo4j_database) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 1) + + logger.info( + f"Starting Cartography ({attack_paths_scan.id}) for " + f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}" + ) + with graph_database.get_session( + tmp_cartography_config.neo4j_database + ) as tmp_neo4j_session: + # Indexes creation + cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config) + findings.create_findings_indexes(tmp_neo4j_session) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2) + + # The real scan, where iterates over cloud services + ingestion_exceptions = utils.call_within_event_loop( + cartography_ingestion_function, + tmp_neo4j_session, + tmp_cartography_config, + prowler_api_provider, + prowler_sdk_provider, + attack_paths_scan, + ) + + # Post-processing: Just keeping it to be more Cartography compliant + logger.info( + f"Syncing Cartography ontology for AWS account {prowler_api_provider.uid}" + ) + cartography_ontology.run(tmp_neo4j_session, tmp_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(tmp_neo4j_session, tmp_cartography_config) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 96) + + # Creating Internet node and CAN_ACCESS relationships + logger.info( + f"Creating Internet graph for AWS account {prowler_api_provider.uid}" + ) + internet.analysis( + tmp_neo4j_session, prowler_api_provider, tmp_cartography_config + ) + + # Adding Prowler Finding nodes and relationships + logger.info( + f"Syncing Prowler analysis for AWS account {prowler_api_provider.uid}" + ) + findings.analysis( + tmp_neo4j_session, prowler_api_provider, scan_id, tmp_cartography_config + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 97) + + logger.info( + f"Clearing Neo4j cache for database {tmp_cartography_config.neo4j_database}" + ) + graph_database.clear_cache(tmp_cartography_config.neo4j_database) + + logger.info( + f"Ensuring tenant database {tenant_database_name}, and its indexes, exists for tenant {prowler_api_provider.tenant_id}" + ) + graph_database.create_database(tenant_database_name) + with graph_database.get_session(tenant_database_name) as tenant_neo4j_session: + cartography_create_indexes.run( + tenant_neo4j_session, tenant_cartography_config + ) + findings.create_findings_indexes(tenant_neo4j_session) + sync.create_sync_indexes(tenant_neo4j_session) + + logger.info(f"Deleting existing provider graph in {tenant_database_name}") + graph_database.drop_subgraph( + database=tenant_database_name, + provider_id=str(prowler_api_provider.id), + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98) + + logger.info( + f"Syncing graph from {tmp_database_name} into {tenant_database_name}" + ) + sync.sync_graph( + source_database=tmp_database_name, + target_database=tenant_database_name, + provider_id=str(prowler_api_provider.id), + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99) + + logger.info(f"Clearing Neo4j cache for database {tenant_database_name}") + graph_database.clear_cache(tenant_database_name) + + logger.info( + f"Completed Cartography ({attack_paths_scan.id}) for " + f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}" + ) + + # TODO + # This piece of code delete old Neo4j databases for this tenant's provider + # When we clean all of these databases we need to: + # - Delete this block + # - Delete function from `db_utils` the functions get_old_attack_paths_scans` & `update_old_attack_paths_scan` + # - Remove `graph_database` & `is_graph_database_deleted` from the AttackPathsScan model: + # - Check indexes + # - Create migration + # - The use of `attack_paths_scan.graph_database` on `views` and `views_helpers` + # - Tests + old_attack_paths_scans = db_utils.get_old_attack_paths_scans( + prowler_api_provider.tenant_id, + prowler_api_provider.id, + attack_paths_scan.id, + ) + for old_attack_paths_scan in old_attack_paths_scans: + old_graph_database = old_attack_paths_scan.graph_database + if old_graph_database and old_graph_database != tenant_database_name: + logger.info( + f"Dropping old Neo4j database {old_graph_database} for provider {prowler_api_provider.id}" + ) + graph_database.drop_database(old_graph_database) + db_utils.update_old_attack_paths_scan(old_attack_paths_scan) + + logger.info(f"Dropping temporary Neo4j database {tmp_database_name}") + graph_database.drop_database(tmp_database_name) + + db_utils.finish_attack_paths_scan( + attack_paths_scan, StateChoices.COMPLETED, ingestion_exceptions + ) + return ingestion_exceptions + + except Exception as e: + exception_message = utils.stringify_exception(e, "Cartography failed") + logger.error(exception_message) + ingestion_exceptions["global_error"] = exception_message + + # Handling databases changes + try: + graph_database.drop_database(tmp_cartography_config.neo4j_database) + except Exception: + logger.exception( + f"Failed to drop temporary Neo4j database {tmp_cartography_config.neo4j_database} during cleanup" + ) + + db_utils.finish_attack_paths_scan( + attack_paths_scan, StateChoices.FAILED, ingestion_exceptions + ) + raise diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py new file mode 100644 index 0000000000..2b525cbf00 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -0,0 +1,202 @@ +""" +Graph sync operations for Attack Paths. + +This module handles syncing graph data from temporary scan databases +to the tenant database, adding provider isolation labels and properties. +""" + +from collections import defaultdict +from typing import Any + +from celery.utils.log import get_task_logger + +from api.attack_paths import database as graph_database +from tasks.jobs.attack_paths.config import BATCH_SIZE, PROVIDER_RESOURCE_LABEL +from tasks.jobs.attack_paths.indexes import IndexType, create_indexes +from tasks.jobs.attack_paths.queries import ( + NODE_FETCH_QUERY, + NODE_SYNC_TEMPLATE, + RELATIONSHIP_SYNC_TEMPLATE, + RELATIONSHIPS_FETCH_QUERY, + render_cypher_template, +) + +logger = get_task_logger(__name__) + + +def create_sync_indexes(neo4j_session) -> None: + """Create indexes for provider resource sync operations.""" + create_indexes(neo4j_session, IndexType.SYNC) + + +def sync_graph( + source_database: str, + target_database: str, + provider_id: str, +) -> dict[str, int]: + """ + Sync all nodes and relationships from source to target database. + + Args: + `source_database`: The temporary scan database + `target_database`: The tenant database + `provider_id`: The provider ID for isolation + + Returns: + Dict with counts of synced nodes and relationships + """ + nodes_synced = sync_nodes( + source_database, + target_database, + provider_id, + ) + relationships_synced = sync_relationships( + source_database, + target_database, + provider_id, + ) + + return { + "nodes": nodes_synced, + "relationships": relationships_synced, + } + + +def sync_nodes( + source_database: str, + target_database: str, + provider_id: str, +) -> int: + """ + Sync nodes from source to target database. + + Adds `ProviderResource` label and `provider_id` property to all nodes. + """ + last_id = -1 + total_synced = 0 + + with ( + graph_database.get_session(source_database) as source_session, + graph_database.get_session(target_database) as target_session, + ): + while True: + rows = list( + source_session.run( + NODE_FETCH_QUERY, + {"last_id": last_id, "batch_size": BATCH_SIZE}, + ) + ) + + if not rows: + break + + last_id = rows[-1]["internal_id"] + + grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) + for row in rows: + labels = tuple(sorted(set(row["labels"] or []))) + props = dict(row["props"] or {}) + _strip_internal_properties(props) + provider_element_id = f"{provider_id}:{row['element_id']}" + grouped[labels].append( + { + "provider_element_id": provider_element_id, + "props": props, + } + ) + + for labels, batch in grouped.items(): + label_set = set(labels) + label_set.add(PROVIDER_RESOURCE_LABEL) + node_labels = ":".join(f"`{label}`" for label in sorted(label_set)) + + query = render_cypher_template( + NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels} + ) + target_session.run( + query, + { + "rows": batch, + "provider_id": provider_id, + }, + ) + + total_synced += len(rows) + logger.info( + f"Synced {total_synced} nodes from {source_database} to {target_database}" + ) + + return total_synced + + +def sync_relationships( + source_database: str, + target_database: str, + provider_id: str, +) -> int: + """ + Sync relationships from source to target database. + + Adds `provider_id` property to all relationships. + """ + last_id = -1 + total_synced = 0 + + with ( + graph_database.get_session(source_database) as source_session, + graph_database.get_session(target_database) as target_session, + ): + while True: + rows = list( + source_session.run( + RELATIONSHIPS_FETCH_QUERY, + {"last_id": last_id, "batch_size": BATCH_SIZE}, + ) + ) + + if not rows: + break + + last_id = rows[-1]["internal_id"] + + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + props = dict(row["props"] or {}) + _strip_internal_properties(props) + rel_type = row["rel_type"] + grouped[rel_type].append( + { + "start_element_id": f"{provider_id}:{row['start_element_id']}", + "end_element_id": f"{provider_id}:{row['end_element_id']}", + "provider_element_id": f"{provider_id}:{rel_type}:{row['internal_id']}", + "props": props, + } + ) + + for rel_type, batch in grouped.items(): + query = render_cypher_template( + RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type} + ) + target_session.run( + query, + { + "rows": batch, + "provider_id": provider_id, + }, + ) + + total_synced += len(rows) + logger.info( + f"Synced {total_synced} relationships from {source_database} to {target_database}" + ) + + return total_synced + + +def _strip_internal_properties(props: dict[str, Any]) -> None: + """Remove internal properties that shouldn't be copied during sync.""" + for key in [ + "provider_element_id", + "provider_id", + ]: + props.pop(key, None) diff --git a/api/src/backend/tasks/jobs/attack_paths/utils.py b/api/src/backend/tasks/jobs/attack_paths/utils.py new file mode 100644 index 0000000000..eef5670782 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/utils.py @@ -0,0 +1,40 @@ +import asyncio +import traceback + +from datetime import datetime, timezone + +from celery.utils.log import get_task_logger + +logger = get_task_logger(__name__) + + +def stringify_exception(exception: Exception, context: str) -> str: + """Format an exception with timestamp and traceback for logging.""" + timestamp = datetime.now(tz=timezone.utc) + exception_traceback = traceback.TracebackException.from_exception(exception) + traceback_string = "".join(exception_traceback.format()) + return f"{timestamp} - {context}\n{traceback_string}" + + +def call_within_event_loop(fn, *args, **kwargs): + """ + Execute a function within a new event loop. + + Cartography needs a running event loop, so assuming there is none + (Celery task or even regular DRF endpoint), this creates a new one + and sets it as the current event loop for this thread. + """ + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return fn(*args, **kwargs) + + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + + except Exception as e: + logger.warning(f"Failed to shutdown async generators cleanly: {e}") + + loop.close() + asyncio.set_event_loop(None) diff --git a/api/src/backend/tasks/jobs/backfill.py b/api/src/backend/tasks/jobs/backfill.py index 6c83e6a69b..d9985afafb 100644 --- a/api/src/backend/tasks/jobs/backfill.py +++ b/api/src/backend/tasks/jobs/backfill.py @@ -1,15 +1,43 @@ -from api.db_utils import rls_transaction +from collections import defaultdict +from datetime import timedelta + +from celery.utils.log import get_task_logger +from django.db.models import OuterRef, Subquery, Sum +from django.utils import timezone +from tasks.jobs.queries import ( + COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, + COMPLIANCE_UPSERT_TENANT_SUMMARY_ALL_SQL, +) +from tasks.jobs.scan import aggregate_category_counts, aggregate_resource_group_counts + +from api.db_router import READ_REPLICA_ALIAS, MainRouter +from api.db_utils import ( + POSTGRES_TENANT_VAR, + SET_CONFIG_QUERY, + psycopg_connection, + rls_transaction, +) from api.models import ( + ComplianceOverviewSummary, + ComplianceRequirementOverview, + DailySeveritySummary, + Finding, + ProviderComplianceScore, Resource, ResourceFindingMapping, ResourceScanSummary, Scan, + ScanCategorySummary, + ScanGroupSummary, + ScanSummary, StateChoices, ) +logger = get_task_logger(__name__) + def backfill_resource_scan_summaries(tenant_id: str, scan_id: str): - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): if ResourceScanSummary.objects.filter( tenant_id=tenant_id, scan_id=scan_id ).exists(): @@ -59,3 +87,468 @@ def backfill_resource_scan_summaries(tenant_id: str, scan_id: str): ) return {"status": "backfilled", "inserted": len(summaries)} + + +def backfill_compliance_summaries(tenant_id: str, scan_id: str): + """ + Backfill ComplianceOverviewSummary records for a completed scan. + + This function checks if summary records already exist for the scan. + If not, it aggregates compliance requirement data and creates the summaries. + + Args: + tenant_id: Target tenant UUID + scan_id: Scan UUID to backfill + + Returns: + dict: Status indicating whether backfill was performed + """ + with rls_transaction(tenant_id): + if ComplianceOverviewSummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).exists(): + return {"status": "already backfilled"} + + with rls_transaction(tenant_id): + if not Scan.objects.filter( + tenant_id=tenant_id, + id=scan_id, + state__in=(StateChoices.COMPLETED, StateChoices.FAILED), + ).exists(): + return {"status": "scan is not completed"} + + # Fetch all compliance requirement overview rows for this scan + requirement_rows = ComplianceRequirementOverview.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).values( + "compliance_id", + "requirement_id", + "requirement_status", + ) + + if not requirement_rows: + return {"status": "no compliance data to backfill"} + + # Group by (compliance_id, requirement_id) across regions + requirement_statuses = defaultdict( + lambda: {"fail_count": 0, "pass_count": 0, "total_count": 0} + ) + + for row in requirement_rows: + compliance_id = row["compliance_id"] + requirement_id = row["requirement_id"] + requirement_status = row["requirement_status"] + + # Aggregate requirement status across regions + key = (compliance_id, requirement_id) + requirement_statuses[key]["total_count"] += 1 + + if requirement_status == "FAIL": + requirement_statuses[key]["fail_count"] += 1 + elif requirement_status == "PASS": + requirement_statuses[key]["pass_count"] += 1 + + # Determine per-requirement status and aggregate to compliance level + compliance_summaries = defaultdict( + lambda: { + "total_requirements": 0, + "requirements_passed": 0, + "requirements_failed": 0, + "requirements_manual": 0, + } + ) + + for (compliance_id, requirement_id), counts in requirement_statuses.items(): + # Apply business rule: any FAIL → requirement fails + if counts["fail_count"] > 0: + req_status = "FAIL" + elif counts["pass_count"] == counts["total_count"]: + req_status = "PASS" + else: + req_status = "MANUAL" + + # Aggregate to compliance level + compliance_summaries[compliance_id]["total_requirements"] += 1 + if req_status == "PASS": + compliance_summaries[compliance_id]["requirements_passed"] += 1 + elif req_status == "FAIL": + compliance_summaries[compliance_id]["requirements_failed"] += 1 + else: + compliance_summaries[compliance_id]["requirements_manual"] += 1 + + # Create summary objects + summary_objects = [] + for compliance_id, data in compliance_summaries.items(): + summary_objects.append( + ComplianceOverviewSummary( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + requirements_passed=data["requirements_passed"], + requirements_failed=data["requirements_failed"], + requirements_manual=data["requirements_manual"], + total_requirements=data["total_requirements"], + ) + ) + + # Bulk insert summaries + if summary_objects: + ComplianceOverviewSummary.objects.bulk_create( + summary_objects, batch_size=500, ignore_conflicts=True + ) + + return {"status": "backfilled", "inserted": len(summary_objects)} + + +def backfill_daily_severity_summaries(tenant_id: str, days: int = None): + """ + Backfill DailySeveritySummary from completed scans. + Groups by provider+date, keeps latest scan per day. + """ + created_count = 0 + updated_count = 0 + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + scan_filter = { + "tenant_id": tenant_id, + "state": StateChoices.COMPLETED, + "completed_at__isnull": False, + } + + if days is not None: + cutoff_date = timezone.now() - timedelta(days=days) + scan_filter["completed_at__gte"] = cutoff_date + + completed_scans = ( + Scan.objects.filter(**scan_filter) + .order_by("provider_id", "-completed_at") + .values("id", "provider_id", "completed_at") + ) + + if not completed_scans: + return {"status": "no scans to backfill"} + + # Keep only latest scan per provider/day + latest_scans_by_day = {} + for scan in completed_scans: + key = (scan["provider_id"], scan["completed_at"].date()) + if key not in latest_scans_by_day: + latest_scans_by_day[key] = scan + + # Process each provider/day + for (provider_id, scan_date), scan in latest_scans_by_day.items(): + scan_id = scan["id"] + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + severity_totals = ( + ScanSummary.objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + ) + .values("severity") + .annotate(total_fail=Sum("fail"), total_muted=Sum("muted")) + ) + + severity_data = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "informational": 0, + "muted": 0, + } + + for row in severity_totals: + severity = row["severity"] + if severity in severity_data: + severity_data[severity] = row["total_fail"] or 0 + severity_data["muted"] += row["total_muted"] or 0 + + with rls_transaction(tenant_id): + _, created = DailySeveritySummary.objects.update_or_create( + tenant_id=tenant_id, + provider_id=provider_id, + date=scan_date, + defaults={ + "scan_id": scan_id, + "critical": severity_data["critical"], + "high": severity_data["high"], + "medium": severity_data["medium"], + "low": severity_data["low"], + "informational": severity_data["informational"], + "muted": severity_data["muted"], + }, + ) + + if created: + created_count += 1 + else: + updated_count += 1 + + return { + "status": "backfilled", + "created": created_count, + "updated": updated_count, + "total_days": len(latest_scans_by_day), + } + + +def backfill_scan_category_summaries(tenant_id: str, scan_id: str): + """ + Backfill ScanCategorySummary for a completed scan. + + Aggregates category counts from all findings in the scan and creates + one ScanCategorySummary row per (category, severity) combination. + + Args: + tenant_id: Target tenant UUID + scan_id: Scan UUID to backfill + + Returns: + dict: Status indicating whether backfill was performed + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + if ScanCategorySummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).exists(): + return {"status": "already backfilled"} + + if not Scan.objects.filter( + tenant_id=tenant_id, + id=scan_id, + state__in=(StateChoices.COMPLETED, StateChoices.FAILED), + ).exists(): + return {"status": "scan is not completed"} + + category_counts: dict[tuple[str, str], dict[str, int]] = {} + for finding in Finding.all_objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).values("categories", "severity", "status", "delta", "muted"): + aggregate_category_counts( + categories=finding.get("categories") or [], + severity=finding.get("severity"), + status=finding.get("status"), + delta=finding.get("delta"), + muted=finding.get("muted", False), + cache=category_counts, + ) + + if not category_counts: + return {"status": "no categories to backfill"} + + category_summaries = [ + ScanCategorySummary( + tenant_id=tenant_id, + scan_id=scan_id, + category=category, + severity=severity, + total_findings=counts["total"], + failed_findings=counts["failed"], + new_failed_findings=counts["new_failed"], + ) + for (category, severity), counts in category_counts.items() + ] + + with rls_transaction(tenant_id): + ScanCategorySummary.objects.bulk_create( + category_summaries, batch_size=500, ignore_conflicts=True + ) + + return {"status": "backfilled", "categories_count": len(category_counts)} + + +def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str): + """ + Backfill ScanGroupSummary for a completed scan. + + Aggregates resource group counts from all findings in the scan and creates + one ScanGroupSummary row per (resource_group, severity) combination. + + Args: + tenant_id: Target tenant UUID + scan_id: Scan UUID to backfill + + Returns: + dict: Status indicating whether backfill was performed + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + if ScanGroupSummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ).exists(): + return {"status": "already backfilled"} + + if not Scan.objects.filter( + tenant_id=tenant_id, + id=scan_id, + state__in=(StateChoices.COMPLETED, StateChoices.FAILED), + ).exists(): + return {"status": "scan is not completed"} + + resource_group_counts: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + # Get findings with their first resource UID via annotation + resource_uid_subquery = ResourceFindingMapping.objects.filter( + finding_id=OuterRef("id"), tenant_id=tenant_id + ).values("resource__uid")[:1] + + for finding in ( + Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id) + .annotate(resource_uid=Subquery(resource_uid_subquery)) + .values( + "resource_groups", + "severity", + "status", + "delta", + "muted", + "resource_uid", + ) + ): + aggregate_resource_group_counts( + resource_group=finding.get("resource_groups"), + severity=finding.get("severity"), + status=finding.get("status"), + delta=finding.get("delta"), + muted=finding.get("muted", False), + resource_uid=finding.get("resource_uid") or "", + cache=resource_group_counts, + group_resources_cache=group_resources_cache, + ) + + if not resource_group_counts: + return {"status": "no resource groups to backfill"} + + # Compute group-level resource counts (same value for all severity rows in a group) + group_resource_counts = { + grp: len(uids) for grp, uids in group_resources_cache.items() + } + resource_group_summaries = [ + ScanGroupSummary( + tenant_id=tenant_id, + scan_id=scan_id, + resource_group=grp, + severity=severity, + total_findings=counts["total"], + failed_findings=counts["failed"], + new_failed_findings=counts["new_failed"], + resources_count=group_resource_counts.get(grp, 0), + ) + for (grp, severity), counts in resource_group_counts.items() + ] + + with rls_transaction(tenant_id): + ScanGroupSummary.objects.bulk_create( + resource_group_summaries, batch_size=500, ignore_conflicts=True + ) + + return {"status": "backfilled", "resource_groups_count": len(resource_group_counts)} + + +def backfill_provider_compliance_scores(tenant_id: str) -> dict: + """ + Backfill ProviderComplianceScore from latest completed scan per provider. + + For each provider with completed scans, finds the most recent scan and + upserts compliance requirement statuses with FAIL-dominant aggregation. + + Args: + tenant_id: Target tenant UUID + + Returns: + dict: Statistics about the backfill operation + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + completed_scans = Scan.all_objects.filter( + tenant_id=tenant_id, + state=StateChoices.COMPLETED, + completed_at__isnull=False, + ) + if not completed_scans.exists(): + return {"status": "no completed scans"} + + existing_providers = set( + ProviderComplianceScore.objects.filter(tenant_id=tenant_id) + .values_list("provider_id", flat=True) + .distinct() + ) + + if existing_providers: + completed_scans = completed_scans.exclude( + provider_id__in=existing_providers + ) + + scan_info = list( + completed_scans.order_by("provider_id", "-completed_at") + .distinct("provider_id") + .values("id", "provider_id", "completed_at") + ) + + if not scan_info: + return {"status": "no scans to process"} + + total_upserted = 0 + providers_processed = 0 + providers_skipped = 0 + + for scan in scan_info: + provider_id = scan["provider_id"] + + scan_id = scan["id"] + + try: + with psycopg_connection(MainRouter.default_db) as connection: + connection.autocommit = False + try: + with connection.cursor() as cursor: + cursor.execute( + SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id] + ) + cursor.execute( + COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, + [tenant_id, str(scan_id)], + ) + upserted = cursor.rowcount + connection.commit() + total_upserted += upserted + providers_processed += 1 + except Exception: + connection.rollback() + raise + except Exception as e: + providers_skipped += 1 + logger.exception( + "Error backfilling provider %s for tenant %s: %s", + provider_id, + tenant_id, + e, + ) + + # Recalculate tenant summary after all providers are backfilled + if providers_processed > 0: + with psycopg_connection(MainRouter.default_db) as connection: + connection.autocommit = False + try: + with connection.cursor() as cursor: + cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id]) + # Advisory lock to prevent race conditions + cursor.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s))", [tenant_id] + ) + cursor.execute( + COMPLIANCE_UPSERT_TENANT_SUMMARY_ALL_SQL, + [tenant_id, tenant_id], + ) + tenant_summary_count = cursor.rowcount + connection.commit() + except Exception: + connection.rollback() + raise + else: + tenant_summary_count = 0 + + return { + "status": "backfilled", + "providers_processed": providers_processed, + "providers_skipped": providers_skipped, + "total_upserted": total_upserted, + "tenant_summary_count": tenant_summary_count, + } diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index d72b8de40e..ba59eaeb5f 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -1,9 +1,18 @@ from celery.utils.log import get_task_logger from django.db import DatabaseError +from api.attack_paths import database as graph_database from api.db_router import MainRouter from api.db_utils import batch_delete, rls_transaction -from api.models import Finding, Provider, Resource, Scan, ScanSummary, Tenant +from api.models import ( + AttackPathsScan, + Finding, + Provider, + Resource, + Scan, + ScanSummary, + Tenant, +) logger = get_task_logger(__name__) @@ -23,16 +32,27 @@ def delete_provider(tenant_id: str, pk: str): Raises: Provider.DoesNotExist: If no instance with the provided primary key exists. """ + # Delete the Attack Paths' graph data related to the provider + tenant_database_name = graph_database.get_database_name(tenant_id) + try: + graph_database.drop_subgraph(tenant_database_name, str(pk)) + + except graph_database.GraphDatabaseQueryException as gdb_error: + logger.error(f"Error deleting Provider graph data: {gdb_error}") + raise + + # Get all provider related data and delete them in batches with rls_transaction(tenant_id): instance = Provider.all_objects.get(pk=pk) - deletion_summary = {} deletion_steps = [ ("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)), ("Findings", Finding.all_objects.filter(scan__provider=instance)), ("Resources", Resource.all_objects.filter(provider=instance)), ("Scans", Scan.all_objects.filter(provider=instance)), + ("AttackPathsScans", AttackPathsScan.all_objects.filter(provider=instance)), ] + deletion_summary = {} for step_name, queryset in deletion_steps: try: _, step_summary = batch_delete(tenant_id, queryset) @@ -48,6 +68,7 @@ def delete_provider(tenant_id: str, pk: str): except DatabaseError as db_error: logger.error(f"Error deleting Provider: {db_error}") raise + return deletion_summary @@ -68,6 +89,13 @@ def delete_tenant(pk: str): summary = delete_provider(pk, provider.id) deletion_summary.update(summary) + try: + tenant_database_name = graph_database.get_database_name(pk) + graph_database.drop_database(tenant_database_name) + except graph_database.GraphDatabaseQueryException as gdb_error: + logger.error(f"Error dropping Tenant graph database: {gdb_error}") + raise + Tenant.objects.using(MainRouter.admin_db).filter(id=pk).delete() return deletion_summary diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 8736a2b022..c2a947d249 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -15,6 +15,7 @@ from prowler.config.config import ( html_file_suffix, json_asff_file_suffix, json_ocsf_file_suffix, + set_output_timestamp, ) from prowler.lib.outputs.asff.asff import ASFF from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected import ( @@ -22,16 +23,23 @@ from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected im ) from prowler.lib.outputs.compliance.c5.c5_aws import AWSC5 from prowler.lib.outputs.compliance.c5.c5_azure import AzureC5 +from prowler.lib.outputs.compliance.c5.c5_gcp import GCPC5 from prowler.lib.outputs.compliance.ccc.ccc_aws import CCC_AWS from prowler.lib.outputs.compliance.ccc.ccc_azure import CCC_Azure from prowler.lib.outputs.compliance.ccc.ccc_gcp import CCC_GCP +from prowler.lib.outputs.compliance.cis.cis_alibabacloud import AlibabaCloudCIS from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS -from prowler.lib.outputs.compliance.cis.cis_oci import OCICIS +from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS +from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA +from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA +from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA +from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA +from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS @@ -48,6 +56,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( + ProwlerThreatScoreAlibaba, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_aws import ( ProwlerThreatScoreAWS, ) @@ -57,6 +68,9 @@ from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_azur from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_gcp import ( ProwlerThreatScoreGCP, ) +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_kubernetes import ( + ProwlerThreatScoreKubernetes, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365 import ( ProwlerThreatScoreM365, ) @@ -81,6 +95,7 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS), (lambda name: name == "ccc_aws", CCC_AWS), (lambda name: name.startswith("c5_"), AWSC5), + (lambda name: name.startswith("csa_"), AWSCSA), ], "azure": [ (lambda name: name.startswith("cis_"), AzureCIS), @@ -90,6 +105,7 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "ccc_azure", CCC_Azure), (lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure), (lambda name: name == "c5_azure", AzureC5), + (lambda name: name.startswith("csa_"), AzureCSA), ], "gcp": [ (lambda name: name.startswith("cis_"), GCPCIS), @@ -98,10 +114,16 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name.startswith("iso27001_"), GCPISO27001), (lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP), (lambda name: name == "ccc_gcp", CCC_GCP), + (lambda name: name == "c5_gcp", GCPC5), + (lambda name: name.startswith("csa_"), GCPCSA), ], "kubernetes": [ (lambda name: name.startswith("cis_"), KubernetesCIS), (lambda name: name.startswith("iso27001_"), KubernetesISO27001), + ( + lambda name: name == "prowler_threatscore_kubernetes", + ProwlerThreatScoreKubernetes, + ), ], "m365": [ (lambda name: name.startswith("cis_"), M365CIS), @@ -111,8 +133,21 @@ COMPLIANCE_CLASS_MAP = { "github": [ (lambda name: name.startswith("cis_"), GithubCIS), ], - "oci": [ - (lambda name: name.startswith("cis_"), OCICIS), + "iac": [ + # IaC provider doesn't have specific compliance frameworks yet + # Trivy handles its own compliance checks + ], + "oraclecloud": [ + (lambda name: name.startswith("cis_"), OracleCloudCIS), + (lambda name: name.startswith("csa_"), OracleCloudCSA), + ], + "alibabacloud": [ + (lambda name: name.startswith("cis_"), AlibabaCloudCIS), + (lambda name: name.startswith("csa_"), AlibabaCloudCSA), + ( + lambda name: name == "prowler_threatscore_alibabacloud", + ProwlerThreatScoreAlibaba, + ), ], } @@ -228,36 +263,33 @@ def _upload_to_s3( logger.error(f"S3 upload failed: {str(e)}") -def _generate_output_directory( - output_directory, prowler_provider: object, tenant_id: str, scan_id: str -) -> tuple[str, str, str]: +def _build_output_path( + output_directory: str, + prowler_provider: str, + tenant_id: str, + scan_id: str, + subdirectory: str = None, +) -> str: """ - Generate a file system path for the output directory of a prowler scan. - - This function constructs the output directory path by combining a base - temporary output directory, the tenant ID, the scan ID, and details about - the prowler provider along with a timestamp. The resulting path is used to - store the output files of a prowler scan. - - Note: - This function depends on one external variable: - - `output_file_timestamp`: A timestamp (as a string) used to uniquely identify the output. + Build a file system path for the output directory of a prowler scan. Args: output_directory (str): The base output directory. - prowler_provider (object): An identifier or descriptor for the prowler provider. - Typically, this is a string indicating the provider (e.g., "aws"). + prowler_provider (str): An identifier or descriptor for the prowler provider. + Typically, this is a string indicating the provider (e.g., "aws"). tenant_id (str): The unique identifier for the tenant. scan_id (str): The unique identifier for the scan. + subdirectory (str, optional): Optional subdirectory to include in the path + (e.g., "compliance", "threatscore", "ens"). Returns: - str: The constructed file system path for the prowler scan output directory. + str: The constructed path with directory created. Example: - >>> _generate_output_directory("/tmp", "aws", "tenant-1234", "scan-5678") - '/tmp/tenant-1234/aws/scan-5678/prowler-output-2023-02-15T12:34:56', - '/tmp/tenant-1234/aws/scan-5678/compliance/prowler-output-2023-02-15T12:34:56' - '/tmp/tenant-1234/aws/scan-5678/threatscore/prowler-output-2023-02-15T12:34:56' + >>> _build_output_path("/tmp", "aws", "tenant-1234", "scan-5678") + '/tmp/tenant-1234/scan-5678/prowler-output-aws-20230215123456' + >>> _build_output_path("/tmp", "aws", "tenant-1234", "scan-5678", "threatscore") + '/tmp/tenant-1234/scan-5678/threatscore/prowler-output-aws-20230215123456' """ # Sanitize the prowler provider name to ensure it is a valid directory name prowler_provider_sanitized = re.sub(r"[^\w\-]", "-", prowler_provider) @@ -265,23 +297,107 @@ def _generate_output_directory( with rls_transaction(tenant_id): started_at = Scan.objects.get(id=scan_id).started_at + set_output_timestamp(started_at) + timestamp = started_at.strftime("%Y%m%d%H%M%S") - path = ( - f"{output_directory}/{tenant_id}/{scan_id}/prowler-output-" - f"{prowler_provider_sanitized}-{timestamp}" - ) + + if subdirectory: + path = ( + f"{output_directory}/{tenant_id}/{scan_id}/{subdirectory}/prowler-output-" + f"{prowler_provider_sanitized}-{timestamp}" + ) + else: + path = ( + f"{output_directory}/{tenant_id}/{scan_id}/prowler-output-" + f"{prowler_provider_sanitized}-{timestamp}" + ) + + # Create directory for the path if it doesn't exist os.makedirs("/".join(path.split("/")[:-1]), exist_ok=True) - compliance_path = ( - f"{output_directory}/{tenant_id}/{scan_id}/compliance/prowler-output-" - f"{prowler_provider_sanitized}-{timestamp}" - ) - os.makedirs("/".join(compliance_path.split("/")[:-1]), exist_ok=True) + return path - threatscore_path = ( - f"{output_directory}/{tenant_id}/{scan_id}/threatscore/prowler-output-" - f"{prowler_provider_sanitized}-{timestamp}" - ) - os.makedirs("/".join(threatscore_path.split("/")[:-1]), exist_ok=True) - return path, compliance_path, threatscore_path +def _generate_compliance_output_directory( + output_directory: str, + prowler_provider: str, + tenant_id: str, + scan_id: str, + compliance_framework: str, +) -> str: + """ + Generate a file system path for a compliance framework output directory. + + This function constructs the output directory path specifically for a compliance + framework (e.g., "threatscore", "ens") by combining a base temporary output directory, + the tenant ID, the scan ID, the compliance framework name, and details about the + prowler provider along with a timestamp. + + Args: + output_directory (str): The base output directory. + prowler_provider (str): An identifier or descriptor for the prowler provider. + Typically, this is a string indicating the provider (e.g., "aws"). + tenant_id (str): The unique identifier for the tenant. + scan_id (str): The unique identifier for the scan. + compliance_framework (str): The compliance framework name (e.g., "threatscore", "ens"). + + Returns: + str: The path for the compliance framework output directory. + + Example: + >>> _generate_compliance_output_directory("/tmp", "aws", "tenant-1234", "scan-5678", "threatscore") + '/tmp/tenant-1234/scan-5678/threatscore/prowler-output-aws-20230215123456' + >>> _generate_compliance_output_directory("/tmp", "aws", "tenant-1234", "scan-5678", "ens") + '/tmp/tenant-1234/scan-5678/ens/prowler-output-aws-20230215123456' + >>> _generate_compliance_output_directory("/tmp", "aws", "tenant-1234", "scan-5678", "nis2") + '/tmp/tenant-1234/scan-5678/nis2/prowler-output-aws-20230215123456' + """ + return _build_output_path( + output_directory, + prowler_provider, + tenant_id, + scan_id, + subdirectory=compliance_framework, + ) + + +def _generate_output_directory( + output_directory: str, + prowler_provider: str, + tenant_id: str, + scan_id: str, +) -> tuple[str, str]: + """ + Generate file system paths for the standard and compliance output directories of a prowler scan. + + This function constructs both the standard output directory path and the compliance + output directory path by combining a base temporary output directory, the tenant ID, + the scan ID, and details about the prowler provider along with a timestamp. + + Args: + output_directory (str): The base output directory. + prowler_provider (str): An identifier or descriptor for the prowler provider. + Typically, this is a string indicating the provider (e.g., "aws"). + tenant_id (str): The unique identifier for the tenant. + scan_id (str): The unique identifier for the scan. + + Returns: + tuple[str, str]: A tuple containing (standard_path, compliance_path). + + Example: + >>> _generate_output_directory("/tmp", "aws", "tenant-1234", "scan-5678") + ('/tmp/tenant-1234/scan-5678/prowler-output-aws-20230215123456', + '/tmp/tenant-1234/scan-5678/compliance/prowler-output-aws-20230215123456') + """ + standard_path = _build_output_path( + output_directory, prowler_provider, tenant_id, scan_id + ) + compliance_path = _build_output_path( + output_directory, + prowler_provider, + tenant_id, + scan_id, + subdirectory="compliance", + ) + + return standard_path, compliance_path diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 4de4d89bb7..cd76762a40 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -5,7 +5,7 @@ from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE from tasks.utils import batched -from api.db_router import READ_REPLICA_ALIAS +from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import rls_transaction from api.models import Finding, Integration, Provider from api.utils import initialize_prowler_integration, initialize_prowler_provider @@ -19,6 +19,9 @@ 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.common.models import Connection +from prowler.providers.aws.lib.security_hub.exceptions.exceptions import ( + SecurityHubNoEnabledRegionsError, +) logger = get_task_logger(__name__) @@ -179,7 +182,7 @@ def get_security_hub_client_from_integration( if the connection was successful and the SecurityHub client or connection object. """ # Get the provider associated with this integration - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): provider_relationship = integration.integrationproviderrelationship_set.first() if not provider_relationship: return Connection( @@ -208,7 +211,7 @@ def get_security_hub_client_from_integration( regions_status[region] = region in connection.enabled_regions # Save regions information in the integration configuration - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=MainRouter.default_db): integration.configuration["regions"] = regions_status integration.save() @@ -222,8 +225,9 @@ def get_security_hub_client_from_integration( ) return True, security_hub else: - # Reset regions information if connection fails - with rls_transaction(tenant_id): + # Reset regions information if connection fails and integration is not connected + with rls_transaction(tenant_id, using=MainRouter.default_db): + integration.connected = False integration.configuration["regions"] = {} integration.save() @@ -330,12 +334,18 @@ def upload_security_hub_integration( ) if not connected: - logger.error( - f"Security Hub connection failed for integration {integration.id}: " - f"{security_hub.error}" - ) - integration.connected = False - integration.save() + if isinstance( + security_hub.error, + SecurityHubNoEnabledRegionsError, + ): + logger.warning( + f"Security Hub integration {integration.id} has no enabled regions" + ) + else: + logger.error( + f"Security Hub connection failed for integration {integration.id}: " + f"{security_hub.error}" + ) break # Skip this integration security_hub_client = security_hub @@ -406,22 +416,16 @@ def upload_security_hub_integration( logger.warning( f"Failed to archive previous findings: {str(archive_error)}" ) - except Exception as e: logger.error( f"Security Hub integration {integration.id} failed: {str(e)}" ) - continue result = integration_executions == len(integrations) if result: logger.info( f"All Security Hub integrations completed successfully for provider {provider_id}" ) - else: - logger.error( - f"Some Security Hub integrations failed for provider {provider_id}" - ) return result diff --git a/api/src/backend/tasks/jobs/lighthouse_providers.py b/api/src/backend/tasks/jobs/lighthouse_providers.py index 1a68a5c1e2..29e36e5e57 100644 --- a/api/src/backend/tasks/jobs/lighthouse_providers.py +++ b/api/src/backend/tasks/jobs/lighthouse_providers.py @@ -1,12 +1,84 @@ -from typing import Dict, Set +from typing import Dict +import boto3 import openai +from botocore import UNSIGNED +from botocore.config import Config +from botocore.exceptions import BotoCoreError, ClientError from celery.utils.log import get_task_logger from api.models import LighthouseProviderConfiguration, LighthouseProviderModels logger = get_task_logger(__name__) +# OpenAI model prefixes to exclude from Lighthouse model selection. +# These models don't support text chat completions and tool calling. +EXCLUDED_OPENAI_MODEL_PREFIXES = ( + "dall-e", # Image generation + "whisper", # Audio transcription + "tts-", # Text-to-speech (tts-1, tts-1-hd, etc.) + "sora", # Text-to-video (sora-2, sora-2-pro, etc.) + "text-embedding", # Embeddings + "embedding", # Embeddings (alternative naming) + "text-moderation", # Content moderation + "omni-moderation", # Content moderation + "text-davinci", # Legacy completion models + "text-curie", # Legacy completion models + "text-babbage", # Legacy completion models + "text-ada", # Legacy completion models + "davinci", # Legacy completion models + "curie", # Legacy completion models + "babbage", # Legacy completion models + "ada", # Legacy completion models + "computer-use", # Computer control agent + "gpt-image", # Image generation + "gpt-audio", # Audio models + "gpt-realtime", # Realtime voice API +) + +# OpenAI model substrings to exclude (patterns that can appear anywhere in model ID). +# These patterns identify non-chat model variants. +EXCLUDED_OPENAI_MODEL_SUBSTRINGS = ( + "-audio-", # Audio preview models (gpt-4o-audio-preview, etc.) + "-realtime-", # Realtime preview models (gpt-4o-realtime-preview, etc.) + "-transcribe", # Transcription models (gpt-4o-transcribe, etc.) + "-tts", # TTS models (gpt-4o-mini-tts) + "-instruct", # Legacy instruct models (gpt-3.5-turbo-instruct, etc.) +) + + +def _extract_error_message(e: Exception) -> str: + """ + Extract a user-friendly error message from various exception types. + + This function handles exceptions from different providers (OpenAI, AWS Bedrock) + and extracts the most relevant error message for display to users. + + Args: + e: The exception to extract a message from. + + Returns: + str: A user-friendly error message. + """ + # For OpenAI SDK errors (>= v1.0) + # OpenAI exceptions have a 'body' attribute with error details + if hasattr(e, "body") and isinstance(e.body, dict): + if "message" in e.body: + return e.body["message"] + # Sometimes nested under 'error' key + if "error" in e.body and isinstance(e.body["error"], dict): + return e.body["error"].get("message", str(e)) + + # For boto3 ClientError + # Boto3 exceptions have a 'response' attribute with error details + if hasattr(e, "response") and isinstance(e.response, dict): + error_info = e.response.get("Error", {}) + if error_info.get("Message"): + return error_info["Message"] + + # Fallback to string representation for unknown error types + return str(e) + def _extract_openai_api_key( provider_cfg: LighthouseProviderConfiguration, @@ -30,16 +102,132 @@ def _extract_openai_api_key( return api_key +def _extract_openai_compatible_params( + provider_cfg: LighthouseProviderConfiguration, +) -> Dict[str, str] | None: + """ + Extract base_url and api_key for OpenAI-compatible providers. + """ + creds = provider_cfg.credentials_decoded + base_url = provider_cfg.base_url + if not isinstance(creds, dict): + return None + api_key = creds.get("api_key") + if not isinstance(api_key, str) or not api_key: + return None + if not isinstance(base_url, str) or not base_url: + return None + return {"base_url": base_url, "api_key": api_key} + + +def _extract_bedrock_credentials( + provider_cfg: LighthouseProviderConfiguration, +) -> Dict[str, str] | None: + """ + Safely extract AWS Bedrock credentials from a provider configuration. + + Supports two authentication methods: + 1. AWS access key + secret key + region + 2. Bedrock API key (bearer token) + region + + Args: + provider_cfg (LighthouseProviderConfiguration): The provider configuration instance + containing the credentials. + + Returns: + Dict[str, str] | None: Dictionary with either: + - 'access_key_id', 'secret_access_key', and 'region' for access key auth + - 'api_key' and 'region' for API key (bearer token) auth + Returns None if credentials are invalid or missing. + """ + creds = provider_cfg.credentials_decoded + if not isinstance(creds, dict): + return None + + region = creds.get("region") + if not isinstance(region, str) or not region: + return None + + # Check for API key authentication first + api_key = creds.get("api_key") + if isinstance(api_key, str) and api_key: + return { + "api_key": api_key, + "region": region, + } + + # Fall back to access key authentication + access_key_id = creds.get("access_key_id") + secret_access_key = creds.get("secret_access_key") + + # Validate all required fields are present and are strings + if ( + not isinstance(access_key_id, str) + or not access_key_id + or not isinstance(secret_access_key, str) + or not secret_access_key + ): + return None + + return { + "access_key_id": access_key_id, + "secret_access_key": secret_access_key, + "region": region, + } + + +def _create_bedrock_client( + bedrock_creds: Dict[str, str], service_name: str = "bedrock" +): + """ + Create a boto3 Bedrock client with the appropriate authentication method. + + Supports two authentication methods: + 1. API key (bearer token) - uses unsigned requests with Authorization header + 2. AWS access key + secret key - uses standard SigV4 signing + + Args: + bedrock_creds: Dictionary with either: + - 'api_key' and 'region' for API key (bearer token) auth + - 'access_key_id', 'secret_access_key', and 'region' for access key auth + service_name: The Bedrock service name. Use 'bedrock' for control plane + operations (list_foundation_models, etc.) or 'bedrock-runtime' for + inference operations. + + Returns: + boto3 client configured for the specified Bedrock service. + """ + region = bedrock_creds["region"] + + if "api_key" in bedrock_creds: + bearer_token = bedrock_creds["api_key"] + client = boto3.client( + service_name=service_name, + region_name=region, + config=Config(signature_version=UNSIGNED), + ) + + def inject_bearer_token(request, **kwargs): + request.headers["Authorization"] = f"Bearer {bearer_token}" + + client.meta.events.register("before-send.*.*", inject_bearer_token) + return client + + return boto3.client( + service_name=service_name, + region_name=region, + aws_access_key_id=bedrock_creds["access_key_id"], + aws_secret_access_key=bedrock_creds["secret_access_key"], + ) + + def check_lighthouse_provider_connection(provider_config_id: str) -> Dict: """ Validate a Lighthouse provider configuration by calling the provider API and toggle its active state accordingly. - Currently supports the OpenAI provider by invoking `models.list` to verify that - the provided credentials are valid. - Args: - provider_config_id (str): The primary key of the `LighthouseProviderConfiguration` + provider_config_id: The primary key of the `LighthouseProviderConfiguration` to validate. Returns: @@ -55,42 +243,369 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> Dict: """ provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id) - # TODO: Add support for other providers - if ( - provider_cfg.provider_type - != LighthouseProviderConfiguration.LLMProviderChoices.OPENAI - ): - return {"connected": False, "error": "Unsupported provider type"} - - api_key = _extract_openai_api_key(provider_cfg) - if not api_key: - provider_cfg.is_active = False - provider_cfg.save() - return {"connected": False, "error": "API key is invalid or missing"} - try: - client = openai.OpenAI(api_key=api_key) - _ = client.models.list() + if ( + provider_cfg.provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI + ): + api_key = _extract_openai_api_key(provider_cfg) + if not api_key: + provider_cfg.is_active = False + provider_cfg.save() + return {"connected": False, "error": "API key is invalid or missing"} + + # Test connection by listing models + client = openai.OpenAI(api_key=api_key) + _ = client.models.list() + + elif ( + provider_cfg.provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK + ): + bedrock_creds = _extract_bedrock_credentials(provider_cfg) + if not bedrock_creds: + provider_cfg.is_active = False + provider_cfg.save() + return { + "connected": False, + "error": "AWS credentials are invalid or missing", + } + + # Test connection by listing foundation models + bedrock_client = _create_bedrock_client(bedrock_creds) + _ = bedrock_client.list_foundation_models() + + elif ( + provider_cfg.provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE + ): + params = _extract_openai_compatible_params(provider_cfg) + if not params: + provider_cfg.is_active = False + provider_cfg.save() + return { + "connected": False, + "error": "Base URL or API key is invalid or missing", + } + + # Test connection using OpenAI SDK with custom base_url + # Note: base_url should include version (e.g., https://openrouter.ai/api/v1) + client = openai.OpenAI( + api_key=params["api_key"], + base_url=params["base_url"], + ) + _ = client.models.list() + + else: + return {"connected": False, "error": "Unsupported provider type"} + + # Connection successful provider_cfg.is_active = True provider_cfg.save() return {"connected": True, "error": None} + except Exception as e: - logger.warning("OpenAI connection check failed: %s", str(e)) + error_message = _extract_error_message(e) + logger.warning( + "%s connection check failed: %s", provider_cfg.provider_type, error_message + ) provider_cfg.is_active = False provider_cfg.save() - return {"connected": False, "error": str(e)} + return {"connected": False, "error": error_message} + + +def _fetch_openai_models(api_key: str) -> Dict[str, str]: + """ + Fetch available models from OpenAI API. + + Filters out models that don't support text input/output and tool calling, + such as image generation (DALL-E), audio transcription (Whisper), + text-to-speech (TTS), embeddings, and moderation models. + + Args: + api_key: OpenAI API key for authentication. + + Returns: + Dict mapping model_id to model_name. For OpenAI, both are the same + as the API doesn't provide separate display names. Only includes + models that support text input, text output or tool calling. + + Raises: + Exception: If the API call fails. + """ + client = openai.OpenAI(api_key=api_key) + models = client.models.list() + + # Filter models to only include those supporting chat completions + tool calling + filtered_models = {} + for model in getattr(models, "data", []): + model_id = model.id + + # Skip if model ID starts with excluded prefixes + if model_id.startswith(EXCLUDED_OPENAI_MODEL_PREFIXES): + continue + + # Skip if model ID contains excluded substrings + if any(substring in model_id for substring in EXCLUDED_OPENAI_MODEL_SUBSTRINGS): + continue + + # Include model (supports chat completions + tool calling) + filtered_models[model_id] = model_id + + return filtered_models + + +def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict[str, str]: + """ + Fetch available models from an OpenAI-compatible API using the OpenAI SDK. + + Returns a mapping of model_id -> model_name. Prefers the 'name' attribute + if available (e.g., from OpenRouter), otherwise falls back to 'id'. + + Note: base_url should include version (e.g., https://openrouter.ai/api/v1) + """ + client = openai.OpenAI(api_key=api_key, base_url=base_url) + models = client.models.list() + + available_models: Dict[str, str] = {} + for model in models.data: + model_id = model.id + # Prefer provider-supplied human-friendly name when available + name = getattr(model, "name", None) + if name: + available_models[model_id] = name + else: + available_models[model_id] = model_id + + return available_models + + +def _get_region_prefix(region: str) -> str: + """ + Determine geographic prefix for AWS region. + + Examples: ap-south-1 -> apac, us-east-1 -> us, eu-west-1 -> eu + """ + if region.startswith(("us-", "ca-", "sa-")): + return "us" + elif region.startswith("eu-"): + return "eu" + elif region.startswith("ap-"): + return "apac" + return "global" + + +def _clean_inference_profile_name(profile_name: str) -> str: + """ + Remove geographic prefix from inference profile name. + + AWS includes geographic prefixes in profile names which are redundant + since the profile ID already contains this information. + + Examples: + "APAC Anthropic Claude 3.5 Sonnet" -> "Anthropic Claude 3.5 Sonnet" + "GLOBAL Claude Sonnet 4.5" -> "Claude Sonnet 4.5" + "US Anthropic Claude 3 Haiku" -> "Anthropic Claude 3 Haiku" + """ + prefixes = ["APAC ", "GLOBAL ", "US ", "EU ", "APAC-", "GLOBAL-", "US-", "EU-"] + + for prefix in prefixes: + if profile_name.upper().startswith(prefix.upper()): + return profile_name[len(prefix) :].strip() + + return profile_name + + +def _supports_text_modality(input_modalities: list, output_modalities: list) -> bool: + """Check if model supports TEXT for both input and output.""" + return "TEXT" in input_modalities and "TEXT" in output_modalities + + +def _get_foundation_model_modalities( + bedrock_client, model_id: str +) -> tuple[list, list] | None: + """ + Fetch input and output modalities for a foundation model. + + Returns: + (input_modalities, output_modalities) or None if fetch fails + """ + try: + model_info = bedrock_client.get_foundation_model(modelIdentifier=model_id) + model_details = model_info.get("modelDetails", {}) + input_mods = model_details.get("inputModalities", []) + output_mods = model_details.get("outputModalities", []) + return (input_mods, output_mods) + except (BotoCoreError, ClientError) as e: + logger.debug("Could not fetch model details for %s: %s", model_id, str(e)) + return None + + +def _extract_foundation_model_ids(profile_models: list) -> list[str]: + """ + Extract foundation model IDs from inference profile model ARNs. + + Args: + profile_models: List of model references from inference profile + + Returns: + List of foundation model IDs extracted from ARNs + """ + model_ids = [] + for model_ref in profile_models: + model_arn = model_ref.get("modelArn", "") + if "foundation-model/" in model_arn: + model_id = model_arn.split("foundation-model/")[1] + model_ids.append(model_id) + return model_ids + + +def _build_inference_profile_map( + bedrock_client, region: str +) -> Dict[str, tuple[str, str]]: + """ + Build map of foundation_model_id -> best inference profile. + + Returns: + Dict mapping foundation_model_id to (profile_id, profile_name) + Only includes profiles with TEXT modality support + Prefers region-matched profiles over others + """ + region_prefix = _get_region_prefix(region) + model_to_profile: Dict[str, tuple[str, str]] = {} + + try: + response = bedrock_client.list_inference_profiles() + profiles = response.get("inferenceProfileSummaries", []) + + for profile in profiles: + profile_id = profile.get("inferenceProfileId") + profile_name = profile.get("inferenceProfileName") + + if not profile_id or not profile_name: + continue + + profile_models = profile.get("models", []) + if not profile_models: + continue + + foundation_model_ids = _extract_foundation_model_ids(profile_models) + if not foundation_model_ids: + continue + + modalities = _get_foundation_model_modalities( + bedrock_client, foundation_model_ids[0] + ) + if not modalities: + continue + + input_mods, output_mods = modalities + if not _supports_text_modality(input_mods, output_mods): + continue + + is_preferred = profile_id.startswith(f"{region_prefix}.") + clean_name = _clean_inference_profile_name(profile_name) + + for foundation_model_id in foundation_model_ids: + if foundation_model_id not in model_to_profile: + model_to_profile[foundation_model_id] = (profile_id, clean_name) + elif is_preferred and not model_to_profile[foundation_model_id][ + 0 + ].startswith(f"{region_prefix}."): + model_to_profile[foundation_model_id] = (profile_id, clean_name) + + except (BotoCoreError, ClientError) as e: + logger.info("Could not fetch inference profiles in %s: %s", region, str(e)) + + return model_to_profile + + +def _check_on_demand_availability(bedrock_client, model_id: str) -> bool: + """Check if an ON_DEMAND foundation model is entitled and available.""" + try: + availability = bedrock_client.get_foundation_model_availability( + modelId=model_id + ) + entitlement = availability.get("entitlementAvailability") + return entitlement == "AVAILABLE" + except (BotoCoreError, ClientError) as e: + logger.debug("Could not check availability for %s: %s", model_id, str(e)) + return False + + +def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]: + """ + Fetch available models from AWS Bedrock, preferring inference profiles over ON_DEMAND. + + Strategy: + 1. Build map of foundation_model -> best_inference_profile (with TEXT validation) + 2. For each TEXT-capable foundation model: + - Use inference profile ID if available (preferred - better throughput) + - Fallback to foundation model ID if only ON_DEMAND available + 3. Verify entitlement for ON_DEMAND models + + Args: + bedrock_creds: Dict with 'region' and auth credentials + + Returns: + Dict mapping model_id to model_name. IDs can be: + - Inference profile IDs (e.g., "apac.anthropic.claude-3-5-sonnet-20240620-v1:0") + - Foundation model IDs (e.g., "anthropic.claude-3-5-sonnet-20240620-v1:0") + """ + bedrock_client = _create_bedrock_client(bedrock_creds) + region = bedrock_creds["region"] + + model_to_profile = _build_inference_profile_map(bedrock_client, region) + + foundation_response = bedrock_client.list_foundation_models() + model_summaries = foundation_response.get("modelSummaries", []) + + models_to_return: Dict[str, str] = {} + on_demand_models: set[str] = set() + + for model in model_summaries: + input_mods = model.get("inputModalities", []) + output_mods = model.get("outputModalities", []) + + if not _supports_text_modality(input_mods, output_mods): + continue + + model_id = model.get("modelId") + model_name = model.get("modelName") + + if not model_id or not model_name: + continue + + if model_id in model_to_profile: + profile_id, profile_name = model_to_profile[model_id] + models_to_return[profile_id] = profile_name + else: + inference_types = model.get("inferenceTypesSupported", []) + if "ON_DEMAND" in inference_types: + models_to_return[model_id] = model_name + on_demand_models.add(model_id) + + available_models: Dict[str, str] = {} + + for model_id, model_name in models_to_return.items(): + if model_id in on_demand_models: + if _check_on_demand_availability(bedrock_client, model_id): + available_models[model_id] = model_name + else: + available_models[model_id] = model_name + + return available_models def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict: """ Refresh the catalog of models for a Lighthouse provider configuration. - For the OpenAI provider, this fetches the current list of models, upserts entries - into `LighthouseProviderModels`, and deletes stale entries no longer returned by - the provider. + Fetches the current list of models from the provider, upserts entries into + `LighthouseProviderModels`, and deletes stale entries no longer returned. Args: - provider_config_id (str): The primary key of the `LighthouseProviderConfiguration` + provider_config_id: The primary key of the `LighthouseProviderConfiguration` whose models should be refreshed. Returns: @@ -104,45 +619,81 @@ def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict: LighthouseProviderConfiguration.DoesNotExist: If no configuration exists with the given ID. """ provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id) - - if ( - provider_cfg.provider_type - != LighthouseProviderConfiguration.LLMProviderChoices.OPENAI - ): - return { - "created": 0, - "updated": 0, - "deleted": 0, - "error": "Unsupported provider type", - } - - api_key = _extract_openai_api_key(provider_cfg) - if not api_key: - return { - "created": 0, - "updated": 0, - "deleted": 0, - "error": "API key is invalid or missing", - } + fetched_models: Dict[str, str] = {} try: - client = openai.OpenAI(api_key=api_key) - models = client.models.list() - fetched_ids: Set[str] = {m.id for m in getattr(models, "data", [])} - except Exception as e: # noqa: BLE001 - logger.warning("OpenAI models refresh failed: %s", str(e)) - return {"created": 0, "updated": 0, "deleted": 0, "error": str(e)} + if ( + provider_cfg.provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI + ): + api_key = _extract_openai_api_key(provider_cfg) + if not api_key: + return { + "created": 0, + "updated": 0, + "deleted": 0, + "error": "API key is invalid or missing", + } + fetched_models = _fetch_openai_models(api_key) + elif ( + provider_cfg.provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK + ): + bedrock_creds = _extract_bedrock_credentials(provider_cfg) + if not bedrock_creds: + return { + "created": 0, + "updated": 0, + "deleted": 0, + "error": "AWS credentials are invalid or missing", + } + fetched_models = _fetch_bedrock_models(bedrock_creds) + + elif ( + provider_cfg.provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE + ): + params = _extract_openai_compatible_params(provider_cfg) + if not params: + return { + "created": 0, + "updated": 0, + "deleted": 0, + "error": "Base URL or API key is invalid or missing", + } + fetched_models = _fetch_openai_compatible_models( + params["base_url"], params["api_key"] + ) + + else: + return { + "created": 0, + "updated": 0, + "deleted": 0, + "error": "Unsupported provider type", + } + + except Exception as e: + error_message = _extract_error_message(e) + logger.warning( + "Unexpected error refreshing %s models: %s", + provider_cfg.provider_type, + error_message, + ) + return {"created": 0, "updated": 0, "deleted": 0, "error": error_message} + + # Upsert models into the catalog created = 0 updated = 0 - for model_id in fetched_ids: + for model_id, model_name in fetched_models.items(): obj, was_created = LighthouseProviderModels.objects.update_or_create( tenant_id=provider_cfg.tenant_id, provider_configuration=provider_cfg, model_id=model_id, defaults={ - "model_name": model_id, # OpenAI doesn't return a separate display name + "model_name": model_name, "default_parameters": {}, }, ) @@ -156,7 +707,7 @@ def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict: LighthouseProviderModels.objects.filter( tenant_id=provider_cfg.tenant_id, provider_configuration=provider_cfg ) - .exclude(model_id__in=fetched_ids) + .exclude(model_id__in=fetched_models.keys()) .delete() ) diff --git a/api/src/backend/tasks/jobs/muting.py b/api/src/backend/tasks/jobs/muting.py new file mode 100644 index 0000000000..6ef4d127f5 --- /dev/null +++ b/api/src/backend/tasks/jobs/muting.py @@ -0,0 +1,64 @@ +from celery.utils.log import get_task_logger +from config.django.base import DJANGO_FINDINGS_BATCH_SIZE +from tasks.utils import batched + +from api.db_utils import rls_transaction +from api.models import Finding, MuteRule + +logger = get_task_logger(__name__) + + +def mute_historical_findings(tenant_id: str, mute_rule_id: str): + """ + Mute historical findings that match the given mute rule. + + This function processes findings in batches, updating their muted status + and adding the mute reason. + + Args: + tenant_id (str): The tenant ID for RLS context + mute_rule_id (str): The ID of the mute rule to apply + + Returns: + dict: Summary of the muting operation with findings_muted count + """ + findings_muted_count = 0 + + # Get the list of UIDs to mute and the reason + with rls_transaction(tenant_id): + mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id) + finding_uids = mute_rule.finding_uids + mute_reason = mute_rule.reason + muted_at = mute_rule.inserted_at + + # Query findings that match the UIDs and are not already muted + with rls_transaction(tenant_id): + findings_to_mute = Finding.objects.filter( + tenant_id=tenant_id, uid__in=finding_uids, muted=False + ) + total_findings = findings_to_mute.count() + + logger.info( + f"Processing {total_findings} findings for mute rule {mute_rule_id}" + ) + + if total_findings > 0: + for batch, is_last in batched( + findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE + ): + batch_ids = [f.id for f in batch] + updated_count = Finding.all_objects.filter( + id__in=batch_ids, tenant_id=tenant_id + ).update( + muted=True, + muted_at=muted_at, + muted_reason=mute_reason, + ) + findings_muted_count += updated_count + + logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}") + + return { + "findings_muted": findings_muted_count, + "rule_id": mute_rule_id, + } diff --git a/api/src/backend/tasks/jobs/queries.py b/api/src/backend/tasks/jobs/queries.py new file mode 100644 index 0000000000..9fbe4901b2 --- /dev/null +++ b/api/src/backend/tasks/jobs/queries.py @@ -0,0 +1,134 @@ +""" +Shared SQL queries for tasks. + +This module centralizes raw SQL queries used across multiple task modules +to ensure consistency and maintainability. +""" + +# ============================================================================= +# COMPLIANCE SCORE QUERIES +# ============================================================================= + +# Upsert provider compliance scores from a scan's compliance requirements. +# Uses FAIL-dominant aggregation: FAIL > MANUAL > PASS +# Parameters: [tenant_id, scan_id] +COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL = """ + INSERT INTO provider_compliance_scores + (id, tenant_id, provider_id, scan_id, compliance_id, requirement_id, + requirement_status, scan_completed_at) + SELECT + gen_random_uuid(), + agg.tenant_id, + agg.provider_id, + agg.scan_id, + agg.compliance_id, + agg.requirement_id, + agg.requirement_status, + agg.completed_at + FROM ( + SELECT DISTINCT ON (cro.compliance_id, cro.requirement_id) + cro.tenant_id, + s.provider_id, + cro.scan_id, + cro.compliance_id, + cro.requirement_id, + (CASE + WHEN bool_or(cro.requirement_status = 'FAIL') + OVER (PARTITION BY cro.compliance_id, cro.requirement_id) THEN 'FAIL' + WHEN bool_or(cro.requirement_status = 'MANUAL') + OVER (PARTITION BY cro.compliance_id, cro.requirement_id) THEN 'MANUAL' + ELSE 'PASS' + END)::status as requirement_status, + s.completed_at + FROM compliance_requirements_overviews cro + JOIN scans s ON s.id = cro.scan_id + WHERE cro.tenant_id = %s AND cro.scan_id = %s + ORDER BY cro.compliance_id, cro.requirement_id + ) agg + ON CONFLICT (tenant_id, provider_id, compliance_id, requirement_id) + DO UPDATE SET + requirement_status = EXCLUDED.requirement_status, + scan_id = EXCLUDED.scan_id, + scan_completed_at = EXCLUDED.scan_completed_at + WHERE EXCLUDED.scan_completed_at > provider_compliance_scores.scan_completed_at +""" + +# Upsert tenant compliance summary for specific compliance IDs. +# Aggregates across all providers with FAIL-dominant logic at requirement level. +# Parameters: [tenant_id, tenant_id, compliance_ids_array] +COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL = """ + INSERT INTO tenant_compliance_summaries + (id, tenant_id, compliance_id, + requirements_passed, requirements_failed, requirements_manual, + total_requirements, updated_at) + SELECT + gen_random_uuid(), + %s as tenant_id, + compliance_id, + COUNT(*) FILTER (WHERE req_status = 'PASS') as requirements_passed, + COUNT(*) FILTER (WHERE req_status = 'FAIL') as requirements_failed, + COUNT(*) FILTER (WHERE req_status = 'MANUAL') as requirements_manual, + COUNT(*) as total_requirements, + NOW() as updated_at + FROM ( + SELECT + compliance_id, + requirement_id, + CASE + WHEN bool_or(requirement_status = 'FAIL') THEN 'FAIL' + WHEN bool_or(requirement_status = 'MANUAL') THEN 'MANUAL' + ELSE 'PASS' + END as req_status + FROM provider_compliance_scores + WHERE tenant_id = %s AND compliance_id = ANY(%s) + GROUP BY compliance_id, requirement_id + ) req_agg + GROUP BY compliance_id + ON CONFLICT (tenant_id, compliance_id) + DO UPDATE SET + requirements_passed = EXCLUDED.requirements_passed, + requirements_failed = EXCLUDED.requirements_failed, + requirements_manual = EXCLUDED.requirements_manual, + total_requirements = EXCLUDED.total_requirements, + updated_at = NOW() +""" + +# Upsert tenant compliance summary for ALL compliance IDs in tenant. +# Used by backfill when recalculating entire tenant summary. +# Parameters: [tenant_id, tenant_id] +COMPLIANCE_UPSERT_TENANT_SUMMARY_ALL_SQL = """ + INSERT INTO tenant_compliance_summaries + (id, tenant_id, compliance_id, + requirements_passed, requirements_failed, requirements_manual, + total_requirements, updated_at) + SELECT + gen_random_uuid(), + %s as tenant_id, + compliance_id, + COUNT(*) FILTER (WHERE req_status = 'PASS') as requirements_passed, + COUNT(*) FILTER (WHERE req_status = 'FAIL') as requirements_failed, + COUNT(*) FILTER (WHERE req_status = 'MANUAL') as requirements_manual, + COUNT(*) as total_requirements, + NOW() as updated_at + FROM ( + SELECT + compliance_id, + requirement_id, + CASE + WHEN bool_or(requirement_status = 'FAIL') THEN 'FAIL' + WHEN bool_or(requirement_status = 'MANUAL') THEN 'MANUAL' + ELSE 'PASS' + END as req_status + FROM provider_compliance_scores + WHERE tenant_id = %s + GROUP BY compliance_id, requirement_id + ) req_agg + GROUP BY compliance_id + ON CONFLICT (tenant_id, compliance_id) + DO UPDATE SET + requirements_passed = EXCLUDED.requirements_passed, + requirements_failed = EXCLUDED.requirements_failed, + requirements_manual = EXCLUDED.requirements_manual, + total_requirements = EXCLUDED.total_requirements, + updated_at = NOW() +""" diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index 641fa5757a..de022e9afd 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -1,627 +1,26 @@ -import io -import os -from collections import defaultdict from pathlib import Path from shutil import rmtree -import matplotlib.pyplot as plt from celery.utils.log import get_task_logger -from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY -from django.db.models import Count, Q -from reportlab.lib import colors -from reportlab.lib.enums import TA_CENTER -from reportlab.lib.pagesizes import letter -from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont -from reportlab.pdfgen import canvas -from reportlab.platypus import ( - Image, - PageBreak, - Paragraph, - SimpleDocTemplate, - Spacer, - Table, - TableStyle, +from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY +from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3 +from tasks.jobs.reports import ( + FRAMEWORK_REGISTRY, + ENSReportGenerator, + NIS2ReportGenerator, + ThreatScoreReportGenerator, ) -from tasks.jobs.export import _generate_output_directory, _upload_to_s3 -from tasks.utils import batched +from tasks.jobs.threatscore import compute_threatscore_metrics +from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_database from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction -from api.models import Finding, Provider, ScanSummary, StatusChoices -from api.utils import initialize_prowler_provider -from prowler.lib.check.compliance_models import Compliance +from api.models import Provider, ScanSummary, ThreatScoreSnapshot from prowler.lib.outputs.finding import Finding as FindingOutput -pdfmetrics.registerFont( - TTFont( - "PlusJakartaSans", - os.path.join( - os.path.dirname(__file__), "../assets/fonts/PlusJakartaSans-Regular.ttf" - ), - ) -) - -pdfmetrics.registerFont( - TTFont( - "FiraCode", - os.path.join(os.path.dirname(__file__), "../assets/fonts/FiraCode-Regular.ttf"), - ) -) - logger = get_task_logger(__name__) -def _create_pdf_styles() -> dict[str, ParagraphStyle]: - """ - Create and return PDF paragraph styles used throughout the report. - - Returns: - dict[str, ParagraphStyle]: A dictionary containing the following styles: - - 'title': Title style with prowler green color - - 'h1': Heading 1 style with blue color and background - - 'h2': Heading 2 style with light blue color - - 'h3': Heading 3 style for sub-headings - - 'normal': Normal text style with left indent - - 'normal_center': Normal text style without indent - """ - styles = getSampleStyleSheet() - prowler_dark_green = colors.Color(0.1, 0.5, 0.2) - - title_style = ParagraphStyle( - "CustomTitle", - parent=styles["Title"], - fontSize=24, - textColor=prowler_dark_green, - spaceAfter=20, - fontName="PlusJakartaSans", - alignment=TA_CENTER, - ) - - h1 = ParagraphStyle( - "CustomH1", - parent=styles["Heading1"], - fontSize=18, - textColor=colors.Color(0.2, 0.4, 0.6), - spaceBefore=20, - spaceAfter=12, - fontName="PlusJakartaSans", - leftIndent=0, - borderWidth=2, - borderColor=colors.Color(0.2, 0.4, 0.6), - borderPadding=8, - backColor=colors.Color(0.95, 0.97, 1.0), - ) - - h2 = ParagraphStyle( - "CustomH2", - parent=styles["Heading2"], - fontSize=14, - textColor=colors.Color(0.3, 0.5, 0.7), - spaceBefore=15, - spaceAfter=8, - fontName="PlusJakartaSans", - leftIndent=10, - borderWidth=1, - borderColor=colors.Color(0.7, 0.8, 0.9), - borderPadding=5, - backColor=colors.Color(0.98, 0.99, 1.0), - ) - - h3 = ParagraphStyle( - "CustomH3", - parent=styles["Heading3"], - fontSize=12, - textColor=colors.Color(0.4, 0.6, 0.8), - spaceBefore=10, - spaceAfter=6, - fontName="PlusJakartaSans", - leftIndent=20, - ) - - normal = ParagraphStyle( - "CustomNormal", - parent=styles["Normal"], - fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), - spaceBefore=4, - spaceAfter=4, - leftIndent=30, - fontName="PlusJakartaSans", - ) - - normal_center = ParagraphStyle( - "CustomNormalCenter", - parent=styles["Normal"], - fontSize=10, - textColor=colors.Color(0.2, 0.2, 0.2), - fontName="PlusJakartaSans", - ) - - return { - "title": title_style, - "h1": h1, - "h2": h2, - "h3": h3, - "normal": normal, - "normal_center": normal_center, - } - - -def _create_risk_component(risk_level: int, weight: int, score: int = 0) -> Table: - """ - Create a visual risk component table for the PDF report. - - Args: - risk_level (int): The risk level (0-5), where higher values indicate higher risk. - weight (int): The weight of the risk component. - score (int): The calculated score. Defaults to 0. - - Returns: - Table: A ReportLab Table object with colored cells representing risk, weight, and score. - """ - if risk_level >= 4: - risk_color = colors.Color(0.8, 0.2, 0.2) - elif risk_level >= 3: - risk_color = colors.Color(0.9, 0.6, 0.2) - elif risk_level >= 2: - risk_color = colors.Color(0.9, 0.9, 0.2) - else: - risk_color = colors.Color(0.2, 0.8, 0.2) - - if weight <= 50: - weight_color = colors.Color(0.2, 0.8, 0.2) - elif weight <= 100: - weight_color = colors.Color(0.9, 0.9, 0.2) - else: - weight_color = colors.Color(0.8, 0.2, 0.2) - - score_color = colors.Color(0.4, 0.4, 0.4) - - data = [ - [ - "Risk Level:", - str(risk_level), - "Weight:", - str(weight), - "Score:", - str(score), - ] - ] - - table = Table( - data, - colWidths=[ - 0.8 * inch, - 0.4 * inch, - 0.6 * inch, - 0.4 * inch, - 0.5 * inch, - 0.4 * inch, - ], - ) - - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (1, 0), (1, 0), risk_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("BACKGROUND", (2, 0), (2, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (3, 0), (3, 0), weight_color), - ("TEXTCOLOR", (3, 0), (3, 0), colors.white), - ("FONTNAME", (3, 0), (3, 0), "FiraCode"), - ("BACKGROUND", (4, 0), (4, 0), colors.Color(0.9, 0.9, 0.9)), - ("BACKGROUND", (5, 0), (5, 0), score_color), - ("TEXTCOLOR", (5, 0), (5, 0), colors.white), - ("FONTNAME", (5, 0), (5, 0), "FiraCode"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 10), - ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), 6), - ("RIGHTPADDING", (0, 0), (-1, -1), 6), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ] - ) - ) - - return table - - -def _create_status_component(status: str) -> Table: - """ - Create a visual status component with colored background. - - Args: - status (str): The status value (e.g., "PASS", "FAIL", "MANUAL"). - - Returns: - Table: A ReportLab Table object displaying the status with appropriate color coding. - """ - if status.upper() == "PASS": - status_color = colors.Color(0.2, 0.8, 0.2) - elif status.upper() == "FAIL": - status_color = colors.Color(0.8, 0.2, 0.2) - else: - status_color = colors.Color(0.4, 0.4, 0.4) - - data = [["State:", status.upper()]] - - table = Table(data, colWidths=[0.6 * inch, 0.8 * inch]) - - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), - ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), - ("BACKGROUND", (1, 0), (1, 0), status_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 12), - ("GRID", (0, 0), (-1, -1), 0.5, colors.black), - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ] - ) - ) - - return table - - -def _create_section_score_chart( - requirements_list: list[dict], attributes_by_requirement_id: dict -) -> io.BytesIO: - """ - Create a bar chart showing compliance score by section using ThreatScore formula. - - Args: - requirements_list (list[dict]): List of requirement dictionaries with status and findings data. - attributes_by_requirement_id (dict): Mapping of requirement IDs to their attributes including risk level and weight. - - Returns: - io.BytesIO: A BytesIO buffer containing the chart image in PNG format. - """ - # Define expected sections - expected_sections = [ - "1. IAM", - "2. Attack Surface", - "3. Logging and Monitoring", - "4. Encryption", - ] - - # Initialize all expected sections with default values - sections_data = { - section: { - "numerator": 0, - "denominator": 0, - "has_findings": False, - } - for section in expected_sections - } - - # Collect data from requirements - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata: - m = metadata[0] - section = getattr(m, "Section", "Unknown") - - # Add section if not in expected list (for flexibility) - if section not in sections_data: - sections_data[section] = { - "numerator": 0, - "denominator": 0, - "has_findings": False, - } - - # Get findings data - passed_findings = requirement["attributes"].get("passed_findings", 0) - total_findings = requirement["attributes"].get("total_findings", 0) - - if total_findings > 0: - sections_data[section]["has_findings"] = True - risk_level = getattr(m, "LevelOfRisk", 0) - weight = getattr(m, "Weight", 0) - - # Calculate using ThreatScore formula from UI - rate_i = passed_findings / total_findings - rfac_i = 1 + 0.25 * risk_level - - sections_data[section]["numerator"] += ( - rate_i * total_findings * weight * rfac_i - ) - sections_data[section]["denominator"] += ( - total_findings * weight * rfac_i - ) - - section_names = [] - compliance_percentages = [] - - for section, data in sections_data.items(): - if data["has_findings"] and data["denominator"] > 0: - compliance_percentage = (data["numerator"] / data["denominator"]) * 100 - else: - compliance_percentage = 100 # No findings = 100% (PASS) - - section_names.append(section) - compliance_percentages.append(compliance_percentage) - - # Sort alphabetically by section name - sorted_data = sorted( - zip(section_names, compliance_percentages), - key=lambda x: x[0], - ) - section_names, compliance_percentages = ( - zip(*sorted_data) if sorted_data else ([], []) - ) - - fig, ax = plt.subplots(figsize=(12, 8)) - - colors_list = [] - for percentage in compliance_percentages: - if percentage >= 80: - color = "#4CAF50" - elif percentage >= 60: - color = "#8BC34A" - elif percentage >= 40: - color = "#FFEB3B" - elif percentage >= 20: - color = "#FF9800" - else: - color = "#F44336" - colors_list.append(color) - - bars = ax.bar(section_names, compliance_percentages, color=colors_list) - - ax.set_ylabel("Compliance Score (%)", fontsize=12) - ax.set_xlabel("Section", fontsize=12) - ax.set_ylim(0, 100) - - for bar, percentage in zip(bars, compliance_percentages): - height = bar.get_height() - ax.text( - bar.get_x() + bar.get_width() / 2.0, - height + 1, - f"{percentage:.1f}%", - ha="center", - va="bottom", - fontweight="bold", - ) - - plt.xticks(rotation=45, ha="right") - - ax.grid(True, alpha=0.3, axis="y") - - plt.tight_layout() - - buffer = io.BytesIO() - plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight") - buffer.seek(0) - plt.close() - - return buffer - - -def _add_pdf_footer(canvas_obj: canvas.Canvas, doc: SimpleDocTemplate) -> None: - """ - Add footer with page number and branding to each page of the PDF. - - Args: - canvas_obj (canvas.Canvas): The ReportLab canvas object for drawing. - doc (SimpleDocTemplate): The document template containing page information. - """ - width, height = doc.pagesize - page_num_text = f"Page {doc.page}" - canvas_obj.setFont("PlusJakartaSans", 9) - canvas_obj.setFillColorRGB(0.4, 0.4, 0.4) - canvas_obj.drawString(30, 20, page_num_text) - powered_text = "Powered by Prowler" - text_width = canvas_obj.stringWidth(powered_text, "PlusJakartaSans", 9) - canvas_obj.drawString(width - text_width - 30, 20, powered_text) - - -def _aggregate_requirement_statistics_from_database( - tenant_id: str, scan_id: str -) -> dict[str, dict[str, int]]: - """ - Aggregate finding statistics by check_id using database aggregation. - - This function uses Django ORM aggregation to calculate pass/fail statistics - entirely in the database, avoiding the need to load findings into memory. - - Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): The ID of the scan to retrieve findings for. - - Returns: - dict[str, dict[str, int]]: Dictionary mapping check_id to statistics: - - 'passed' (int): Number of passed findings for this check - - 'total' (int): Total number of findings for this check - - Example: - { - 'aws_iam_user_mfa_enabled': {'passed': 10, 'total': 15}, - 'aws_s3_bucket_public_access': {'passed': 0, 'total': 5} - } - """ - requirement_statistics_by_check_id = {} - - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Use database aggregation to calculate stats without loading findings into memory - aggregated_statistics_queryset = ( - Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id) - .values("check_id") - .annotate( - total_findings=Count("id"), - passed_findings=Count("id", filter=Q(status=StatusChoices.PASS)), - ) - ) - - for aggregated_stat in aggregated_statistics_queryset: - check_id = aggregated_stat["check_id"] - requirement_statistics_by_check_id[check_id] = { - "passed": aggregated_stat["passed_findings"], - "total": aggregated_stat["total_findings"], - } - - logger.info( - f"Aggregated statistics for {len(requirement_statistics_by_check_id)} unique checks" - ) - return requirement_statistics_by_check_id - - -def _load_findings_for_requirement_checks( - tenant_id: str, scan_id: str, check_ids: list[str], prowler_provider -) -> dict[str, list[FindingOutput]]: - """ - Load findings for specific check IDs on-demand. - - This function loads only the findings needed for a specific set of checks, - minimizing memory usage by avoiding loading all findings at once. This is used - when generating detailed findings tables for specific requirements in the PDF. - - Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): The ID of the scan to retrieve findings for. - check_ids (list[str]): List of check IDs to load findings for. - prowler_provider: The initialized Prowler provider instance. - - Returns: - dict[str, list[FindingOutput]]: Dictionary mapping check_id to list of FindingOutput objects. - - Example: - { - 'aws_iam_user_mfa_enabled': [FindingOutput(...), FindingOutput(...)], - 'aws_s3_bucket_public_access': [FindingOutput(...)] - } - """ - findings_by_check_id = defaultdict(list) - - if not check_ids: - return dict(findings_by_check_id) - - logger.info(f"Loading findings for {len(check_ids)} checks on-demand") - - findings_queryset = ( - Finding.all_objects.filter( - tenant_id=tenant_id, scan_id=scan_id, check_id__in=check_ids - ) - .order_by("uid") - .iterator() - ) - - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - for batch, is_last_batch in batched( - findings_queryset, DJANGO_FINDINGS_BATCH_SIZE - ): - for finding_model in batch: - finding_output = FindingOutput.transform_api_finding( - finding_model, prowler_provider - ) - findings_by_check_id[finding_output.check_id].append(finding_output) - - total_findings_loaded = sum( - len(findings) for findings in findings_by_check_id.values() - ) - logger.info( - f"Loaded {total_findings_loaded} findings for {len(findings_by_check_id)} checks" - ) - - return dict(findings_by_check_id) - - -def _calculate_requirements_data_from_statistics( - compliance_obj, requirement_statistics_by_check_id: dict[str, dict[str, int]] -) -> tuple[dict[str, dict], list[dict]]: - """ - Calculate requirement status and statistics using pre-aggregated database statistics. - - This function uses O(n) lookups with pre-aggregated statistics from the database, - avoiding the need to iterate over all findings for each requirement. - - Args: - compliance_obj: The compliance framework object containing requirements. - requirement_statistics_by_check_id (dict[str, dict[str, int]]): Pre-aggregated statistics - mapping check_id to {'passed': int, 'total': int} counts. - - Returns: - tuple[dict[str, dict], list[dict]]: A tuple containing: - - attributes_by_requirement_id: Dictionary mapping requirement IDs to their attributes. - - requirements_list: List of requirement dictionaries with status and statistics. - """ - attributes_by_requirement_id = {} - requirements_list = [] - - compliance_framework = getattr(compliance_obj, "Framework", "N/A") - compliance_version = getattr(compliance_obj, "Version", "N/A") - - for requirement in compliance_obj.Requirements: - requirement_id = requirement.Id - requirement_description = getattr(requirement, "Description", "") - requirement_checks = getattr(requirement, "Checks", []) - requirement_attributes = getattr(requirement, "Attributes", []) - - # Store requirement metadata for later use - attributes_by_requirement_id[requirement_id] = { - "attributes": { - "req_attributes": requirement_attributes, - "checks": requirement_checks, - }, - "description": requirement_description, - } - - # Calculate aggregated passed and total findings for this requirement - total_passed_findings = 0 - total_findings_count = 0 - - for check_id in requirement_checks: - if check_id in requirement_statistics_by_check_id: - check_statistics = requirement_statistics_by_check_id[check_id] - total_findings_count += check_statistics["total"] - total_passed_findings += check_statistics["passed"] - - # Determine overall requirement status based on findings - if total_findings_count > 0: - if total_passed_findings == total_findings_count: - requirement_status = StatusChoices.PASS - else: - # Partial pass or complete fail both count as FAIL - requirement_status = StatusChoices.FAIL - else: - # No findings means manual review required - requirement_status = StatusChoices.MANUAL - - requirements_list.append( - { - "id": requirement_id, - "attributes": { - "framework": compliance_framework, - "version": compliance_version, - "status": requirement_status, - "description": requirement_description, - "passed_findings": total_passed_findings, - "total_findings": total_findings_count, - }, - } - ) - - return attributes_by_requirement_id, requirements_list - - def generate_threatscore_report( tenant_id: str, scan_id: str, @@ -630,708 +29,478 @@ def generate_threatscore_report( provider_id: str, only_failed: bool = True, min_risk_level: int = 4, + provider_obj: Provider | None = None, + requirement_statistics: dict[str, dict[str, int]] | None = None, + findings_cache: dict[str, list[FindingOutput]] | None = None, ) -> None: """ Generate a PDF compliance report based on Prowler ThreatScore framework. - This function creates a comprehensive PDF report containing: - - Compliance overview and metadata - - Section-by-section compliance scores with charts - - Overall ThreatScore calculation - - Critical failed requirements - - Detailed findings for each requirement - Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): ID of the scan executed by Prowler. - compliance_id (str): ID of the compliance framework (e.g., "prowler_threatscore_aws"). - output_path (str): Output PDF file path (e.g., "/tmp/threatscore_report.pdf"). - provider_id (str): Provider ID for the scan. - only_failed (bool): If True, only requirements with status "FAIL" will be included - in the detailed requirements section. Defaults to True. - min_risk_level (int): Minimum risk level for critical failed requirements. Defaults to 4. - - Raises: - Exception: If any error occurs during PDF generation, it will be logged and re-raised. + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the compliance framework (e.g., "prowler_threatscore_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + only_failed: If True, only include failed requirements in detailed section. + min_risk_level: Minimum risk level for critical failed requirements. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. """ - logger.info( - f"Generating the report for the scan {scan_id} with provider {provider_id}" + generator = ThreatScoreReportGenerator(FRAMEWORK_REGISTRY["prowler_threatscore"]) + generator._min_risk_level = min_risk_level + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + only_failed=only_failed, ) - try: - # Get PDF styles - pdf_styles = _create_pdf_styles() - title_style = pdf_styles["title"] - h1 = pdf_styles["h1"] - h2 = pdf_styles["h2"] - h3 = pdf_styles["h3"] - normal = pdf_styles["normal"] - normal_center = pdf_styles["normal_center"] - # Get compliance and provider information - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - provider_obj = Provider.objects.get(id=provider_id) - prowler_provider = initialize_prowler_provider(provider_obj) - provider_type = provider_obj.provider - frameworks_bulk = Compliance.get_bulk(provider_type) - compliance_obj = frameworks_bulk[compliance_id] - compliance_framework = getattr(compliance_obj, "Framework", "N/A") - compliance_version = getattr(compliance_obj, "Version", "N/A") - compliance_name = getattr(compliance_obj, "Name", "N/A") - compliance_description = getattr(compliance_obj, "Description", "") - - # Aggregate requirement statistics from database (memory-efficient) - logger.info(f"Aggregating requirement statistics for scan {scan_id}") - requirement_statistics_by_check_id = ( - _aggregate_requirement_statistics_from_database(tenant_id, scan_id) - ) - - # Calculate requirements data using aggregated statistics - attributes_by_requirement_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - compliance_obj, requirement_statistics_by_check_id - ) - ) - - # Initialize PDF document - doc = SimpleDocTemplate( - output_path, - pagesize=letter, - title=f"Prowler ThreatScore Report - {compliance_framework}", - author="Prowler", - subject=f"Compliance Report for {compliance_framework}", - creator="Prowler Engineering Team", - keywords=f"compliance,{compliance_framework},security,framework,prowler", - ) - - elements = [] - - # Add logo - img_path = os.path.join( - os.path.dirname(__file__), "../assets/img/prowler_logo.png" - ) - logo = Image( - img_path, - width=5 * inch, - height=1 * inch, - ) - elements.append(logo) - - elements.append(Spacer(1, 0.5 * inch)) - elements.append(Paragraph("Prowler ThreatScore Report", title_style)) - elements.append(Spacer(1, 0.5 * inch)) - - # Add compliance information table - info_data = [ - ["Framework:", compliance_framework], - ["ID:", compliance_id], - ["Name:", Paragraph(compliance_name, normal_center)], - ["Version:", compliance_version], - ["Scan ID:", scan_id], - ["Description:", Paragraph(compliance_description, normal_center)], - ] - info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) - info_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 5), colors.Color(0.2, 0.4, 0.6)), - ("TEXTCOLOR", (0, 0), (0, 5), colors.white), - ("FONTNAME", (0, 0), (0, 5), "FiraCode"), - ("BACKGROUND", (1, 0), (1, 5), colors.Color(0.95, 0.97, 1.0)), - ("TEXTCOLOR", (1, 0), (1, 5), colors.Color(0.2, 0.2, 0.2)), - ("FONTNAME", (1, 0), (1, 5), "PlusJakartaSans"), - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("FONTSIZE", (0, 0), (-1, -1), 11), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)), - ("LEFTPADDING", (0, 0), (-1, -1), 10), - ("RIGHTPADDING", (0, 0), (-1, -1), 10), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ] - ) - ) - - elements.append(info_table) - elements.append(PageBreak()) - - # Add compliance score chart - elements.append(Paragraph("Compliance Score by Sections", h1)) - elements.append(Spacer(1, 0.2 * inch)) - - chart_buffer = _create_section_score_chart( - requirements_list, attributes_by_requirement_id - ) - chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) - elements.append(chart_image) - - # Calculate overall ThreatScore using the same formula as the UI - numerator = 0 - denominator = 0 - has_findings = False - - for requirement in requirements_list: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - - # Get findings data - passed_findings = requirement["attributes"].get("passed_findings", 0) - total_findings = requirement["attributes"].get("total_findings", 0) - - # Skip if no findings (avoid division by zero) - if total_findings == 0: - continue - - has_findings = True - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata and len(metadata) > 0: - m = metadata[0] - risk_level = getattr(m, "LevelOfRisk", 0) - weight = getattr(m, "Weight", 0) - - # Calculate using ThreatScore formula from UI - rate_i = passed_findings / total_findings - rfac_i = 1 + 0.25 * risk_level - - numerator += rate_i * total_findings * weight * rfac_i - denominator += total_findings * weight * rfac_i - - # Calculate ThreatScore (percentualScore) - # If no findings exist, consider it 100% (PASS) - if not has_findings: - overall_compliance = 100 - elif denominator > 0: - overall_compliance = (numerator / denominator) * 100 - else: - overall_compliance = 0 - - elements.append(Spacer(1, 0.3 * inch)) - - summary_data = [ - ["ThreatScore:", f"{overall_compliance:.2f}%"], - ] - - if overall_compliance >= 80: - compliance_color = colors.Color(0.2, 0.8, 0.2) - elif overall_compliance >= 60: - compliance_color = colors.Color(0.8, 0.8, 0.2) - else: - compliance_color = colors.Color(0.8, 0.2, 0.2) - - summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) - summary_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), - ("TEXTCOLOR", (0, 0), (0, 0), colors.white), - ("FONTNAME", (0, 0), (0, 0), "FiraCode"), - ("FONTSIZE", (0, 0), (0, 0), 12), - ("BACKGROUND", (1, 0), (1, 0), compliance_color), - ("TEXTCOLOR", (1, 0), (1, 0), colors.white), - ("FONTNAME", (1, 0), (1, 0), "FiraCode"), - ("FONTSIZE", (1, 0), (1, 0), 16), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 12), - ("RIGHTPADDING", (0, 0), (-1, -1), 12), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ] - ) - ) - - elements.append(summary_table) - elements.append(PageBreak()) - - # Add requirements index - elements.append(Paragraph("Requirements Index", h1)) - - sections = {} - for ( - requirement_id, - requirement_attributes, - ) in attributes_by_requirement_id.items(): - meta = requirement_attributes["attributes"]["req_attributes"][0] - section = getattr(meta, "Section", "N/A") - subsection = getattr(meta, "SubSection", "N/A") - title = getattr(meta, "Title", "N/A") - - if section not in sections: - sections[section] = {} - if subsection not in sections[section]: - sections[section][subsection] = [] - - sections[section][subsection].append({"id": requirement_id, "title": title}) - - section_num = 1 - for section_name, subsections in sections.items(): - elements.append(Paragraph(f"{section_num}. {section_name}", h2)) - - subsection_num = 1 - for subsection_name, requirements in subsections.items(): - elements.append(Paragraph(f"{subsection_name}", h3)) - - req_num = 1 - for req in requirements: - elements.append(Paragraph(f"{req['id']} - {req['title']}", normal)) - req_num += 1 - - subsection_num += 1 - - section_num += 1 - elements.append(Spacer(1, 0.1 * inch)) - - elements.append(PageBreak()) - - # Add critical failed requirements section - elements.append(Paragraph("Top Requirements by Level of Risk", h1)) - elements.append(Spacer(1, 0.1 * inch)) - elements.append( - Paragraph( - f"Critical Failed Requirements (Risk Level ≥ {min_risk_level})", h2 - ) - ) - elements.append(Spacer(1, 0.2 * inch)) - - critical_failed_requirements = [] - for requirement in requirements_list: - requirement_status = requirement["attributes"]["status"] - if requirement_status == StatusChoices.FAIL: - requirement_id = requirement["id"] - metadata = ( - attributes_by_requirement_id.get(requirement_id, {}) - .get("attributes", {}) - .get("req_attributes", [{}])[0] - ) - if metadata: - risk_level = getattr(metadata, "LevelOfRisk", 0) - weight = getattr(metadata, "Weight", 0) - - if risk_level >= min_risk_level: - critical_failed_requirements.append( - { - "requirement": requirement, - "attributes": attributes_by_requirement_id[ - requirement_id - ], - "risk_level": risk_level, - "weight": weight, - "metadata": metadata, - } - ) - - critical_failed_requirements.sort( - key=lambda x: (x["risk_level"], x["weight"]), reverse=True - ) - - if not critical_failed_requirements: - elements.append( - Paragraph( - "✅ No critical failed requirements found. Great job!", normal - ) - ) - else: - elements.append( - Paragraph( - f"Found {len(critical_failed_requirements)} critical failed requirements that require immediate attention:", - normal, - ) - ) - elements.append(Spacer(1, 0.5 * inch)) - - table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] - - for idx, critical_failed_requirement in enumerate( - critical_failed_requirements - ): - requirement_id = critical_failed_requirement["requirement"]["id"] - risk_level = critical_failed_requirement["risk_level"] - weight = critical_failed_requirement["weight"] - title = getattr(critical_failed_requirement["metadata"], "Title", "N/A") - section = getattr( - critical_failed_requirement["metadata"], "Section", "N/A" - ) - - if len(title) > 50: - title = title[:47] + "..." - - table_data.append( - [str(risk_level), str(weight), requirement_id, title, section] - ) - - critical_table = Table( - table_data, - colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch], - ) - - critical_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("FONTSIZE", (0, 0), (-1, 0), 10), - ("BACKGROUND", (0, 1), (0, -1), colors.Color(0.8, 0.2, 0.2)), - ("TEXTCOLOR", (0, 1), (0, -1), colors.white), - ("FONTNAME", (0, 1), (0, -1), "FiraCode"), - ("ALIGN", (0, 1), (0, -1), "CENTER"), - ("FONTSIZE", (0, 1), (0, -1), 12), - ("ALIGN", (1, 1), (1, -1), "CENTER"), - ("FONTNAME", (1, 1), (1, -1), "FiraCode"), - ("FONTNAME", (2, 1), (2, -1), "FiraCode"), - ("FONTSIZE", (2, 1), (2, -1), 9), - ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), - ("FONTSIZE", (3, 1), (-1, -1), 8), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), - ("LEFTPADDING", (0, 0), (-1, -1), 6), - ("RIGHTPADDING", (0, 0), (-1, -1), 6), - ("TOPPADDING", (0, 0), (-1, -1), 8), - ("BOTTOMPADDING", (0, 0), (-1, -1), 8), - ( - "BACKGROUND", - (1, 1), - (-1, -1), - colors.Color(0.98, 0.98, 0.98), - ), - ] - ) - ) - - for idx, critical_failed_requirement in enumerate( - critical_failed_requirements - ): - row_idx = idx + 1 - weight = critical_failed_requirement["weight"] - - if weight >= 150: - weight_color = colors.Color(0.8, 0.2, 0.2) - elif weight >= 100: - weight_color = colors.Color(0.9, 0.6, 0.2) - else: - weight_color = colors.Color(0.9, 0.9, 0.2) - - critical_table.setStyle( - TableStyle( - [ - ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), - ("TEXTCOLOR", (1, row_idx), (1, row_idx), colors.white), - ] - ) - ) - - elements.append(critical_table) - elements.append(Spacer(1, 0.2 * inch)) - - # Get styles for warning - styles = getSampleStyleSheet() - warning_text = """ - IMMEDIATE ACTION REQUIRED:
- These requirements have the highest risk levels and have failed compliance checks. - Please prioritize addressing these issues to improve your security posture. - """ - - warning_style = ParagraphStyle( - "Warning", - parent=styles["Normal"], - fontSize=11, - textColor=colors.Color(0.8, 0.2, 0.2), - spaceBefore=10, - spaceAfter=10, - leftIndent=20, - rightIndent=20, - fontName="PlusJakartaSans", - backColor=colors.Color(1.0, 0.95, 0.95), - borderWidth=2, - borderColor=colors.Color(0.8, 0.2, 0.2), - borderPadding=10, - ) - - elements.append(Paragraph(warning_text, warning_style)) - - elements.append(PageBreak()) - - # Add detailed requirements section - def get_weight_for_requirement(requirement_dict): - requirement_id = requirement_dict["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata: - return getattr(metadata[0], "Weight", 0) - return 0 - - sorted_requirements = sorted( - requirements_list, key=get_weight_for_requirement, reverse=True - ) - - if only_failed: - sorted_requirements = [ - requirement - for requirement in sorted_requirements - if requirement["attributes"]["status"] == StatusChoices.FAIL - ] - - # Collect all check IDs for requirements that will be displayed - # This allows us to load only the findings we actually need (memory optimization) - check_ids_to_load = [] - for requirement in sorted_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - check_ids = requirement_attributes.get("attributes", {}).get("checks", []) - check_ids_to_load.extend(check_ids) - - # Load findings on-demand only for the checks that will be displayed - logger.info( - f"Loading findings on-demand for {len(sorted_requirements)} requirements" - ) - findings_by_check_id = _load_findings_for_requirement_checks( - tenant_id, scan_id, check_ids_to_load, prowler_provider - ) - - for requirement in sorted_requirements: - requirement_id = requirement["id"] - requirement_attributes = attributes_by_requirement_id.get( - requirement_id, {} - ) - requirement_description = requirement["attributes"]["description"] - requirement_status = requirement["attributes"]["status"] - - elements.append( - Paragraph( - f"{requirement_id}: {requirement_attributes.get('description', requirement_description)}", - h1, - ) - ) - - status_component = _create_status_component(requirement_status) - elements.append(status_component) - elements.append(Spacer(1, 0.1 * inch)) - - metadata = requirement_attributes.get("attributes", {}).get( - "req_attributes", [] - ) - if metadata and len(metadata) > 0: - m = metadata[0] - elements.append(Paragraph("Title: ", h3)) - elements.append(Paragraph(f"{getattr(m, 'Title', 'N/A')}", normal)) - elements.append(Paragraph("Section: ", h3)) - elements.append(Paragraph(f"{getattr(m, 'Section', 'N/A')}", normal)) - elements.append(Paragraph("SubSection: ", h3)) - elements.append(Paragraph(f"{getattr(m, 'SubSection', 'N/A')}", normal)) - elements.append(Paragraph("Description: ", h3)) - elements.append( - Paragraph(f"{getattr(m, 'AttributeDescription', 'N/A')}", normal) - ) - elements.append(Paragraph("Additional Information: ", h3)) - elements.append( - Paragraph(f"{getattr(m, 'AdditionalInformation', 'N/A')}", normal) - ) - elements.append(Spacer(1, 0.1 * inch)) - - risk_level = getattr(m, "LevelOfRisk", 0) - weight = getattr(m, "Weight", 0) - - if requirement_status == StatusChoices.PASS: - score = risk_level * weight - else: - score = 0 - - risk_component = _create_risk_component(risk_level, weight, score) - elements.append(risk_component) - elements.append(Spacer(1, 0.1 * inch)) - - # Get findings for this requirement's checks (loaded on-demand earlier) - requirement_check_ids = requirement_attributes.get("attributes", {}).get( - "checks", [] - ) - for check_id in requirement_check_ids: - elements.append(Paragraph(f"Check: {check_id}", h2)) - elements.append(Spacer(1, 0.1 * inch)) - - # Get findings for this check (already loaded on-demand) - check_findings = findings_by_check_id.get(check_id, []) - - if not check_findings: - elements.append( - Paragraph("- No information for this finding currently", normal) - ) - else: - findings_table_data = [ - [ - "Finding", - "Resource name", - "Severity", - "Status", - "Region", - ] - ] - for finding_output in check_findings: - check_metadata = getattr(finding_output, "metadata", {}) - finding_title = getattr( - check_metadata, - "CheckTitle", - getattr(finding_output, "check_id", ""), - ) - resource_name = getattr(finding_output, "resource_name", "") - if not resource_name: - resource_name = getattr(finding_output, "resource_uid", "") - severity = getattr(check_metadata, "Severity", "").capitalize() - finding_status = getattr(finding_output, "status", "").upper() - region = getattr(finding_output, "region", "global") - - findings_table_data.append( - [ - Paragraph(finding_title, normal_center), - Paragraph(resource_name, normal_center), - Paragraph(severity, normal_center), - Paragraph(finding_status, normal_center), - Paragraph(region, normal_center), - ] - ) - findings_table = Table( - findings_table_data, - colWidths=[ - 2.5 * inch, - 3 * inch, - 0.9 * inch, - 0.9 * inch, - 0.9 * inch, - ], - ) - findings_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - colors.Color(0.2, 0.4, 0.6), - ), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), - ("ALIGN", (0, 0), (0, 0), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTSIZE", (0, 0), (-1, -1), 9), - ( - "GRID", - (0, 0), - (-1, -1), - 0.1, - colors.Color(0.7, 0.8, 0.9), - ), - ("LEFTPADDING", (0, 0), (0, 0), 0), - ("RIGHTPADDING", (0, 0), (0, 0), 0), - ("TOPPADDING", (0, 0), (-1, -1), 4), - ("BOTTOMPADDING", (0, 0), (-1, -1), 4), - ] - ) - ) - elements.append(findings_table) - elements.append(Spacer(1, 0.1 * inch)) - - elements.append(PageBreak()) - - # Build the PDF - doc.build(elements, onFirstPage=_add_pdf_footer, onLaterPages=_add_pdf_footer) - except Exception as e: - logger.info( - f"Error building the document, line {e.__traceback__.tb_lineno} -- {e}" - ) - raise e - - -def generate_threatscore_report_job( - tenant_id: str, scan_id: str, provider_id: str -) -> dict[str, bool | str]: +def generate_ens_report( + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + include_manual: bool = True, + provider_obj: Provider | None = None, + requirement_statistics: dict[str, dict[str, int]] | None = None, + findings_cache: dict[str, list[FindingOutput]] | None = None, +) -> None: """ - Job function to generate a threatscore report and upload it to S3. - - This function orchestrates the complete report generation workflow: - 1. Validates that the scan has findings - 2. Checks provider type compatibility - 3. Generates the output directory - 4. Calls generate_threatscore_report to create the PDF - 5. Uploads the PDF to S3 - 6. Cleans up temporary files + Generate a PDF compliance report for ENS RD2022 framework. Args: - tenant_id (str): The tenant ID for Row-Level Security context. - scan_id (str): The ID of the scan to generate a report for. - provider_id (str): The ID of the provider used in the scan. + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the compliance framework (e.g., "ens_rd2022_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + include_manual: If True, include manual requirements in detailed section. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. + """ + generator = ENSReportGenerator(FRAMEWORK_REGISTRY["ens"]) + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + include_manual=include_manual, + ) + + +def generate_nis2_report( + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + only_failed: bool = True, + include_manual: bool = False, + provider_obj: Provider | None = None, + requirement_statistics: dict[str, dict[str, int]] | None = None, + findings_cache: dict[str, list[FindingOutput]] | None = None, +) -> None: + """ + Generate a PDF compliance report for NIS2 Directive (EU) 2022/2555. + + Args: + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the compliance framework (e.g., "nis2_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + only_failed: If True, only include failed requirements in detailed section. + include_manual: If True, include manual requirements in detailed section. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. + """ + generator = NIS2ReportGenerator(FRAMEWORK_REGISTRY["nis2"]) + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + only_failed=only_failed, + include_manual=include_manual, + ) + + +def generate_compliance_reports( + tenant_id: str, + scan_id: str, + provider_id: str, + generate_threatscore: bool = True, + generate_ens: bool = True, + generate_nis2: bool = True, + only_failed_threatscore: bool = True, + min_risk_level_threatscore: int = 4, + include_manual_ens: bool = True, + include_manual_nis2: bool = False, + only_failed_nis2: bool = True, +) -> dict[str, dict[str, bool | str]]: + """ + Generate multiple compliance reports with shared database queries. + + This function optimizes the generation of multiple reports by: + - Fetching the provider object once + - Aggregating requirement statistics once (shared across all reports) + - Reusing compliance framework data when possible + + Args: + tenant_id: The tenant ID for Row-Level Security context. + scan_id: The ID of the scan to generate reports for. + provider_id: The ID of the provider used in the scan. + generate_threatscore: Whether to generate ThreatScore report. + generate_ens: Whether to generate ENS report. + generate_nis2: Whether to generate NIS2 report. + only_failed_threatscore: For ThreatScore, only include failed requirements. + min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements. + include_manual_ens: For ENS, include manual requirements. + include_manual_nis2: For NIS2, include manual requirements. + only_failed_nis2: For NIS2, only include failed requirements. Returns: - dict[str, bool | str]: A dictionary containing: - - 'upload' (bool): True if the report was successfully uploaded to S3, False otherwise. - - 'error' (str): Error message if an exception occurred (only present on error). + Dictionary with results for each report type. """ - # Check if the scan has findings and get provider info + logger.info( + "Generating compliance reports for scan %s with provider %s" + " (ThreatScore: %s, ENS: %s, NIS2: %s)", + scan_id, + provider_id, + generate_threatscore, + generate_ens, + generate_nis2, + ) + + results = {} + + # Validate that the scan has findings and get provider info with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): if not ScanSummary.objects.filter(scan_id=scan_id).exists(): - logger.info(f"No findings found for scan {scan_id}") - return {"upload": False} + logger.info("No findings found for scan %s", scan_id) + if generate_threatscore: + results["threatscore"] = {"upload": False, "path": ""} + if generate_ens: + results["ens"] = {"upload": False, "path": ""} + if generate_nis2: + results["nis2"] = {"upload": False, "path": ""} + return results provider_obj = Provider.objects.get(id=provider_id) provider_uid = provider_obj.uid provider_type = provider_obj.provider - if provider_type not in ["aws", "azure", "gcp", "m365"]: - logger.info( - f"Provider {provider_id} is not supported for threatscore report" - ) - return {"upload": False} + # Check provider compatibility + if generate_threatscore and provider_type not in [ + "aws", + "azure", + "gcp", + "m365", + "kubernetes", + "alibabacloud", + ]: + logger.info("Provider %s not supported for ThreatScore report", provider_type) + results["threatscore"] = {"upload": False, "path": ""} + generate_threatscore = False - # This compliance is hardcoded because is the only one that is available for the threatscore report - compliance_id = f"prowler_threatscore_{provider_type}" + if generate_ens and provider_type not in ["aws", "azure", "gcp"]: + logger.info("Provider %s not supported for ENS report", provider_type) + results["ens"] = {"upload": False, "path": ""} + generate_ens = False + + if generate_nis2 and provider_type not in ["aws", "azure", "gcp"]: + logger.info("Provider %s not supported for NIS2 report", provider_type) + results["nis2"] = {"upload": False, "path": ""} + generate_nis2 = False + + if not generate_threatscore and not generate_ens and not generate_nis2: + return results + + # Aggregate requirement statistics once logger.info( - f"Generating threatscore report for scan {scan_id} with compliance {compliance_id} inside the job" + "Aggregating requirement statistics once for all reports (scan %s)", scan_id + ) + requirement_statistics = _aggregate_requirement_statistics_from_database( + tenant_id, scan_id ) - try: - logger.info("Generating the output directory") - out_dir, _, threatscore_path = _generate_output_directory( - DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id - ) - except Exception as e: - logger.error(f"Error generating output directory: {e}") - return {"error": str(e)} - pdf_path = f"{threatscore_path}_threatscore_report.pdf" - logger.info(f"The path for the threatscore report is {pdf_path}") - generate_threatscore_report( + # Create shared findings cache + findings_cache = {} + logger.info("Created shared findings cache for all reports") + + # Generate output directories + try: + logger.info("Generating output directories") + threatscore_path = _generate_compliance_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, + provider_uid, + tenant_id, + scan_id, + compliance_framework="threatscore", + ) + ens_path = _generate_compliance_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, + provider_uid, + tenant_id, + scan_id, + compliance_framework="ens", + ) + nis2_path = _generate_compliance_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, + provider_uid, + tenant_id, + scan_id, + compliance_framework="nis2", + ) + out_dir = str(Path(threatscore_path).parent.parent) + except Exception as e: + logger.error("Error generating output directory: %s", e) + error_dict = {"error": str(e), "upload": False, "path": ""} + if generate_threatscore: + results["threatscore"] = error_dict.copy() + if generate_ens: + results["ens"] = error_dict.copy() + if generate_nis2: + results["nis2"] = error_dict.copy() + return results + + # Generate ThreatScore report + if generate_threatscore: + compliance_id_threatscore = f"prowler_threatscore_{provider_type}" + pdf_path_threatscore = f"{threatscore_path}_threatscore_report.pdf" + logger.info( + "Generating ThreatScore report with compliance %s", + compliance_id_threatscore, + ) + + try: + generate_threatscore_report( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id_threatscore, + output_path=pdf_path_threatscore, + provider_id=provider_id, + only_failed=only_failed_threatscore, + min_risk_level=min_risk_level_threatscore, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + ) + + # Compute and store ThreatScore metrics snapshot + logger.info("Computing ThreatScore metrics for scan %s", scan_id) + try: + metrics = compute_threatscore_metrics( + tenant_id=tenant_id, + scan_id=scan_id, + provider_id=provider_id, + compliance_id=compliance_id_threatscore, + min_risk_level=min_risk_level_threatscore, + ) + + with rls_transaction(tenant_id): + previous_snapshot = ( + ThreatScoreSnapshot.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + compliance_id=compliance_id_threatscore, + ) + .order_by("-inserted_at") + .first() + ) + + score_delta = None + if previous_snapshot: + score_delta = metrics["overall_score"] - float( + previous_snapshot.overall_score + ) + + snapshot = ThreatScoreSnapshot.objects.create( + tenant_id=tenant_id, + scan_id=scan_id, + provider_id=provider_id, + compliance_id=compliance_id_threatscore, + overall_score=metrics["overall_score"], + score_delta=score_delta, + section_scores=metrics["section_scores"], + critical_requirements=metrics["critical_requirements"], + total_requirements=metrics["total_requirements"], + passed_requirements=metrics["passed_requirements"], + failed_requirements=metrics["failed_requirements"], + manual_requirements=metrics["manual_requirements"], + total_findings=metrics["total_findings"], + passed_findings=metrics["passed_findings"], + failed_findings=metrics["failed_findings"], + ) + + delta_msg = ( + f" (delta: {score_delta:+.2f}%)" + if score_delta is not None + else "" + ) + logger.info( + f"ThreatScore snapshot created with ID {snapshot.id} (score: {snapshot.overall_score}%{delta_msg})", + ) + except Exception as e: + logger.error("Error creating ThreatScore snapshot: %s", e) + + upload_uri_threatscore = _upload_to_s3( + tenant_id, + scan_id, + pdf_path_threatscore, + f"threatscore/{Path(pdf_path_threatscore).name}", + ) + + if upload_uri_threatscore: + results["threatscore"] = { + "upload": True, + "path": upload_uri_threatscore, + } + logger.info("ThreatScore report uploaded to %s", upload_uri_threatscore) + else: + results["threatscore"] = {"upload": False, "path": out_dir} + logger.warning("ThreatScore report saved locally at %s", out_dir) + + except Exception as e: + logger.error("Error generating ThreatScore report: %s", e) + results["threatscore"] = {"upload": False, "path": "", "error": str(e)} + + # Generate ENS report + if generate_ens: + compliance_id_ens = f"ens_rd2022_{provider_type}" + pdf_path_ens = f"{ens_path}_ens_report.pdf" + logger.info("Generating ENS report with compliance %s", compliance_id_ens) + + try: + generate_ens_report( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id_ens, + output_path=pdf_path_ens, + provider_id=provider_id, + include_manual=include_manual_ens, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + ) + + upload_uri_ens = _upload_to_s3( + tenant_id, scan_id, pdf_path_ens, f"ens/{Path(pdf_path_ens).name}" + ) + + if upload_uri_ens: + results["ens"] = {"upload": True, "path": upload_uri_ens} + logger.info("ENS report uploaded to %s", upload_uri_ens) + else: + results["ens"] = {"upload": False, "path": out_dir} + logger.warning("ENS report saved locally at %s", out_dir) + + except Exception as e: + logger.error("Error generating ENS report: %s", e) + results["ens"] = {"upload": False, "path": "", "error": str(e)} + + # Generate NIS2 report + if generate_nis2: + compliance_id_nis2 = f"nis2_{provider_type}" + pdf_path_nis2 = f"{nis2_path}_nis2_report.pdf" + logger.info("Generating NIS2 report with compliance %s", compliance_id_nis2) + + try: + generate_nis2_report( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id_nis2, + output_path=pdf_path_nis2, + provider_id=provider_id, + only_failed=only_failed_nis2, + include_manual=include_manual_nis2, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + ) + + upload_uri_nis2 = _upload_to_s3( + tenant_id, scan_id, pdf_path_nis2, f"nis2/{Path(pdf_path_nis2).name}" + ) + + if upload_uri_nis2: + results["nis2"] = {"upload": True, "path": upload_uri_nis2} + logger.info("NIS2 report uploaded to %s", upload_uri_nis2) + else: + results["nis2"] = {"upload": False, "path": out_dir} + logger.warning("NIS2 report saved locally at %s", out_dir) + + except Exception as e: + logger.error("Error generating NIS2 report: %s", e) + results["nis2"] = {"upload": False, "path": "", "error": str(e)} + + # Clean up temporary files if all reports were uploaded successfully + all_uploaded = all( + result.get("upload", False) + for result in results.values() + if result.get("upload") is not None + ) + + if all_uploaded: + try: + rmtree(Path(out_dir), ignore_errors=True) + logger.info("Cleaned up temporary files at %s", out_dir) + except Exception as e: + logger.error("Error deleting output files: %s", e) + + logger.info("Compliance reports generation completed. Results: %s", results) + return results + + +def generate_compliance_reports_job( + tenant_id: str, + scan_id: str, + provider_id: str, + generate_threatscore: bool = True, + generate_ens: bool = True, + generate_nis2: bool = True, +) -> dict[str, dict[str, bool | str]]: + """ + Celery task wrapper for generate_compliance_reports. + + Args: + tenant_id: The tenant ID for Row-Level Security context. + scan_id: The ID of the scan to generate reports for. + provider_id: The ID of the provider used in the scan. + generate_threatscore: Whether to generate ThreatScore report. + generate_ens: Whether to generate ENS report. + generate_nis2: Whether to generate NIS2 report. + + Returns: + Dictionary with results for each report type. + """ + return generate_compliance_reports( tenant_id=tenant_id, scan_id=scan_id, - compliance_id=compliance_id, - output_path=pdf_path, provider_id=provider_id, - only_failed=True, - min_risk_level=4, + generate_threatscore=generate_threatscore, + generate_ens=generate_ens, + generate_nis2=generate_nis2, ) - - upload_uri = _upload_to_s3( - tenant_id, - scan_id, - pdf_path, - f"threatscore/{Path(pdf_path).name}", - ) - if upload_uri: - try: - rmtree(Path(pdf_path).parent, ignore_errors=True) - except Exception as e: - logger.error(f"Error deleting output files: {e}") - final_location, did_upload = upload_uri, True - else: - final_location, did_upload = out_dir, False - - logger.info(f"Threatscore report outputs at {final_location}") - - return {"upload": did_upload} diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py new file mode 100644 index 0000000000..60602b93ab --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/__init__.py @@ -0,0 +1,186 @@ +# Base classes and data structures +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + RequirementData, + create_pdf_styles, + get_requirement_metadata, +) + +# Chart functions +from .charts import ( + create_horizontal_bar_chart, + create_pie_chart, + create_radar_chart, + create_stacked_bar_chart, + create_vertical_bar_chart, + get_chart_color_for_percentage, +) + +# Reusable components +# Reusable components: Color helpers, Badge components, Risk component, +# Table components, Section components +from .components import ( + ColumnConfig, + create_badge, + create_data_table, + create_findings_table, + create_info_table, + create_multi_badge_row, + create_risk_component, + create_section_header, + create_status_badge, + create_summary_table, + get_color_for_compliance, + get_color_for_risk_level, + get_color_for_weight, + get_status_color, +) + +# Framework configuration: Main configuration, Color constants, ENS colors, +# NIS2 colors, Chart colors, ENS constants, Section constants, Layout constants +from .config import ( + CHART_COLOR_BLUE, + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COL_WIDTH_LARGE, + COL_WIDTH_MEDIUM, + COL_WIDTH_SMALL, + COL_WIDTH_XLARGE, + COL_WIDTH_XXLARGE, + COLOR_BG_BLUE, + COLOR_BG_LIGHT_BLUE, + COLOR_BLUE, + COLOR_DARK_GRAY, + COLOR_ENS_ALTO, + COLOR_ENS_BAJO, + COLOR_ENS_MEDIO, + COLOR_ENS_OPCIONAL, + COLOR_GRAY, + COLOR_HIGH_RISK, + COLOR_LIGHT_BLUE, + COLOR_LIGHT_GRAY, + COLOR_LIGHTER_BLUE, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_NIS2_PRIMARY, + COLOR_NIS2_SECONDARY, + COLOR_PROWLER_DARK_GREEN, + COLOR_SAFE, + COLOR_WHITE, + DIMENSION_KEYS, + DIMENSION_MAPPING, + DIMENSION_NAMES, + ENS_NIVEL_ORDER, + ENS_TIPO_ORDER, + FRAMEWORK_REGISTRY, + NIS2_SECTION_TITLES, + NIS2_SECTIONS, + PADDING_LARGE, + PADDING_MEDIUM, + PADDING_SMALL, + PADDING_XLARGE, + THREATSCORE_SECTIONS, + TIPO_ICONS, + FrameworkConfig, + get_framework_config, +) + +# Framework-specific generators +from .ens import ENSReportGenerator +from .nis2 import NIS2ReportGenerator +from .threatscore import ThreatScoreReportGenerator + +__all__ = [ + # Base classes + "BaseComplianceReportGenerator", + "ComplianceData", + "RequirementData", + "create_pdf_styles", + "get_requirement_metadata", + # Framework-specific generators + "ThreatScoreReportGenerator", + "ENSReportGenerator", + "NIS2ReportGenerator", + # Configuration + "FrameworkConfig", + "FRAMEWORK_REGISTRY", + "get_framework_config", + # Color constants + "COLOR_BLUE", + "COLOR_LIGHT_BLUE", + "COLOR_LIGHTER_BLUE", + "COLOR_BG_BLUE", + "COLOR_BG_LIGHT_BLUE", + "COLOR_GRAY", + "COLOR_LIGHT_GRAY", + "COLOR_DARK_GRAY", + "COLOR_WHITE", + "COLOR_HIGH_RISK", + "COLOR_MEDIUM_RISK", + "COLOR_LOW_RISK", + "COLOR_SAFE", + "COLOR_PROWLER_DARK_GREEN", + "COLOR_ENS_ALTO", + "COLOR_ENS_MEDIO", + "COLOR_ENS_BAJO", + "COLOR_ENS_OPCIONAL", + "COLOR_NIS2_PRIMARY", + "COLOR_NIS2_SECONDARY", + "CHART_COLOR_BLUE", + "CHART_COLOR_GREEN_1", + "CHART_COLOR_GREEN_2", + "CHART_COLOR_YELLOW", + "CHART_COLOR_ORANGE", + "CHART_COLOR_RED", + # ENS constants + "DIMENSION_MAPPING", + "DIMENSION_NAMES", + "DIMENSION_KEYS", + "ENS_NIVEL_ORDER", + "ENS_TIPO_ORDER", + "TIPO_ICONS", + # Section constants + "THREATSCORE_SECTIONS", + "NIS2_SECTIONS", + "NIS2_SECTION_TITLES", + # Layout constants + "COL_WIDTH_SMALL", + "COL_WIDTH_MEDIUM", + "COL_WIDTH_LARGE", + "COL_WIDTH_XLARGE", + "COL_WIDTH_XXLARGE", + "PADDING_SMALL", + "PADDING_MEDIUM", + "PADDING_LARGE", + "PADDING_XLARGE", + # Color helpers + "get_color_for_risk_level", + "get_color_for_weight", + "get_color_for_compliance", + "get_status_color", + # Badge components + "create_badge", + "create_status_badge", + "create_multi_badge_row", + # Risk component + "create_risk_component", + # Table components + "create_info_table", + "create_data_table", + "create_findings_table", + "ColumnConfig", + # Section components + "create_section_header", + "create_summary_table", + # Chart functions + "get_chart_color_for_percentage", + "create_vertical_bar_chart", + "create_horizontal_bar_chart", + "create_radar_chart", + "create_pie_chart", + "create_stacked_bar_chart", +] diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py new file mode 100644 index 0000000000..42776681fb --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -0,0 +1,911 @@ +import gc +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from celery.utils.log import get_task_logger +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas +from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer +from tasks.jobs.threatscore_utils import ( + _aggregate_requirement_statistics_from_database, + _calculate_requirements_data_from_statistics, + _load_findings_for_requirement_checks, +) + +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Provider, StatusChoices +from api.utils import initialize_prowler_provider +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.finding import Finding as FindingOutput + +from .components import ( + ColumnConfig, + create_data_table, + create_info_table, + create_status_badge, +) +from .config import ( + COLOR_BG_BLUE, + COLOR_BG_LIGHT_BLUE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_GRAY, + COLOR_LIGHT_BLUE, + COLOR_LIGHTER_BLUE, + COLOR_PROWLER_DARK_GREEN, + PADDING_LARGE, + PADDING_SMALL, + FrameworkConfig, +) + +logger = get_task_logger(__name__) + +# Register fonts (done once at module load) +_fonts_registered: bool = False + + +def _register_fonts() -> None: + """Register custom fonts for PDF generation. + + Uses a module-level flag to ensure fonts are only registered once, + avoiding duplicate registration errors from reportlab. + """ + global _fonts_registered + if _fonts_registered: + return + + fonts_dir = os.path.join(os.path.dirname(__file__), "../../assets/fonts") + + pdfmetrics.registerFont( + TTFont( + "PlusJakartaSans", + os.path.join(fonts_dir, "PlusJakartaSans-Regular.ttf"), + ) + ) + + pdfmetrics.registerFont( + TTFont( + "FiraCode", + os.path.join(fonts_dir, "FiraCode-Regular.ttf"), + ) + ) + + _fonts_registered = True + + +# ============================================================================= +# Data Classes +# ============================================================================= + + +@dataclass +class RequirementData: + """Data for a single compliance requirement. + + Attributes: + id: Requirement identifier + description: Requirement description + status: Compliance status (PASS, FAIL, MANUAL) + passed_findings: Number of passed findings + failed_findings: Number of failed findings + total_findings: Total number of findings + checks: List of check IDs associated with this requirement + attributes: Framework-specific requirement attributes + """ + + id: str + description: str + status: str + passed_findings: int = 0 + failed_findings: int = 0 + total_findings: int = 0 + checks: list[str] = field(default_factory=list) + attributes: Any = None + + +@dataclass +class ComplianceData: + """Aggregated compliance data for report generation. + + This dataclass holds all the data needed to generate a compliance report, + including compliance framework metadata, requirements, and findings. + + Attributes: + tenant_id: Tenant identifier + scan_id: Scan identifier + provider_id: Provider identifier + compliance_id: Compliance framework identifier + framework: Framework name (e.g., "CIS", "ENS") + name: Full compliance framework name + version: Framework version + description: Framework description + requirements: List of RequirementData objects + attributes_by_requirement_id: Mapping of requirement IDs to their attributes + findings_by_check_id: Mapping of check IDs to their findings + provider_obj: Provider model object + prowler_provider: Initialized Prowler provider + """ + + tenant_id: str + scan_id: str + provider_id: str + compliance_id: str + framework: str + name: str + version: str + description: str + requirements: list[RequirementData] = field(default_factory=list) + attributes_by_requirement_id: dict[str, dict] = field(default_factory=dict) + findings_by_check_id: dict[str, list[FindingOutput]] = field(default_factory=dict) + provider_obj: Provider | None = None + prowler_provider: Any = None + + +def get_requirement_metadata( + requirement_id: str, + attributes_by_requirement_id: dict[str, dict], +) -> Any | None: + """Get the first requirement metadata object from attributes. + + This helper function extracts the requirement metadata (req_attributes) + from the attributes dictionary. It's a common pattern used across all + report generators. + + Args: + requirement_id: The requirement ID to look up. + attributes_by_requirement_id: Mapping of requirement IDs to their attributes. + + Returns: + The first requirement attribute object, or None if not found. + + Example: + >>> meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + >>> if meta: + ... section = getattr(meta, "Section", "Unknown") + """ + req_attrs = attributes_by_requirement_id.get(requirement_id, {}) + meta_list = req_attrs.get("attributes", {}).get("req_attributes", []) + if meta_list: + return meta_list[0] + return None + + +# ============================================================================= +# PDF Styles Cache +# ============================================================================= + +_PDF_STYLES_CACHE: dict[str, ParagraphStyle] | None = None + + +def create_pdf_styles() -> dict[str, ParagraphStyle]: + """Create and return PDF paragraph styles used throughout the report. + + Styles are cached on first call to improve performance. + + Returns: + Dictionary containing the following styles: + - 'title': Title style with prowler green color + - 'h1': Heading 1 style with blue color and background + - 'h2': Heading 2 style with light blue color + - 'h3': Heading 3 style for sub-headings + - 'normal': Normal text style with left indent + - 'normal_center': Normal text style without indent + """ + global _PDF_STYLES_CACHE + + if _PDF_STYLES_CACHE is not None: + return _PDF_STYLES_CACHE + + _register_fonts() + styles = getSampleStyleSheet() + + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Title"], + fontSize=24, + textColor=COLOR_PROWLER_DARK_GREEN, + spaceAfter=20, + fontName="PlusJakartaSans", + alignment=TA_CENTER, + ) + + h1 = ParagraphStyle( + "CustomH1", + parent=styles["Heading1"], + fontSize=18, + textColor=COLOR_BLUE, + spaceBefore=20, + spaceAfter=12, + fontName="PlusJakartaSans", + leftIndent=0, + borderWidth=2, + borderColor=COLOR_BLUE, + borderPadding=PADDING_LARGE, + backColor=COLOR_BG_BLUE, + ) + + h2 = ParagraphStyle( + "CustomH2", + parent=styles["Heading2"], + fontSize=14, + textColor=COLOR_LIGHT_BLUE, + spaceBefore=15, + spaceAfter=8, + fontName="PlusJakartaSans", + leftIndent=10, + borderWidth=1, + borderColor=COLOR_BORDER_GRAY, + borderPadding=5, + backColor=COLOR_BG_LIGHT_BLUE, + ) + + h3 = ParagraphStyle( + "CustomH3", + parent=styles["Heading3"], + fontSize=12, + textColor=COLOR_LIGHTER_BLUE, + spaceBefore=10, + spaceAfter=6, + fontName="PlusJakartaSans", + leftIndent=20, + ) + + normal = ParagraphStyle( + "CustomNormal", + parent=styles["Normal"], + fontSize=10, + textColor=COLOR_GRAY, + spaceBefore=PADDING_SMALL, + spaceAfter=PADDING_SMALL, + leftIndent=30, + fontName="PlusJakartaSans", + ) + + normal_center = ParagraphStyle( + "CustomNormalCenter", + parent=styles["Normal"], + fontSize=10, + textColor=COLOR_GRAY, + fontName="PlusJakartaSans", + ) + + _PDF_STYLES_CACHE = { + "title": title_style, + "h1": h1, + "h2": h2, + "h3": h3, + "normal": normal, + "normal_center": normal_center, + } + + return _PDF_STYLES_CACHE + + +# ============================================================================= +# Base Report Generator +# ============================================================================= + + +class BaseComplianceReportGenerator(ABC): + """Abstract base class for compliance PDF report generators. + + This class implements the Template Method pattern, providing a common + structure for all compliance reports while allowing subclasses to + customize specific sections. + + Subclasses must implement: + - create_executive_summary() + - create_charts_section() + - create_requirements_index() + + Optionally, subclasses can override: + - create_cover_page() + - create_detailed_findings() + - get_footer_text() + """ + + def __init__(self, config: FrameworkConfig): + """Initialize the report generator. + + Args: + config: Framework configuration + """ + self.config = config + self.styles = create_pdf_styles() + + # ========================================================================= + # Template Method + # ========================================================================= + + def generate( + self, + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + provider_obj: Provider | None = None, + requirement_statistics: dict[str, dict[str, int]] | None = None, + findings_cache: dict[str, list[FindingOutput]] | None = None, + **kwargs, + ) -> None: + """Generate the PDF compliance report. + + This is the template method that orchestrates the report generation. + It calls abstract methods that subclasses must implement. + + Args: + tenant_id: Tenant identifier for RLS context + scan_id: Scan identifier + compliance_id: Compliance framework identifier + output_path: Path where the PDF will be saved + provider_id: Provider identifier + provider_obj: Optional pre-fetched Provider object + requirement_statistics: Optional pre-aggregated statistics + findings_cache: Optional pre-loaded findings cache + **kwargs: Additional framework-specific arguments + """ + logger.info( + "Generating %s report for scan %s", self.config.display_name, scan_id + ) + + try: + # 1. Load compliance data + data = self._load_compliance_data( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + ) + + # 2. Create PDF document + doc = self._create_document(output_path, data) + + # 3. Build report elements incrementally to manage memory + # We collect garbage after heavy sections to prevent OOM on large reports + elements = [] + + # Cover page (lightweight) + elements.extend(self.create_cover_page(data)) + elements.append(PageBreak()) + + # Executive summary (framework-specific) + elements.extend(self.create_executive_summary(data)) + + # Body sections (charts + requirements index) + # Override _build_body_sections() in subclasses to change section order + elements.extend(self._build_body_sections(data)) + + # Detailed findings - heaviest section, loads findings on-demand + logger.info("Building detailed findings section...") + elements.extend(self.create_detailed_findings(data, **kwargs)) + gc.collect() # Free findings data after processing + + # 4. Build the PDF + logger.info("Building PDF document with %d elements...", len(elements)) + self._build_pdf(doc, elements, data) + + # Final cleanup + del elements + gc.collect() + + logger.info("Successfully generated report at %s", output_path) + + except Exception as e: + import traceback + + tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown" + logger.error("Error generating report, line %s -- %s", tb_lineno, e) + logger.error("Full traceback:\n%s", traceback.format_exc()) + raise + + def _build_body_sections(self, data: ComplianceData) -> list: + """Build the body sections between executive summary and detailed findings. + + Override in subclasses to change section order. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Charts section (framework-specific) - heavy on memory due to matplotlib + elements.extend(self.create_charts_section(data)) + elements.append(PageBreak()) + gc.collect() # Free matplotlib resources + + # Requirements index (framework-specific) + elements.extend(self.create_requirements_index(data)) + elements.append(PageBreak()) + + return elements + + # ========================================================================= + # Abstract Methods (must be implemented by subclasses) + # ========================================================================= + + @abstractmethod + def create_executive_summary(self, data: ComplianceData) -> list: + """Create the executive summary section. + + This section typically includes: + - Overall compliance score/metrics + - High-level statistics + - Critical findings summary + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + + @abstractmethod + def create_charts_section(self, data: ComplianceData) -> list: + """Create the charts and visualizations section. + + This section typically includes: + - Compliance score charts by section + - Distribution charts + - Trend visualizations + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + + @abstractmethod + def create_requirements_index(self, data: ComplianceData) -> list: + """Create the requirements index/table of contents. + + This section typically includes: + - Hierarchical list of requirements + - Status indicators + - Section groupings + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + + # ========================================================================= + # Common Methods (can be overridden by subclasses) + # ========================================================================= + + def create_cover_page(self, data: ComplianceData) -> list: + """Create the report cover page. + + Args: + data: Aggregated compliance data + + Returns: + List of ReportLab elements + """ + elements = [] + + # Prowler logo + logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + if os.path.exists(logo_path): + logo = Image(logo_path, width=5 * inch, height=1 * inch) + elements.append(logo) + + elements.append(Spacer(1, 0.5 * inch)) + + # Title + title_text = f"{self.config.display_name} Report" + elements.append(Paragraph(title_text, self.styles["title"])) + elements.append(Spacer(1, 0.5 * inch)) + + # Compliance info table + info_rows = self._build_info_rows(data, language=self.config.language) + + info_table = create_info_table( + rows=info_rows, + label_width=2 * inch, + value_width=4 * inch, + normal_style=self.styles["normal_center"], + ) + elements.append(info_table) + + return elements + + def _build_info_rows( + self, data: ComplianceData, language: str = "en" + ) -> list[tuple[str, str]]: + """Build the standard info rows for the cover page table. + + This helper method creates the common metadata rows used in all + report cover pages. Subclasses can use this to maintain consistency + while customizing other aspects of the cover page. + + Args: + data: Aggregated compliance data. + language: Language for labels ("en" or "es"). + + Returns: + List of (label, value) tuples for the info table. + """ + # Labels based on language + labels = { + "en": { + "framework": "Framework:", + "id": "ID:", + "name": "Name:", + "version": "Version:", + "provider": "Provider:", + "account_id": "Account ID:", + "alias": "Alias:", + "scan_id": "Scan ID:", + "description": "Description:", + }, + "es": { + "framework": "Framework:", + "id": "ID:", + "name": "Nombre:", + "version": "Versión:", + "provider": "Proveedor:", + "account_id": "Account ID:", + "alias": "Alias:", + "scan_id": "Scan ID:", + "description": "Descripción:", + }, + } + lang_labels = labels.get(language, labels["en"]) + + info_rows = [ + (lang_labels["framework"], data.framework), + (lang_labels["id"], data.compliance_id), + (lang_labels["name"], data.name), + (lang_labels["version"], data.version), + ] + + # Add provider info if available + if data.provider_obj: + info_rows.append( + (lang_labels["provider"], data.provider_obj.provider.upper()) + ) + info_rows.append( + (lang_labels["account_id"], data.provider_obj.uid or "N/A") + ) + info_rows.append((lang_labels["alias"], data.provider_obj.alias or "N/A")) + + info_rows.append((lang_labels["scan_id"], data.scan_id)) + + if data.description: + info_rows.append((lang_labels["description"], data.description)) + + return info_rows + + def create_detailed_findings(self, data: ComplianceData, **kwargs) -> list: + """Create the detailed findings section. + + This default implementation creates a requirement-by-requirement + breakdown with findings tables. Subclasses can override for + framework-specific presentation. + + This method implements on-demand loading of findings using the shared + findings cache to minimize database queries and memory usage. + + Args: + data: Aggregated compliance data + **kwargs: Framework-specific options (e.g., only_failed) + + Returns: + List of ReportLab elements + """ + elements = [] + only_failed = kwargs.get("only_failed", True) + include_manual = kwargs.get("include_manual", False) + + # Filter requirements if needed + requirements = data.requirements + if only_failed: + # Include FAIL requirements, and optionally MANUAL if include_manual is True + if include_manual: + requirements = [ + r + for r in requirements + if r.status in (StatusChoices.FAIL, StatusChoices.MANUAL) + ] + else: + requirements = [ + r for r in requirements if r.status == StatusChoices.FAIL + ] + + # Collect all check IDs for requirements that will be displayed + # This allows us to load only the findings we actually need (memory optimization) + check_ids_to_load = [] + for req in requirements: + check_ids_to_load.extend(req.checks) + + # Load findings on-demand only for the checks that will be displayed + # Uses the shared findings cache to avoid duplicate queries across reports + logger.info("Loading findings on-demand for %d requirements", len(requirements)) + findings_by_check_id = _load_findings_for_requirement_checks( + data.tenant_id, + data.scan_id, + check_ids_to_load, + data.prowler_provider, + data.findings_by_check_id, # Pass the cache to update it + ) + + for req in requirements: + # Requirement header + elements.append( + Paragraph( + f"{req.id}: {req.description}", + self.styles["h1"], + ) + ) + + # Status badge + elements.append(create_status_badge(req.status)) + elements.append(Spacer(1, 0.1 * inch)) + + # Findings for this requirement + for check_id in req.checks: + elements.append(Paragraph(f"Check: {check_id}", self.styles["h2"])) + + findings = findings_by_check_id.get(check_id, []) + if not findings: + elements.append( + Paragraph( + "- No information for this finding currently", + self.styles["normal"], + ) + ) + else: + # Create findings table + findings_table = self._create_findings_table(findings) + elements.append(findings_table) + + elements.append(Spacer(1, 0.1 * inch)) + + elements.append(PageBreak()) + + return elements + + def get_footer_text(self, page_num: int) -> tuple[str, str]: + """Get footer text for a page. + + Args: + page_num: Current page number + + Returns: + Tuple of (left_text, right_text) for the footer + """ + if self.config.language == "es": + page_text = f"Página {page_num}" + else: + page_text = f"Page {page_num}" + + return page_text, "Powered by Prowler" + + # ========================================================================= + # Private Helper Methods + # ========================================================================= + + def _load_compliance_data( + self, + tenant_id: str, + scan_id: str, + compliance_id: str, + provider_id: str, + provider_obj: Provider | None, + requirement_statistics: dict | None, + findings_cache: dict | None, + ) -> ComplianceData: + """Load and aggregate compliance data from the database. + + Args: + tenant_id: Tenant identifier + scan_id: Scan identifier + compliance_id: Compliance framework identifier + provider_id: Provider identifier + provider_obj: Optional pre-fetched Provider + requirement_statistics: Optional pre-aggregated statistics + findings_cache: Optional pre-loaded findings + + Returns: + Aggregated ComplianceData object + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Load provider + if provider_obj is None: + provider_obj = Provider.objects.get(id=provider_id) + + prowler_provider = initialize_prowler_provider(provider_obj) + provider_type = provider_obj.provider + + # Load compliance framework + frameworks_bulk = Compliance.get_bulk(provider_type) + compliance_obj = frameworks_bulk.get(compliance_id) + + if not compliance_obj: + raise ValueError(f"Compliance framework not found: {compliance_id}") + + framework = getattr(compliance_obj, "Framework", "N/A") + name = getattr(compliance_obj, "Name", "N/A") + version = getattr(compliance_obj, "Version", "N/A") + description = getattr(compliance_obj, "Description", "") + + # Aggregate requirement statistics + if requirement_statistics is None: + logger.info("Aggregating requirement statistics for scan %s", scan_id) + requirement_statistics = _aggregate_requirement_statistics_from_database( + tenant_id, scan_id + ) + else: + logger.info("Reusing pre-aggregated statistics for scan %s", scan_id) + + # Calculate requirements data + attributes_by_requirement_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics + ) + ) + + # Convert to RequirementData objects + requirements = [] + for req_dict in requirements_list: + req = RequirementData( + id=req_dict["id"], + description=req_dict["attributes"].get("description", ""), + status=req_dict["attributes"].get("status", StatusChoices.MANUAL), + passed_findings=req_dict["attributes"].get("passed_findings", 0), + failed_findings=req_dict["attributes"].get("failed_findings", 0), + total_findings=req_dict["attributes"].get("total_findings", 0), + checks=attributes_by_requirement_id.get(req_dict["id"], {}) + .get("attributes", {}) + .get("checks", []), + ) + requirements.append(req) + + return ComplianceData( + tenant_id=tenant_id, + scan_id=scan_id, + provider_id=provider_id, + compliance_id=compliance_id, + framework=framework, + name=name, + version=version, + description=description, + requirements=requirements, + attributes_by_requirement_id=attributes_by_requirement_id, + findings_by_check_id=findings_cache if findings_cache is not None else {}, + provider_obj=provider_obj, + prowler_provider=prowler_provider, + ) + + def _create_document( + self, output_path: str, data: ComplianceData + ) -> SimpleDocTemplate: + """Create the PDF document template. + + Args: + output_path: Path for the output PDF + data: Compliance data for metadata + + Returns: + Configured SimpleDocTemplate + """ + return SimpleDocTemplate( + output_path, + pagesize=letter, + title=f"{self.config.display_name} Report - {data.framework}", + author="Prowler", + subject=f"Compliance Report for {data.framework}", + creator="Prowler Engineering Team", + keywords=f"compliance,{data.framework},security,framework,prowler", + ) + + def _build_pdf( + self, + doc: SimpleDocTemplate, + elements: list, + data: ComplianceData, + ) -> None: + """Build the final PDF with footers. + + Args: + doc: Document template + elements: List of ReportLab elements + data: Compliance data + """ + + def add_footer( + canvas_obj: canvas.Canvas, + doc_template: SimpleDocTemplate, + ) -> None: + canvas_obj.saveState() + width, _ = doc_template.pagesize + left_text, right_text = self.get_footer_text(doc_template.page) + + canvas_obj.setFont("PlusJakartaSans", 9) + canvas_obj.setFillColorRGB(0.4, 0.4, 0.4) + canvas_obj.drawString(30, 20, left_text) + + text_width = canvas_obj.stringWidth(right_text, "PlusJakartaSans", 9) + canvas_obj.drawString(width - text_width - 30, 20, right_text) + canvas_obj.restoreState() + + doc.build( + elements, + onFirstPage=add_footer, + onLaterPages=add_footer, + ) + + def _create_findings_table(self, findings: list[FindingOutput]) -> Any: + """Create a findings table. + + Args: + findings: List of finding objects + + Returns: + ReportLab Table element + """ + + def get_finding_title(f): + metadata = getattr(f, "metadata", None) + if metadata: + return getattr(metadata, "CheckTitle", getattr(f, "check_id", "")) + return getattr(f, "check_id", "") + + def get_resource_name(f): + name = getattr(f, "resource_name", "") + if not name: + name = getattr(f, "resource_uid", "") + return name + + def get_severity(f): + metadata = getattr(f, "metadata", None) + if metadata: + return getattr(metadata, "Severity", "").capitalize() + return "" + + # Convert findings to dicts for the table + data = [] + for f in findings: + item = { + "title": get_finding_title(f), + "resource_name": get_resource_name(f), + "severity": get_severity(f), + "status": getattr(f, "status", "").upper(), + "region": getattr(f, "region", "global"), + } + data.append(item) + + columns = [ + ColumnConfig("Finding", 2.5 * inch, "title"), + ColumnConfig("Resource", 3 * inch, "resource_name"), + ColumnConfig("Severity", 0.9 * inch, "severity"), + ColumnConfig("Status", 0.9 * inch, "status"), + ColumnConfig("Region", 0.9 * inch, "region"), + ] + + return create_data_table( + data=data, + columns=columns, + header_color=self.config.primary_color, + normal_style=self.styles["normal_center"], + ) diff --git a/api/src/backend/tasks/jobs/reports/charts.py b/api/src/backend/tasks/jobs/reports/charts.py new file mode 100644 index 0000000000..0f0338acab --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/charts.py @@ -0,0 +1,404 @@ +import gc +import io +import math +from typing import Callable + +import matplotlib + +# Use non-interactive Agg backend for memory efficiency in server environments +# This MUST be set before importing pyplot +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +from .config import ( # noqa: E402 + CHART_COLOR_BLUE, + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + CHART_DPI_DEFAULT, +) + +# Use centralized DPI setting from config +DEFAULT_CHART_DPI = CHART_DPI_DEFAULT + + +def get_chart_color_for_percentage(percentage: float) -> str: + """Get chart color string based on percentage. + + Args: + percentage: Value between 0 and 100 + + Returns: + Hex color string for matplotlib + """ + if percentage >= 80: + return CHART_COLOR_GREEN_1 + if percentage >= 60: + return CHART_COLOR_GREEN_2 + if percentage >= 40: + return CHART_COLOR_YELLOW + if percentage >= 20: + return CHART_COLOR_ORANGE + return CHART_COLOR_RED + + +def create_vertical_bar_chart( + labels: list[str], + values: list[float], + ylabel: str = "Compliance Score (%)", + xlabel: str = "Section", + title: str | None = None, + color_func: Callable[[float], str] | None = None, + colors: list[str] | None = None, + figsize: tuple[int, int] = (10, 6), + dpi: int = DEFAULT_CHART_DPI, + y_limit: tuple[float, float] = (0, 100), + show_labels: bool = True, + rotation: int = 45, +) -> io.BytesIO: + """Create a vertical bar chart. + + Args: + labels: X-axis labels + values: Bar heights (numeric values) + ylabel: Y-axis label + xlabel: X-axis label + title: Optional chart title + color_func: Function to determine bar color based on value + colors: Explicit list of colors (overrides color_func) + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + y_limit: Y-axis limits (min, max) + show_labels: Whether to show value labels on bars + rotation: X-axis label rotation angle + + Returns: + BytesIO buffer containing the PNG image + """ + if color_func is None: + color_func = get_chart_color_for_percentage + + fig, ax = plt.subplots(figsize=figsize) + + # Determine colors + if colors is None: + colors_list = [color_func(v) for v in values] + else: + colors_list = colors + + bars = ax.bar(labels, values, color=colors_list) + + ax.set_ylabel(ylabel, fontsize=12) + ax.set_xlabel(xlabel, fontsize=12) + ax.set_ylim(*y_limit) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + # Add value labels on bars + if show_labels: + for bar_item, value in zip(bars, values): + height = bar_item.get_height() + ax.text( + bar_item.get_x() + bar_item.get_width() / 2.0, + height + 1, + f"{value:.1f}%", + ha="center", + va="bottom", + fontweight="bold", + ) + + plt.xticks(rotation=rotation, ha="right") + ax.grid(True, alpha=0.3, axis="y") + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_horizontal_bar_chart( + labels: list[str], + values: list[float], + xlabel: str = "Compliance (%)", + title: str | None = None, + color_func: Callable[[float], str] | None = None, + colors: list[str] | None = None, + figsize: tuple[int, int] | None = None, + dpi: int = DEFAULT_CHART_DPI, + x_limit: tuple[float, float] = (0, 100), + show_labels: bool = True, + label_fontsize: int = 16, +) -> io.BytesIO: + """Create a horizontal bar chart. + + Args: + labels: Y-axis labels (bar names) + values: Bar widths (numeric values) + xlabel: X-axis label + title: Optional chart title + color_func: Function to determine bar color based on value + colors: Explicit list of colors (overrides color_func) + figsize: Figure size (auto-calculated if None based on label count) + dpi: Resolution for output image + x_limit: X-axis limits (min, max) + show_labels: Whether to show value labels on bars + label_fontsize: Font size for y-axis labels + + Returns: + BytesIO buffer containing the PNG image + """ + if color_func is None: + color_func = get_chart_color_for_percentage + + # Auto-calculate figure size based on number of items + if figsize is None: + figsize = (10, max(6, int(len(labels) * 0.4))) + + fig, ax = plt.subplots(figsize=figsize) + + # Determine colors + if colors is None: + colors_list = [color_func(v) for v in values] + else: + colors_list = colors + + y_pos = range(len(labels)) + bars = ax.barh(y_pos, values, color=colors_list) + + ax.set_yticks(y_pos) + ax.set_yticklabels(labels, fontsize=label_fontsize) + ax.set_xlabel(xlabel, fontsize=14) + ax.set_xlim(*x_limit) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + # Add value labels + if show_labels: + for bar_item, value in zip(bars, values): + width = bar_item.get_width() + ax.text( + width + 1, + bar_item.get_y() + bar_item.get_height() / 2.0, + f"{value:.1f}%", + ha="left", + va="center", + fontweight="bold", + fontsize=10, + ) + + ax.grid(True, alpha=0.3, axis="x") + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_radar_chart( + labels: list[str], + values: list[float], + color: str = CHART_COLOR_BLUE, + fill_alpha: float = 0.25, + figsize: tuple[int, int] = (8, 8), + dpi: int = DEFAULT_CHART_DPI, + y_limit: tuple[float, float] = (0, 100), + y_ticks: list[int] | None = None, + label_fontsize: int = 14, + title: str | None = None, +) -> io.BytesIO: + """Create a radar/spider chart. + + Args: + labels: Category names around the chart + values: Values for each category (should have same length as labels) + color: Line and fill color + fill_alpha: Transparency of the fill (0-1) + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + y_limit: Radial axis limits (min, max) + y_ticks: Custom tick values for radial axis + label_fontsize: Font size for category labels + title: Optional chart title + + Returns: + BytesIO buffer containing the PNG image + """ + num_vars = len(labels) + angles = [n / float(num_vars) * 2 * math.pi for n in range(num_vars)] + + # Close the polygon + values_closed = list(values) + [values[0]] + angles_closed = angles + [angles[0]] + + fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"}) + + ax.plot(angles_closed, values_closed, "o-", linewidth=2, color=color) + ax.fill(angles_closed, values_closed, alpha=fill_alpha, color=color) + + ax.set_xticks(angles) + ax.set_xticklabels(labels, fontsize=label_fontsize) + ax.set_ylim(*y_limit) + + if y_ticks is None: + y_ticks = [20, 40, 60, 80, 100] + ax.set_yticks(y_ticks) + ax.set_yticklabels([f"{t}%" for t in y_ticks], fontsize=12) + + ax.grid(True, alpha=0.3) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold", y=1.08) + + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_pie_chart( + labels: list[str], + values: list[float], + colors: list[str] | None = None, + figsize: tuple[int, int] = (6, 6), + dpi: int = DEFAULT_CHART_DPI, + autopct: str = "%1.1f%%", + startangle: int = 90, + title: str | None = None, +) -> io.BytesIO: + """Create a pie chart. + + Args: + labels: Slice labels + values: Slice values + colors: Optional list of colors for slices + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + autopct: Format string for percentage labels + startangle: Starting angle for first slice + title: Optional chart title + + Returns: + BytesIO buffer containing the PNG image + """ + fig, ax = plt.subplots(figsize=figsize) + + _, _, autotexts = ax.pie( + values, + labels=labels, + colors=colors, + autopct=autopct, + startangle=startangle, + ) + + # Style the text + for autotext in autotexts: + autotext.set_fontweight("bold") + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer + + +def create_stacked_bar_chart( + labels: list[str], + data_series: dict[str, list[float]], + colors: dict[str, str] | None = None, + xlabel: str = "", + ylabel: str = "Count", + title: str | None = None, + figsize: tuple[int, int] = (10, 6), + dpi: int = DEFAULT_CHART_DPI, + rotation: int = 45, + show_legend: bool = True, +) -> io.BytesIO: + """Create a stacked bar chart. + + Args: + labels: X-axis labels + data_series: Dictionary mapping series name to list of values + colors: Dictionary mapping series name to color + xlabel: X-axis label + ylabel: Y-axis label + title: Optional chart title + figsize: Figure size (width, height) in inches + dpi: Resolution for output image + rotation: X-axis label rotation angle + show_legend: Whether to show the legend + + Returns: + BytesIO buffer containing the PNG image + """ + fig, ax = plt.subplots(figsize=figsize) + + # Default colors if not provided + default_colors = { + "Pass": CHART_COLOR_GREEN_1, + "Fail": CHART_COLOR_RED, + "Manual": CHART_COLOR_YELLOW, + } + if colors is None: + colors = default_colors + + bottom = [0] * len(labels) + for series_name, values in data_series.items(): + color = colors.get(series_name, CHART_COLOR_BLUE) + ax.bar(labels, values, bottom=bottom, label=series_name, color=color) + bottom = [b + v for b, v in zip(bottom, values)] + + ax.set_xlabel(xlabel, fontsize=12) + ax.set_ylabel(ylabel, fontsize=12) + + if title: + ax.set_title(title, fontsize=14, fontweight="bold") + + plt.xticks(rotation=rotation, ha="right") + + if show_legend: + ax.legend() + + ax.grid(True, alpha=0.3, axis="y") + plt.tight_layout() + + buffer = io.BytesIO() + try: + fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight") + buffer.seek(0) + finally: + plt.close(fig) + gc.collect() # Force garbage collection after heavy matplotlib operation + + return buffer diff --git a/api/src/backend/tasks/jobs/reports/components.py b/api/src/backend/tasks/jobs/reports/components.py new file mode 100644 index 0000000000..323c4547e6 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/components.py @@ -0,0 +1,599 @@ +from dataclasses import dataclass +from typing import Any, Callable + +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import LongTable, Paragraph, Spacer, Table, TableStyle + +from .config import ( + ALTERNATE_ROWS_MAX_SIZE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_DARK_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_LIGHT_GRAY, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_SAFE, + COLOR_WHITE, + LONG_TABLE_THRESHOLD, + PADDING_LARGE, + PADDING_MEDIUM, + PADDING_SMALL, + PADDING_XLARGE, +) + + +def get_color_for_risk_level(risk_level: int) -> colors.Color: + """ + Get color based on risk level. + + Args: + risk_level (int): Numeric risk level (0-5). + + Returns: + colors.Color: Appropriate color for the risk level. + """ + if risk_level >= 4: + return COLOR_HIGH_RISK + if risk_level >= 3: + return COLOR_MEDIUM_RISK + if risk_level >= 2: + return COLOR_LOW_RISK + return COLOR_SAFE + + +def get_color_for_weight(weight: int) -> colors.Color: + """ + Get color based on weight value. + + Args: + weight (int): Numeric weight value. + + Returns: + colors.Color: Appropriate color for the weight. + """ + if weight > 100: + return COLOR_HIGH_RISK + if weight > 50: + return COLOR_LOW_RISK + return COLOR_SAFE + + +def get_color_for_compliance(percentage: float) -> colors.Color: + """ + Get color based on compliance percentage. + + Args: + percentage (float): Compliance percentage (0-100). + + Returns: + colors.Color: Appropriate color for the compliance level. + """ + if percentage >= 80: + return COLOR_SAFE + if percentage >= 60: + return COLOR_LOW_RISK + return COLOR_HIGH_RISK + + +def get_status_color(status: str) -> colors.Color: + """ + Get color for a status value. + + Args: + status (str): Status string (PASS, FAIL, MANUAL, etc.). + + Returns: + colors.Color: Appropriate color for the status. + """ + status_upper = status.upper() + if status_upper == "PASS": + return COLOR_SAFE + if status_upper == "FAIL": + return COLOR_HIGH_RISK + return COLOR_DARK_GRAY + + +def create_badge( + text: str, + bg_color: colors.Color, + text_color: colors.Color = COLOR_WHITE, + width: float = 1.4 * inch, + font: str = "FiraCode", + font_size: int = 11, +) -> Table: + """ + Create a generic colored badge component. + + Args: + text (str): Text to display in the badge. + bg_color (colors.Color): Background color. + text_color (colors.Color): Text color (default white). + width (float): Badge width in inches. + font (str): Font name to use. + font_size (int): Font size. + + Returns: + Table: A Table object styled as a badge. + """ + data = [[text]] + table = Table(data, colWidths=[width]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), bg_color), + ("TEXTCOLOR", (0, 0), (0, 0), text_color), + ("FONTNAME", (0, 0), (0, 0), font), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), font_size), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +def create_status_badge(status: str) -> Table: + """ + Create a PASS/FAIL/MANUAL status badge. + + Args: + status (str): Status value (e.g., "PASS", "FAIL", "MANUAL"). + + Returns: + Table: A styled Table badge for the status. + """ + status_upper = status.upper() + status_color = get_status_color(status_upper) + + data = [["State:", status_upper]] + table = Table(data, colWidths=[0.6 * inch, 0.8 * inch]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (1, 0), (1, 0), status_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 12), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ] + ) + ) + + return table + + +def create_multi_badge_row( + badges: list[tuple[str, colors.Color]], + badge_width: float = 0.4 * inch, + font: str = "FiraCode", +) -> Table: + """ + Create a row of multiple small badges. + + Args: + badges (list[tuple[str, colors.Color]]): List of (text, color) tuples for each badge. + badge_width (float): Width of each badge. + font (str): Font name to use. + + Returns: + Table: A Table with multiple colored badges in a row. + """ + if not badges: + data = [["N/A"]] + table = Table(data, colWidths=[1 * inch]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ] + ) + ) + return table + + data = [[text for text, _ in badges]] + col_widths = [badge_width] * len(badges) + table = Table(data, colWidths=col_widths) + + styles = [ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, -1), font), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("TEXTCOLOR", (0, 0), (-1, -1), COLOR_WHITE), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_SMALL), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ] + + for idx, (_, badge_color) in enumerate(badges): + styles.append(("BACKGROUND", (idx, 0), (idx, 0), badge_color)) + + table.setStyle(TableStyle(styles)) + return table + + +def create_risk_component( + risk_level: int, + weight: int, + score: int = 0, +) -> Table: + """ + Create a visual risk component showing risk level, weight, and score. + + Args: + risk_level (int): The risk level (0-5). + weight (int): The weight value. + score (int): The calculated score (default 0). + + Returns: + Table: A styled Table showing risk metrics. + """ + risk_color = get_color_for_risk_level(risk_level) + weight_color = get_color_for_weight(weight) + + data = [ + [ + "Risk Level:", + str(risk_level), + "Weight:", + str(weight), + "Score:", + str(score), + ] + ] + + table = Table( + data, + colWidths=[ + 0.8 * inch, + 0.4 * inch, + 0.6 * inch, + 0.4 * inch, + 0.5 * inch, + 0.4 * inch, + ], + ) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_LIGHT_GRAY), + ("BACKGROUND", (1, 0), (1, 0), risk_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("BACKGROUND", (2, 0), (2, 0), COLOR_LIGHT_GRAY), + ("BACKGROUND", (3, 0), (3, 0), weight_color), + ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE), + ("FONTNAME", (3, 0), (3, 0), "FiraCode"), + ("BACKGROUND", (4, 0), (4, 0), COLOR_LIGHT_GRAY), + ("BACKGROUND", (5, 0), (5, 0), COLOR_DARK_GRAY), + ("TEXTCOLOR", (5, 0), (5, 0), COLOR_WHITE), + ("FONTNAME", (5, 0), (5, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +def create_info_table( + rows: list[tuple[str, Any]], + label_width: float = 2 * inch, + value_width: float = 4 * inch, + label_color: colors.Color = COLOR_BLUE, + value_bg_color: colors.Color | None = None, + normal_style: ParagraphStyle | None = None, +) -> Table: + """ + Create a key-value information table. + + Args: + rows (list[tuple[str, Any]]): List of (label, value) tuples. + label_width (float): Width of the label column. + value_width (float): Width of the value column. + label_color (colors.Color): Background color for labels. + value_bg_color (colors.Color | None): Background color for values (optional). + normal_style (ParagraphStyle | None): ParagraphStyle for wrapping long values. + + Returns: + Table: A styled Table with key-value pairs. + """ + from .config import COLOR_BG_BLUE + + if value_bg_color is None: + value_bg_color = COLOR_BG_BLUE + + # Handle empty rows case - Table requires at least one row + if not rows: + table = Table([["", ""]], colWidths=[label_width, value_width]) + table.setStyle(TableStyle([("FONTSIZE", (0, 0), (-1, -1), 0)])) + return table + + # Process rows - wrap long values in Paragraph if style provided + table_data = [] + for label, value in rows: + if normal_style and isinstance(value, str) and len(value) > 50: + value = Paragraph(value, normal_style) + table_data.append([label, value]) + + table = Table(table_data, colWidths=[label_width, value_width]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), label_color), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), value_bg_color), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_DARK_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_XLARGE), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_LARGE), + ] + ) + ) + + return table + + +@dataclass +class ColumnConfig: + """ + Configuration for a table column. + + Attributes: + header (str): Column header text. + width (float): Column width in inches. + field (str | Callable[[Any], str]): Field name or callable to extract value from data. + align (str): Text alignment (LEFT, CENTER, RIGHT). + """ + + header: str + width: float + field: str | Callable[[Any], str] + align: str = "CENTER" + + +def create_data_table( + data: list[dict[str, Any]], + columns: list[ColumnConfig], + header_color: colors.Color = COLOR_BLUE, + alternate_rows: bool = True, + normal_style: ParagraphStyle | None = None, +) -> Table | LongTable: + """ + Create a data table with configurable columns. + + Uses LongTable for large datasets (>50 rows) for better memory efficiency + and page splitting. LongTable repeats headers on each page and has + optimized memory handling for large tables. + + Args: + data (list[dict[str, Any]]): List of data dictionaries. + columns (list[ColumnConfig]): Column configuration list. + header_color (colors.Color): Background color for header row. + alternate_rows (bool): Whether to alternate row backgrounds. + normal_style (ParagraphStyle | None): ParagraphStyle for cell values. + + Returns: + Table or LongTable: A styled table with data. + """ + # Build header row + header_row = [col.header for col in columns] + table_data = [header_row] + + # Build data rows + for item in data: + row = [] + for col in columns: + if callable(col.field): + value = col.field(item) + else: + value = item.get(col.field, "") + + if normal_style and isinstance(value, str): + value = Paragraph(value, normal_style) + row.append(value) + table_data.append(row) + + col_widths = [col.width for col in columns] + + # Use LongTable for large datasets - it handles page breaks better + # and has optimized memory handling for tables with many rows + use_long_table = len(data) > LONG_TABLE_THRESHOLD + if use_long_table: + table = LongTable(table_data, colWidths=col_widths, repeatRows=1) + else: + table = Table(table_data, colWidths=col_widths) + + styles = [ + ("BACKGROUND", (0, 0), (-1, 0), header_color), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("RIGHTPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("TOPPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ("BOTTOMPADDING", (0, 0), (-1, -1), PADDING_MEDIUM), + ] + + # Apply column alignments + for idx, col in enumerate(columns): + styles.append(("ALIGN", (idx, 0), (idx, -1), col.align)) + + # Alternate row backgrounds - skip for very large tables as it adds memory overhead + if ( + alternate_rows + and len(table_data) > 1 + and len(table_data) <= ALTERNATE_ROWS_MAX_SIZE + ): + for i in range(1, len(table_data)): + if i % 2 == 0: + styles.append( + ("BACKGROUND", (0, i), (-1, i), colors.Color(0.98, 0.98, 0.98)) + ) + + table.setStyle(TableStyle(styles)) + return table + + +def create_findings_table( + findings: list[Any], + columns: list[ColumnConfig] | None = None, + header_color: colors.Color = COLOR_BLUE, + normal_style: ParagraphStyle | None = None, +) -> Table: + """ + Create a findings table with default or custom columns. + + Args: + findings (list[Any]): List of finding objects. + columns (list[ColumnConfig] | None): Optional column configuration (defaults to standard columns). + header_color (colors.Color): Background color for header row. + normal_style (ParagraphStyle | None): ParagraphStyle for cell values. + + Returns: + Table: A styled Table with findings data. + """ + if columns is None: + columns = [ + ColumnConfig("Finding", 2.5 * inch, "title"), + ColumnConfig("Resource", 3 * inch, "resource_name"), + ColumnConfig("Severity", 0.9 * inch, "severity"), + ColumnConfig("Status", 0.9 * inch, "status"), + ColumnConfig("Region", 0.9 * inch, "region"), + ] + + # Convert findings to dicts + data = [] + for finding in findings: + item = {} + for col in columns: + if callable(col.field): + item[col.header.lower()] = col.field(finding) + elif hasattr(finding, col.field): + item[col.field] = getattr(finding, col.field, "") + elif isinstance(finding, dict): + item[col.field] = finding.get(col.field, "") + data.append(item) + + return create_data_table( + data=data, + columns=columns, + header_color=header_color, + alternate_rows=True, + normal_style=normal_style, + ) + + +def create_section_header( + text: str, + style: ParagraphStyle, + add_spacer: bool = True, + spacer_height: float = 0.2, +) -> list: + """ + Create a section header with optional spacer. + + Args: + text (str): Header text. + style (ParagraphStyle): ParagraphStyle to apply. + add_spacer (bool): Whether to add a spacer after the header. + spacer_height (float): Height of the spacer in inches. + + Returns: + list: List of elements (Paragraph and optional Spacer). + """ + elements = [Paragraph(text, style)] + if add_spacer: + elements.append(Spacer(1, spacer_height * inch)) + return elements + + +def create_summary_table( + label: str, + value: str, + value_color: colors.Color, + label_width: float = 2.5 * inch, + value_width: float = 2 * inch, +) -> Table: + """ + Create a summary metric table (e.g., for ThreatScore display). + + Args: + label (str): Label text (e.g., "ThreatScore:"). + value (str): Value text (e.g., "85.5%"). + value_color (colors.Color): Background color for the value cell. + label_width (float): Width of the label column. + value_width (float): Width of the value column. + + Returns: + Table: A styled summary Table. + """ + data = [[label, value]] + table = Table(data, colWidths=[label_width, value_width]) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), value_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + + return table diff --git a/api/src/backend/tasks/jobs/reports/config.py b/api/src/backend/tasks/jobs/reports/config.py new file mode 100644 index 0000000000..0785505820 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/config.py @@ -0,0 +1,286 @@ +from dataclasses import dataclass, field + +from reportlab.lib import colors +from reportlab.lib.units import inch + +# ============================================================================= +# Performance & Memory Optimization Settings +# ============================================================================= +# These settings control memory usage and performance for large reports. +# Adjust these values if workers are running out of memory. + +# Chart settings - lower DPI = less memory, 150 is good quality for PDF +CHART_DPI_DEFAULT = 150 + +# LongTable threshold - use LongTable for tables with more rows than this +# LongTable handles page breaks better and has optimized memory for large tables +LONG_TABLE_THRESHOLD = 50 + +# Skip alternating row colors for tables larger than this (reduces memory) +ALTERNATE_ROWS_MAX_SIZE = 200 + +# Database query batch size for findings (matches Django settings) +# Larger = fewer queries but more memory per batch +FINDINGS_BATCH_SIZE = 2000 + + +# ============================================================================= +# Base colors +# ============================================================================= +COLOR_PROWLER_DARK_GREEN = colors.Color(0.1, 0.5, 0.2) +COLOR_BLUE = colors.Color(0.2, 0.4, 0.6) +COLOR_LIGHT_BLUE = colors.Color(0.3, 0.5, 0.7) +COLOR_LIGHTER_BLUE = colors.Color(0.4, 0.6, 0.8) +COLOR_BG_BLUE = colors.Color(0.95, 0.97, 1.0) +COLOR_BG_LIGHT_BLUE = colors.Color(0.98, 0.99, 1.0) +COLOR_GRAY = colors.Color(0.2, 0.2, 0.2) +COLOR_LIGHT_GRAY = colors.Color(0.9, 0.9, 0.9) +COLOR_BORDER_GRAY = colors.Color(0.7, 0.8, 0.9) +COLOR_GRID_GRAY = colors.Color(0.7, 0.7, 0.7) +COLOR_DARK_GRAY = colors.Color(0.4, 0.4, 0.4) +COLOR_HEADER_DARK = colors.Color(0.1, 0.3, 0.5) +COLOR_HEADER_MEDIUM = colors.Color(0.15, 0.35, 0.55) +COLOR_WHITE = colors.white + +# Risk and status colors +COLOR_HIGH_RISK = colors.Color(0.8, 0.2, 0.2) +COLOR_MEDIUM_RISK = colors.Color(0.9, 0.6, 0.2) +COLOR_LOW_RISK = colors.Color(0.9, 0.9, 0.2) +COLOR_SAFE = colors.Color(0.2, 0.8, 0.2) + +# ENS specific colors +COLOR_ENS_ALTO = colors.Color(0.8, 0.2, 0.2) +COLOR_ENS_MEDIO = colors.Color(0.98, 0.75, 0.13) +COLOR_ENS_BAJO = colors.Color(0.06, 0.72, 0.51) +COLOR_ENS_OPCIONAL = colors.Color(0.42, 0.45, 0.50) +COLOR_ENS_TIPO = colors.Color(0.2, 0.4, 0.6) +COLOR_ENS_AUTO = colors.Color(0.30, 0.69, 0.31) +COLOR_ENS_MANUAL = colors.Color(0.96, 0.60, 0.0) + +# NIS2 specific colors +COLOR_NIS2_PRIMARY = colors.Color(0.12, 0.23, 0.54) +COLOR_NIS2_SECONDARY = colors.Color(0.23, 0.51, 0.96) +COLOR_NIS2_BG_BLUE = colors.Color(0.96, 0.97, 0.99) + +# Chart colors (hex strings for matplotlib) +CHART_COLOR_GREEN_1 = "#4CAF50" +CHART_COLOR_GREEN_2 = "#8BC34A" +CHART_COLOR_YELLOW = "#FFEB3B" +CHART_COLOR_ORANGE = "#FF9800" +CHART_COLOR_RED = "#F44336" +CHART_COLOR_BLUE = "#2196F3" + +# ENS dimension mappings: dimension name -> (abbreviation, color) +DIMENSION_MAPPING = { + "trazabilidad": ("T", colors.Color(0.26, 0.52, 0.96)), + "autenticidad": ("A", colors.Color(0.30, 0.69, 0.31)), + "integridad": ("I", colors.Color(0.61, 0.15, 0.69)), + "confidencialidad": ("C", colors.Color(0.96, 0.26, 0.21)), + "disponibilidad": ("D", colors.Color(1.0, 0.60, 0.0)), +} + +# ENS tipo icons +TIPO_ICONS = { + "requisito": "\u26a0\ufe0f", + "refuerzo": "\U0001f6e1\ufe0f", + "recomendacion": "\U0001f4a1", + "medida": "\U0001f4cb", +} + +# Dimension names for charts (Spanish) +DIMENSION_NAMES = [ + "Trazabilidad", + "Autenticidad", + "Integridad", + "Confidencialidad", + "Disponibilidad", +] + +DIMENSION_KEYS = [ + "trazabilidad", + "autenticidad", + "integridad", + "confidencialidad", + "disponibilidad", +] + +# ENS nivel and tipo order +ENS_NIVEL_ORDER = ["alto", "medio", "bajo", "opcional"] +ENS_TIPO_ORDER = ["requisito", "refuerzo", "recomendacion", "medida"] + +# ThreatScore sections +THREATSCORE_SECTIONS = [ + "1. IAM", + "2. Attack Surface", + "3. Logging and Monitoring", + "4. Encryption", +] + +# NIS2 sections +NIS2_SECTIONS = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "9", + "11", + "12", +] + +NIS2_SECTION_TITLES = { + "1": "1. Policy on Security", + "2": "2. Risk Management", + "3": "3. Incident Handling", + "4": "4. Business Continuity", + "5": "5. Supply Chain", + "6": "6. Acquisition & Dev", + "7": "7. Effectiveness", + "9": "9. Cryptography", + "11": "11. Access Control", + "12": "12. Asset Management", +} + +# Table column widths +COL_WIDTH_SMALL = 0.4 * inch +COL_WIDTH_MEDIUM = 0.9 * inch +COL_WIDTH_LARGE = 1.5 * inch +COL_WIDTH_XLARGE = 2 * inch +COL_WIDTH_XXLARGE = 3 * inch + +# Common padding values +PADDING_SMALL = 4 +PADDING_MEDIUM = 6 +PADDING_LARGE = 8 +PADDING_XLARGE = 10 + + +@dataclass +class FrameworkConfig: + """ + Configuration for a compliance framework PDF report. + + This dataclass defines all the configurable aspects of a compliance framework + report, including visual styling, metadata fields, and feature flags. + + Attributes: + name (str): Internal framework identifier (e.g., "prowler_threatscore"). + display_name (str): Human-readable framework name for the report title. + logo_filename (str | None): Optional filename of the framework logo in assets/img/. + primary_color (colors.Color): Main color used for headers and important elements. + secondary_color (colors.Color): Secondary color for sub-headers and accents. + bg_color (colors.Color): Background color for highlighted sections. + attribute_fields (list[str]): List of metadata field names to extract from requirements. + sections (list[str] | None): Optional ordered list of section names for grouping. + language (str): Report language ("en" for English, "es" for Spanish). + has_risk_levels (bool): Whether the framework uses numeric risk levels. + has_dimensions (bool): Whether the framework uses security dimensions (ENS). + has_niveles (bool): Whether the framework uses nivel classification (ENS). + has_weight (bool): Whether requirements have weight values. + """ + + name: str + display_name: str + logo_filename: str | None = None + primary_color: colors.Color = field(default_factory=lambda: COLOR_BLUE) + secondary_color: colors.Color = field(default_factory=lambda: COLOR_LIGHT_BLUE) + bg_color: colors.Color = field(default_factory=lambda: COLOR_BG_BLUE) + attribute_fields: list[str] = field(default_factory=list) + sections: list[str] | None = None + language: str = "en" + has_risk_levels: bool = False + has_dimensions: bool = False + has_niveles: bool = False + has_weight: bool = False + + +FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = { + "prowler_threatscore": FrameworkConfig( + name="prowler_threatscore", + display_name="Prowler ThreatScore", + logo_filename=None, + primary_color=COLOR_BLUE, + secondary_color=COLOR_LIGHT_BLUE, + bg_color=COLOR_BG_BLUE, + attribute_fields=[ + "Title", + "Section", + "SubSection", + "LevelOfRisk", + "Weight", + "AttributeDescription", + "AdditionalInformation", + ], + sections=THREATSCORE_SECTIONS, + language="en", + has_risk_levels=True, + has_weight=True, + ), + "ens": FrameworkConfig( + name="ens", + display_name="ENS RD2022", + logo_filename="ens_logo.png", + primary_color=COLOR_ENS_ALTO, + secondary_color=COLOR_ENS_MEDIO, + bg_color=COLOR_BG_BLUE, + attribute_fields=[ + "IdGrupoControl", + "Marco", + "Categoria", + "DescripcionControl", + "Tipo", + "Nivel", + "Dimensiones", + "ModoEjecucion", + ], + sections=None, + language="es", + has_risk_levels=False, + has_dimensions=True, + has_niveles=True, + has_weight=False, + ), + "nis2": FrameworkConfig( + name="nis2", + display_name="NIS2 Directive", + logo_filename="nis2_logo.png", + primary_color=COLOR_NIS2_PRIMARY, + secondary_color=COLOR_NIS2_SECONDARY, + bg_color=COLOR_NIS2_BG_BLUE, + attribute_fields=[ + "Section", + "SubSection", + "Description", + ], + sections=NIS2_SECTIONS, + language="en", + has_risk_levels=False, + has_dimensions=False, + has_niveles=False, + has_weight=False, + ), +} + + +def get_framework_config(compliance_id: str) -> FrameworkConfig | None: + """ + Get framework configuration based on compliance ID. + + Args: + compliance_id (str): The compliance framework identifier (e.g., "prowler_threatscore_aws"). + + Returns: + FrameworkConfig | None: The framework configuration if found, None otherwise. + """ + compliance_lower = compliance_id.lower() + + if "threatscore" in compliance_lower: + return FRAMEWORK_REGISTRY["prowler_threatscore"] + if "ens" in compliance_lower: + return FRAMEWORK_REGISTRY["ens"] + if "nis2" in compliance_lower: + return FRAMEWORK_REGISTRY["nis2"] + + return None diff --git a/api/src/backend/tasks/jobs/reports/ens.py b/api/src/backend/tasks/jobs/reports/ens.py new file mode 100644 index 0000000000..56ee4bc40f --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/ens.py @@ -0,0 +1,1004 @@ +import os +from collections import defaultdict + +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from api.models import StatusChoices + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + get_requirement_metadata, +) +from .charts import create_horizontal_bar_chart, create_radar_chart +from .components import get_color_for_compliance +from .config import ( + COLOR_BG_BLUE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_ENS_ALTO, + COLOR_ENS_AUTO, + COLOR_ENS_BAJO, + COLOR_ENS_MANUAL, + COLOR_ENS_MEDIO, + COLOR_ENS_OPCIONAL, + COLOR_ENS_TIPO, + COLOR_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_SAFE, + COLOR_WHITE, + DIMENSION_KEYS, + DIMENSION_MAPPING, + DIMENSION_NAMES, + ENS_NIVEL_ORDER, + ENS_TIPO_ORDER, +) + + +class ENSReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for ENS RD2022 framework. + + This generator creates comprehensive PDF reports containing: + - Cover page with both Prowler and ENS logos + - Executive summary with overall compliance score + - Marco/Categoría analysis with charts + - Security dimensions radar chart + - Requirement type distribution + - Execution mode distribution + - Critical failed requirements (nivel alto) + - Requirements index + - Detailed findings for failed and manual requirements + """ + + def create_cover_page(self, data: ComplianceData) -> list: + """ + Create the ENS report cover page with both logos and legend. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Create logos side by side + prowler_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + ens_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/ens_logo.png" + ) + + prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch) + ens_logo = Image(ens_logo_path, width=1.5 * inch, height=2 * inch) + + logos_table = Table( + [[prowler_logo, ens_logo]], colWidths=[4 * inch, 2.5 * inch] + ) + logos_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("VALIGN", (1, 0), (1, 0), "TOP"), + ] + ) + ) + elements.append(logos_table) + elements.append(Spacer(1, 0.3 * inch)) + elements.append( + Paragraph("Informe de Cumplimiento ENS RD 311/2022", self.styles["title"]) + ) + elements.append(Spacer(1, 0.5 * inch)) + + # Compliance info table - use base class helper for consistency + info_rows = self._build_info_rows(data, language="es") + # Convert tuples to lists and wrap long text in Paragraphs + info_data = [] + for label, value in info_rows: + if label in ("Nombre:", "Descripción:") and value: + info_data.append( + [label, Paragraph(value, self.styles["normal_center"])] + ) + else: + info_data.append([label, value]) + + info_table = Table(info_data, colWidths=[2 * inch, 4 * inch]) + info_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(info_table) + elements.append(Spacer(1, 0.5 * inch)) + + # Warning about excluded manual requirements + manual_count = self._count_manual_requirements(data) + auto_count = len( + [r for r in data.requirements if r.status != StatusChoices.MANUAL] + ) + + warning_text = ( + f"AVISO: Este informe no incluye los requisitos de ejecución manual. " + f"El compliance {data.compliance_id} contiene un total de " + f"{manual_count} requisitos manuales que no han sido evaluados " + f"automáticamente y por tanto no están reflejados en las estadísticas de este reporte. " + f"El análisis se basa únicamente en los {auto_count} requisitos automatizados." + ) + warning_paragraph = Paragraph(warning_text, self.styles["normal"]) + warning_table = Table([[warning_paragraph]], colWidths=[6 * inch]) + warning_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(1.0, 0.95, 0.7)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.Color(0.4, 0.3, 0.0)), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("BOX", (0, 0), (-1, -1), 2, colors.Color(0.9, 0.7, 0.0)), + ("LEFTPADDING", (0, 0), (-1, -1), 15), + ("RIGHTPADDING", (0, 0), (-1, -1), 15), + ("TOPPADDING", (0, 0), (-1, -1), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 12), + ] + ) + ) + elements.append(warning_table) + elements.append(Spacer(1, 0.5 * inch)) + + # Legend + elements.append(self._create_legend()) + + return elements + + def create_executive_summary(self, data: ComplianceData) -> list: + """ + Create the executive summary with compliance metrics. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Resumen Ejecutivo", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Filter out manual requirements + auto_requirements = [ + r for r in data.requirements if r.status != StatusChoices.MANUAL + ] + total = len(auto_requirements) + passed = sum(1 for r in auto_requirements if r.status == StatusChoices.PASS) + failed = sum(1 for r in auto_requirements if r.status == StatusChoices.FAIL) + + overall_compliance = (passed / total * 100) if total > 0 else 0 + compliance_color = get_color_for_compliance(overall_compliance) + + # Summary table + summary_data = [["Nivel de Cumplimiento Global:", f"{overall_compliance:.2f}%"]] + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), compliance_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + elements.append(summary_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Counts table + counts_data = [ + ["Estado", "Cantidad", "Porcentaje"], + [ + "CUMPLE", + str(passed), + f"{(passed / total * 100):.1f}%" if total > 0 else "0%", + ], + [ + "NO CUMPLE", + str(failed), + f"{(failed / total * 100):.1f}%" if total > 0 else "0%", + ], + ["TOTAL", str(total), "100%"], + ] + counts_table = Table(counts_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch]) + counts_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("BACKGROUND", (0, 1), (0, 1), COLOR_SAFE), + ("TEXTCOLOR", (0, 1), (0, 1), COLOR_WHITE), + ("BACKGROUND", (0, 2), (0, 2), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE), + ("BACKGROUND", (0, 3), (0, 3), colors.Color(0.4, 0.4, 0.4)), + ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(counts_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Compliance by Nivel + elements.extend(self._create_nivel_table(data)) + + return elements + + def create_charts_section(self, data: ComplianceData) -> list: + """ + Create the charts section with Marco analysis and radar chart. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Critical failed requirements section (nivel alto) - new page + elements.append(PageBreak()) + elements.extend(self._create_critical_failed_section(data)) + + # Marco y Categorías chart - new page + elements.append(PageBreak()) + elements.append( + Paragraph("Análisis por Marcos y Categorías", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + marco_cat_chart = self._create_marco_category_chart(data) + marco_cat_image = Image(marco_cat_chart, width=7 * inch, height=5.5 * inch) + elements.append(marco_cat_image) + + # Security dimensions radar chart - new page + elements.append(PageBreak()) + elements.append( + Paragraph("Análisis por Dimensiones de Seguridad", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + radar_buffer = self._create_dimensions_radar_chart(data) + radar_image = Image(radar_buffer, width=6 * inch, height=6 * inch) + elements.append(radar_image) + elements.append(PageBreak()) + + # Type distribution + elements.extend(self._create_tipo_section(data)) + + return elements + + def create_requirements_index(self, data: ComplianceData) -> list: + """ + Create the requirements index organized by Marco and Categoria. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Índice de Requisitos", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Organize by Marco and Categoria + marcos = {} + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + marco = getattr(m, "Marco", "Otros") + categoria = getattr(m, "Categoria", "Sin categoría") + descripcion = getattr(m, "DescripcionControl", req.description) + nivel = getattr(m, "Nivel", "") + + if marco not in marcos: + marcos[marco] = {} + if categoria not in marcos[marco]: + marcos[marco][categoria] = [] + + marcos[marco][categoria].append( + { + "id": req.id, + "descripcion": descripcion, + "nivel": nivel, + "status": req.status, + } + ) + + for marco_name, categorias in marcos.items(): + elements.append(Paragraph(f"Marco: {marco_name}", self.styles["h2"])) + + for categoria_name, reqs in categorias.items(): + elements.append(Paragraph(f"{categoria_name}", self.styles["h3"])) + + for req in reqs: + status_indicator = ( + "✓" if req["status"] == StatusChoices.PASS else "✗" + ) + nivel_badge = f"[{req['nivel'].upper()}]" if req["nivel"] else "" + elements.append( + Paragraph( + f"{status_indicator} {req['id']} {nivel_badge}", + self.styles["normal"], + ) + ) + + elements.append(Spacer(1, 0.1 * inch)) + + return elements + + def get_footer_text(self, page_num: int) -> tuple[str, str]: + """ + Get Spanish footer text for ENS report. + + Args: + page_num: Current page number. + + Returns: + Tuple of (left_text, right_text) for the footer. + """ + return f"Página {page_num}", "Powered by Prowler" + + def _count_manual_requirements(self, data: ComplianceData) -> int: + """Count requirements with manual execution mode.""" + return sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL) + + def _create_legend(self) -> Table: + """Create the ENS values legend table.""" + legend_text = """ + Nivel (Criticidad del requisito):
+ • Alto: Requisitos críticos que deben cumplirse prioritariamente
+ • Medio: Requisitos importantes con impacto moderado
+ • Bajo: Requisitos complementarios de menor criticidad
+ • Opcional: Recomendaciones adicionales no obligatorias
+
+ Tipo (Clasificación del requisito):
+ • Requisito: Obligación establecida por el ENS
+ • Refuerzo: Medida adicional que refuerza un requisito
+ • Recomendación: Buena práctica sugerida
+ • Medida: Acción concreta de implementación
+
+ Dimensiones de Seguridad:
+ • C (Confidencialidad): Protección contra accesos no autorizados
+ • I (Integridad): Garantía de exactitud y completitud
+ • T (Trazabilidad): Capacidad de rastrear acciones
+ • A (Autenticidad): Verificación de identidad
+ • D (Disponibilidad): Acceso cuando se necesita + """ + legend_paragraph = Paragraph(legend_text, self.styles["normal"]) + legend_table = Table([[legend_paragraph]], colWidths=[6.5 * inch]) + legend_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_BG_BLUE), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_GRAY), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("VALIGN", (0, 0), (0, 0), "TOP"), + ("BOX", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.8)), + ("LEFTPADDING", (0, 0), (-1, -1), 15), + ("RIGHTPADDING", (0, 0), (-1, -1), 15), + ("TOPPADDING", (0, 0), (-1, -1), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 12), + ] + ) + ) + return legend_table + + def _create_nivel_table(self, data: ComplianceData) -> list: + """Create compliance by nivel table.""" + elements = [] + elements.append(Paragraph("Cumplimiento por Nivel", self.styles["h2"])) + + nivel_data = defaultdict(lambda: {"passed": 0, "total": 0}) + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + nivel = getattr(m, "Nivel", "").lower() + nivel_data[nivel]["total"] += 1 + if req.status == StatusChoices.PASS: + nivel_data[nivel]["passed"] += 1 + + table_data = [["Nivel", "Cumplidos", "Total", "Porcentaje"]] + nivel_colors = { + "alto": COLOR_ENS_ALTO, + "medio": COLOR_ENS_MEDIO, + "bajo": COLOR_ENS_BAJO, + "opcional": COLOR_ENS_OPCIONAL, + } + + for nivel in ENS_NIVEL_ORDER: + if nivel in nivel_data: + d = nivel_data[nivel] + pct = (d["passed"] / d["total"] * 100) if d["total"] > 0 else 0 + table_data.append( + [ + nivel.capitalize(), + str(d["passed"]), + str(d["total"]), + f"{pct:.1f}%", + ] + ) + + table = Table( + table_data, colWidths=[1.5 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + + # Color nivel column + for idx, nivel in enumerate(ENS_NIVEL_ORDER): + if nivel in nivel_data: + row_idx = idx + 1 + if row_idx < len(table_data): + color = nivel_colors.get(nivel, COLOR_GRAY) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, row_idx), (0, row_idx), color), + ("TEXTCOLOR", (0, row_idx), (0, row_idx), COLOR_WHITE), + ] + ) + ) + + elements.append(table) + return elements + + def _create_marco_category_chart(self, data: ComplianceData): + """Create Marco - Categoría combined compliance chart.""" + # Group by marco + categoria combination + marco_cat_scores = defaultdict(lambda: {"passed": 0, "total": 0}) + + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + marco = getattr(m, "Marco", "otros") + categoria = getattr(m, "Categoria", "sin categoría") + # Combined key: "marco - categoría" + key = f"{marco} - {categoria}" + marco_cat_scores[key]["total"] += 1 + if req.status == StatusChoices.PASS: + marco_cat_scores[key]["passed"] += 1 + + labels = [] + values = [] + for key, scores in sorted(marco_cat_scores.items()): + if scores["total"] > 0: + pct = (scores["passed"] / scores["total"]) * 100 + labels.append(key) + values.append(pct) + + return create_horizontal_bar_chart( + labels=labels, + values=values, + xlabel="Porcentaje de Cumplimiento (%)", + ) + + def _create_dimensions_radar_chart(self, data: ComplianceData): + """Create security dimensions radar chart.""" + dimension_scores = {dim: {"passed": 0, "total": 0} for dim in DIMENSION_KEYS} + + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + dimensiones = getattr(m, "Dimensiones", []) + if isinstance(dimensiones, str): + dimensiones = [d.strip().lower() for d in dimensiones.split(",")] + elif isinstance(dimensiones, list): + dimensiones = [ + d.lower() if isinstance(d, str) else d for d in dimensiones + ] + + for dim in dimensiones: + if dim in dimension_scores: + dimension_scores[dim]["total"] += 1 + if req.status == StatusChoices.PASS: + dimension_scores[dim]["passed"] += 1 + + values = [] + for dim in DIMENSION_KEYS: + scores = dimension_scores[dim] + if scores["total"] > 0: + pct = (scores["passed"] / scores["total"]) * 100 + else: + pct = 100 + values.append(pct) + + return create_radar_chart( + labels=DIMENSION_NAMES, + values=values, + color="#2196F3", + ) + + def _create_tipo_section(self, data: ComplianceData) -> list: + """Create type distribution section.""" + elements = [] + elements.append( + Paragraph("Distribución por Tipo de Requisito", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + tipo_data = defaultdict(lambda: {"passed": 0, "total": 0}) + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + tipo = getattr(m, "Tipo", "").lower() + tipo_data[tipo]["total"] += 1 + if req.status == StatusChoices.PASS: + tipo_data[tipo]["passed"] += 1 + + table_data = [["Tipo", "Cumplidos", "Total", "Porcentaje"]] + for tipo in ENS_TIPO_ORDER: + if tipo in tipo_data: + d = tipo_data[tipo] + pct = (d["passed"] / d["total"] * 100) if d["total"] > 0 else 0 + table_data.append( + [ + tipo.capitalize(), + str(d["passed"]), + str(d["total"]), + f"{pct:.1f}%", + ] + ) + + table = Table( + table_data, colWidths=[2 * inch, 1.5 * inch, 1.5 * inch, 1.5 * inch] + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 1, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(table) + return elements + + def _create_critical_failed_section(self, data: ComplianceData) -> list: + """Create section for critical failed requirements (nivel alto).""" + elements = [] + + elements.append( + Paragraph("Requisitos Críticos No Cumplidos", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Get failed requirements with nivel alto + critical_failed = [] + for req in data.requirements: + if req.status != StatusChoices.FAIL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + nivel = getattr(m, "Nivel", "").lower() + if nivel == "alto": + critical_failed.append( + { + "id": req.id, + "descripcion": getattr( + m, "DescripcionControl", req.description + ), + "marco": getattr(m, "Marco", ""), + "categoria": getattr(m, "Categoria", ""), + "tipo": getattr(m, "Tipo", ""), + } + ) + + if not critical_failed: + elements.append( + Paragraph( + "✅ No hay requisitos críticos (nivel ALTO) que hayan fallado.", + self.styles["normal"], + ) + ) + return elements + + elements.append( + Paragraph( + f"Se encontraron {len(critical_failed)} requisitos de nivel ALTO " + "que no cumplen y requieren atención inmediata:", + self.styles["normal"], + ) + ) + elements.append(Spacer(1, 0.2 * inch)) + + # Create table - use a cell style without leftIndent for proper alignment + cell_style = ParagraphStyle( + "CellStyle", + parent=self.styles["normal"], + leftIndent=0, + spaceBefore=0, + spaceAfter=0, + ) + table_data: list = [["ID Requisito", "Marco", "Categoría", "Tipo"]] + for req in critical_failed: + table_data.append( + [ + req["id"], + req["marco"], + Paragraph(req["categoria"], cell_style), + req["tipo"].capitalize() if req["tipo"] else "", + ] + ) + + table = Table( + table_data, + colWidths=[2 * inch, 1.5 * inch, 1.8 * inch, 1.2 * inch], + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_ENS_ALTO), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, colors.Color(1.0, 0.95, 0.95)], + ), + ] + ) + ) + elements.append(table) + + return elements + + def create_detailed_findings(self, data: ComplianceData, **kwargs) -> list: + """ + Create detailed findings section with ENS-specific format. + + Shows each failed requirement with: + - Requirement ID as title + - Status, Nivel, Tipo, ModoEjecucion badges + - Dimensiones badges + - Info table with Descripción, Marco, Categoría, etc. + + Args: + data: Aggregated compliance data. + **kwargs: Additional options. + + Returns: + List of ReportLab elements. + """ + elements = [] + include_manual = kwargs.get("include_manual", True) + + elements.append(Paragraph("Detalle de Requisitos", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Get failed requirements, and optionally manual requirements + if include_manual: + failed_requirements = [ + r + for r in data.requirements + if r.status in (StatusChoices.FAIL, StatusChoices.MANUAL) + ] + else: + failed_requirements = [ + r for r in data.requirements if r.status == StatusChoices.FAIL + ] + + if not failed_requirements: + elements.append( + Paragraph( + "No hay requisitos fallidos para mostrar.", + self.styles["normal"], + ) + ) + return elements + + elements.append( + Paragraph( + f"Se muestran {len(failed_requirements)} requisitos que requieren " + "atención:", + self.styles["normal"], + ) + ) + elements.append(Spacer(1, 0.3 * inch)) + + # Nivel colors mapping + nivel_colors = { + "alto": COLOR_ENS_ALTO, + "medio": COLOR_ENS_MEDIO, + "bajo": COLOR_ENS_BAJO, + "opcional": COLOR_ENS_OPCIONAL, + } + + for req in failed_requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + + if not m: + continue + + nivel = getattr(m, "Nivel", "").lower() + tipo = getattr(m, "Tipo", "") + modo = getattr(m, "ModoEjecucion", "") + dimensiones = getattr(m, "Dimensiones", []) + descripcion = getattr(m, "DescripcionControl", req.description) + marco = getattr(m, "Marco", "") + categoria = getattr(m, "Categoria", "") + id_grupo = getattr(m, "IdGrupoControl", "") + + # Requirement ID title + req_title = Table([[req.id]], colWidths=[6.5 * inch]) + req_title.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_BG_BLUE), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_BLUE), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 14), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("BOX", (0, 0), (-1, -1), 2, COLOR_BLUE), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + elements.append(req_title) + elements.append(Spacer(1, 0.15 * inch)) + + # Status and Nivel badges row + status_color = COLOR_HIGH_RISK # FAIL + nivel_color = nivel_colors.get(nivel, COLOR_GRAY) + + badges_row1 = [ + ["State:", "FAIL", "", f"Nivel: {nivel.upper()}"], + ] + badges_table1 = Table( + badges_row1, + colWidths=[0.7 * inch, 0.8 * inch, 1.5 * inch, 1.5 * inch], + ) + badges_table1.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (1, 0), (1, 0), status_color), + ("TEXTCOLOR", (1, 0), (1, 0), COLOR_WHITE), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("BACKGROUND", (3, 0), (3, 0), nivel_color), + ("TEXTCOLOR", (3, 0), (3, 0), COLOR_WHITE), + ("FONTNAME", (3, 0), (3, 0), "FiraCode"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (1, 0), 0.5, colors.black), + ("GRID", (3, 0), (3, 0), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(badges_table1) + elements.append(Spacer(1, 0.1 * inch)) + + # Tipo and Modo badges row + tipo_display = f"☰ {tipo.capitalize()}" if tipo else "N/A" + modo_display = f"☰ {modo.capitalize()}" if modo else "N/A" + modo_color = ( + COLOR_ENS_AUTO if modo.lower() == "automatico" else COLOR_ENS_MANUAL + ) + + badges_row2 = [[tipo_display, "", modo_display]] + badges_table2 = Table( + badges_row2, colWidths=[2.2 * inch, 0.5 * inch, 2.2 * inch] + ) + badges_table2.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), COLOR_ENS_TIPO), + ("TEXTCOLOR", (0, 0), (0, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("BACKGROUND", (2, 0), (2, 0), modo_color), + ("TEXTCOLOR", (2, 0), (2, 0), COLOR_WHITE), + ("FONTNAME", (2, 0), (2, 0), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (0, 0), 0.5, colors.black), + ("GRID", (2, 0), (2, 0), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(badges_table2) + elements.append(Spacer(1, 0.1 * inch)) + + # Dimensiones badges + if dimensiones: + if isinstance(dimensiones, str): + dim_list = [d.strip().lower() for d in dimensiones.split(",")] + else: + dim_list = [ + d.lower() if isinstance(d, str) else str(d) for d in dimensiones + ] + + dim_badges = [] + for dim in dim_list: + if dim in DIMENSION_MAPPING: + abbrev, dim_color = DIMENSION_MAPPING[dim] + dim_badges.append((abbrev, dim_color)) + + if dim_badges: + dim_label = [["Dimensiones:"] + [b[0] for b in dim_badges]] + dim_widths = [1.2 * inch] + [0.4 * inch] * len(dim_badges) + dim_table = Table(dim_label, colWidths=dim_widths) + + dim_styles = [ + ("FONTNAME", (0, 0), (0, 0), "PlusJakartaSans"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + for idx, (_, dim_color) in enumerate(dim_badges): + col_idx = idx + 1 + dim_styles.extend( + [ + ( + "BACKGROUND", + (col_idx, 0), + (col_idx, 0), + dim_color, + ), + ("TEXTCOLOR", (col_idx, 0), (col_idx, 0), COLOR_WHITE), + ("FONTNAME", (col_idx, 0), (col_idx, 0), "FiraCode"), + ("GRID", (col_idx, 0), (col_idx, 0), 0.5, colors.black), + ] + ) + + dim_table.setStyle(TableStyle(dim_styles)) + elements.append(dim_table) + elements.append(Spacer(1, 0.15 * inch)) + + # Info table - use Paragraph for text wrapping + info_data = [ + [ + "Descripción:", + Paragraph(descripcion, self.styles["normal_center"]), + ], + ["Marco:", marco], + [ + "Categoría:", + Paragraph(categoria, self.styles["normal_center"]), + ], + ["ID Grupo Control:", id_grupo], + ] + info_table = Table(info_data, colWidths=[2 * inch, 4.5 * inch]) + info_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("FONTSIZE", (0, 0), (0, -1), 10), + ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("FONTSIZE", (1, 0), (1, -1), 10), + ("ALIGN", (0, 0), (0, -1), "LEFT"), + ("ALIGN", (1, 0), (1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + elements.append(info_table) + elements.append(Spacer(1, 0.3 * inch)) + + return elements diff --git a/api/src/backend/tasks/jobs/reports/nis2.py b/api/src/backend/tasks/jobs/reports/nis2.py new file mode 100644 index 0000000000..4ac5fa3d15 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/nis2.py @@ -0,0 +1,471 @@ +import os +from collections import defaultdict + +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from api.models import StatusChoices + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + get_requirement_metadata, +) +from .charts import create_horizontal_bar_chart, get_chart_color_for_percentage +from .config import ( + COLOR_BORDER_GRAY, + COLOR_DARK_GRAY, + COLOR_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_NIS2_BG_BLUE, + COLOR_NIS2_PRIMARY, + COLOR_SAFE, + COLOR_WHITE, + NIS2_SECTION_TITLES, + NIS2_SECTIONS, +) + + +def _extract_section_number(section_string: str) -> str: + """Extract the section number from a full NIS2 section title. + + NIS2 section strings are formatted like: + "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS..." + + This function extracts just the leading number. + + Args: + section_string: Full section title string. + + Returns: + Section number as string (e.g., "1", "2", "11"). + """ + if not section_string: + return "Other" + parts = section_string.split() + if parts and parts[0].isdigit(): + return parts[0] + return "Other" + + +class NIS2ReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for NIS2 Directive (EU) 2022/2555. + + This generator creates comprehensive PDF reports containing: + - Cover page with both Prowler and NIS2 logos + - Executive summary with overall compliance score + - Section analysis with horizontal bar chart + - SubSection breakdown table + - Critical failed requirements + - Requirements index organized by section and subsection + - Detailed findings for failed requirements + """ + + def create_cover_page(self, data: ComplianceData) -> list: + """ + Create the NIS2 report cover page with both logos. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Create logos side by side + prowler_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + nis2_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/nis2_logo.png" + ) + + prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch) + nis2_logo = Image(nis2_logo_path, width=2.3 * inch, height=1.5 * inch) + + logos_table = Table( + [[prowler_logo, nis2_logo]], colWidths=[4 * inch, 2.5 * inch] + ) + logos_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("VALIGN", (1, 0), (1, 0), "MIDDLE"), + ] + ) + ) + elements.append(logos_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Title + title = Paragraph( + "NIS2 Compliance Report
Directive (EU) 2022/2555", + self.styles["title"], + ) + elements.append(title) + elements.append(Spacer(1, 0.3 * inch)) + + # Compliance metadata table - use base class helper for consistency + info_rows = self._build_info_rows(data, language="en") + # Convert tuples to lists and wrap long text in Paragraphs + metadata_data = [] + for label, value in info_rows: + if label in ("Name:", "Description:") and value: + metadata_data.append( + [label, Paragraph(value, self.styles["normal_center"])] + ) + else: + metadata_data.append([label, value]) + + metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch]) + metadata_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_NIS2_PRIMARY), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), COLOR_NIS2_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(metadata_table) + + return elements + + def create_executive_summary(self, data: ComplianceData) -> list: + """ + Create the executive summary with compliance metrics. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Executive Summary", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + # Calculate statistics + total = len(data.requirements) + passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS) + failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL) + manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL) + + # Calculate compliance excluding manual + evaluated = passed + failed + overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100 + + # Summary statistics table + summary_data = [ + ["Metric", "Value"], + ["Total Requirements", str(total)], + ["Passed ✓", str(passed)], + ["Failed ✗", str(failed)], + ["Manual ⊙", str(manual)], + ["Overall Compliance", f"{overall_compliance:.1f}%"], + ] + + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE), + ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE), + ("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE), + ("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY), + ("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"), + ("FONTSIZE", (0, 0), (-1, 0), 12), + ("FONTSIZE", (0, 1), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, 0), 10), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), + ( + "ROWBACKGROUNDS", + (1, 1), + (1, -1), + [COLOR_WHITE, COLOR_NIS2_BG_BLUE], + ), + ] + ) + ) + elements.append(summary_table) + + return elements + + def create_charts_section(self, data: ComplianceData) -> list: + """ + Create the charts section with section analysis. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + # Section chart + elements.append(Paragraph("Compliance by Section", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + "The following chart shows compliance percentage for each main section " + "of the NIS2 directive:", + self.styles["normal_center"], + ) + ) + elements.append(Spacer(1, 0.1 * inch)) + + chart_buffer = self._create_section_chart(data) + chart_buffer.seek(0) + chart_image = Image(chart_buffer, width=6.5 * inch, height=5 * inch) + elements.append(chart_image) + elements.append(PageBreak()) + + # SubSection breakdown table + elements.append(Paragraph("SubSection Breakdown", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + subsection_table = self._create_subsection_table(data) + elements.append(subsection_table) + + return elements + + def create_requirements_index(self, data: ComplianceData) -> list: + """ + Create the requirements index organized by section and subsection. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Requirements Index", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + # Organize by section number and subsection + sections = {} + for req in data.requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + full_section = getattr(m, "Section", "Other") + # Extract section number from full title (e.g., "1 POLICY..." -> "1") + section_num = _extract_section_number(full_section) + subsection = getattr(m, "SubSection", "") + description = getattr(m, "Description", req.description) + + if section_num not in sections: + sections[section_num] = {} + if subsection not in sections[section_num]: + sections[section_num][subsection] = [] + + sections[section_num][subsection].append( + { + "id": req.id, + "description": description, + "status": req.status, + } + ) + + # Sort by NIS2 section order + for section in NIS2_SECTIONS: + if section not in sections: + continue + + section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}") + elements.append(Paragraph(section_title, self.styles["h2"])) + + for subsection_name, reqs in sections[section].items(): + if subsection_name: + # Truncate long subsection names for display + display_subsection = ( + subsection_name[:80] + "..." + if len(subsection_name) > 80 + else subsection_name + ) + elements.append(Paragraph(display_subsection, self.styles["h3"])) + + for req in reqs: + status_indicator = ( + "✓" if req["status"] == StatusChoices.PASS else "✗" + ) + if req["status"] == StatusChoices.MANUAL: + status_indicator = "⊙" + + desc = ( + req["description"][:60] + "..." + if len(req["description"]) > 60 + else req["description"] + ) + elements.append( + Paragraph( + f"{status_indicator} {req['id']}: {desc}", + self.styles["normal"], + ) + ) + + elements.append(Spacer(1, 0.1 * inch)) + + return elements + + def _create_section_chart(self, data: ComplianceData): + """ + Create the section compliance chart. + + Args: + data: Aggregated compliance data. + + Returns: + BytesIO buffer containing the chart image. + """ + section_scores = defaultdict(lambda: {"passed": 0, "total": 0}) + + for req in data.requirements: + if req.status == StatusChoices.MANUAL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + full_section = getattr(m, "Section", "Other") + # Extract section number from full title (e.g., "1 POLICY..." -> "1") + section_num = _extract_section_number(full_section) + section_scores[section_num]["total"] += 1 + if req.status == StatusChoices.PASS: + section_scores[section_num]["passed"] += 1 + + # Build labels and values in NIS2 section order + labels = [] + values = [] + for section in NIS2_SECTIONS: + if section in section_scores and section_scores[section]["total"] > 0: + scores = section_scores[section] + pct = (scores["passed"] / scores["total"]) * 100 + section_title = NIS2_SECTION_TITLES.get(section, f"Section {section}") + labels.append(section_title) + values.append(pct) + + return create_horizontal_bar_chart( + labels=labels, + values=values, + xlabel="Compliance (%)", + color_func=get_chart_color_for_percentage, + ) + + def _create_subsection_table(self, data: ComplianceData) -> Table: + """ + Create the subsection breakdown table. + + Args: + data: Aggregated compliance data. + + Returns: + ReportLab Table element. + """ + subsection_scores = defaultdict(lambda: {"passed": 0, "failed": 0, "manual": 0}) + + for req in data.requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + full_section = getattr(m, "Section", "") + subsection = getattr(m, "SubSection", "") + # Use section number + subsection for grouping + section_num = _extract_section_number(full_section) + # Create a shorter key using section number + if subsection: + # Extract subsection number if present (e.g., "1.1 Policy..." -> "1.1") + subsection_parts = subsection.split() + if subsection_parts: + key = subsection_parts[0] # Just the number like "1.1" + else: + key = f"{section_num}" + else: + key = section_num + + if req.status == StatusChoices.PASS: + subsection_scores[key]["passed"] += 1 + elif req.status == StatusChoices.FAIL: + subsection_scores[key]["failed"] += 1 + else: + subsection_scores[key]["manual"] += 1 + + table_data = [["Section", "Passed", "Failed", "Manual", "Compliance"]] + for key, scores in sorted( + subsection_scores.items(), key=lambda x: self._sort_section_key(x[0]) + ): + total = scores["passed"] + scores["failed"] + pct = (scores["passed"] / total * 100) if total > 0 else 100 + table_data.append( + [ + key, + str(scores["passed"]), + str(scores["failed"]), + str(scores["manual"]), + f"{pct:.1f}%", + ] + ) + + table = Table( + table_data, + colWidths=[1.2 * inch, 0.9 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch], + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_NIS2_PRIMARY), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_NIS2_BG_BLUE], + ), + ] + ) + ) + + return table + + def _sort_section_key(self, key: str) -> tuple: + """Sort section keys numerically (e.g., 1, 1.1, 1.2, 2, 11).""" + parts = key.split(".") + result = [] + for part in parts: + try: + result.append(int(part)) + except ValueError: + result.append(float("inf")) + return tuple(result) diff --git a/api/src/backend/tasks/jobs/reports/threatscore.py b/api/src/backend/tasks/jobs/reports/threatscore.py new file mode 100644 index 0000000000..e23085b1c3 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/threatscore.py @@ -0,0 +1,509 @@ +import gc + +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from api.models import StatusChoices + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + get_requirement_metadata, +) +from .charts import create_vertical_bar_chart, get_chart_color_for_percentage +from .components import get_color_for_compliance, get_color_for_weight +from .config import COLOR_HIGH_RISK, COLOR_WHITE + + +class ThreatScoreReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for Prowler ThreatScore framework. + + This generator creates comprehensive PDF reports containing: + - Compliance overview and metadata + - Section-by-section compliance scores with charts + - Overall ThreatScore calculation + - Critical failed requirements + - Detailed findings for each requirement + """ + + def create_executive_summary(self, data: ComplianceData) -> list: + """ + Create the executive summary section with ThreatScore calculation. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Compliance Score by Sections", self.styles["h1"])) + elements.append(Spacer(1, 0.2 * inch)) + + # Create section score chart + chart_buffer = self._create_section_score_chart(data) + chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch) + elements.append(chart_image) + + # Calculate overall ThreatScore + overall_compliance = self._calculate_threatscore(data) + + elements.append(Spacer(1, 0.3 * inch)) + + # Summary table + summary_data = [["ThreatScore:", f"{overall_compliance:.2f}%"]] + compliance_color = get_color_for_compliance(overall_compliance) + + summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, 0), colors.Color(0.1, 0.3, 0.5)), + ("TEXTCOLOR", (0, 0), (0, 0), colors.white), + ("FONTNAME", (0, 0), (0, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (0, 0), 12), + ("BACKGROUND", (1, 0), (1, 0), compliance_color), + ("TEXTCOLOR", (1, 0), (1, 0), colors.white), + ("FONTNAME", (1, 0), (1, 0), "FiraCode"), + ("FONTSIZE", (1, 0), (1, 0), 16), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + + elements.append(summary_table) + + return elements + + def _build_body_sections(self, data: ComplianceData) -> list: + """Override section order: Requirements Index before Critical Requirements.""" + elements = [] + + # Page break to separate from executive summary + elements.append(PageBreak()) + + # Requirements index first + elements.extend(self.create_requirements_index(data)) + + # Critical requirements section (already starts with PageBreak internally) + elements.extend(self.create_charts_section(data)) + elements.append(PageBreak()) + gc.collect() + + return elements + + def create_charts_section(self, data: ComplianceData) -> list: + """ + Create the critical failed requirements section. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + min_risk_level = getattr(self, "_min_risk_level", 4) + + # Start on a new page + elements.append(PageBreak()) + elements.append( + Paragraph("Top Requirements by Level of Risk", self.styles["h1"]) + ) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + f"Critical Failed Requirements (Risk Level ≥ {min_risk_level})", + self.styles["h2"], + ) + ) + elements.append(Spacer(1, 0.2 * inch)) + + critical_failed = self._get_critical_failed_requirements(data, min_risk_level) + + if not critical_failed: + elements.append( + Paragraph( + "✅ No critical failed requirements found. Great job!", + self.styles["normal"], + ) + ) + else: + elements.append( + Paragraph( + f"Found {len(critical_failed)} critical failed requirements " + "that require immediate attention:", + self.styles["normal"], + ) + ) + elements.append(Spacer(1, 0.5 * inch)) + + table = self._create_critical_requirements_table(critical_failed) + elements.append(table) + + # Immediate action required banner + elements.append(Spacer(1, 0.3 * inch)) + elements.append(self._create_action_required_banner()) + + return elements + + def create_requirements_index(self, data: ComplianceData) -> list: + """ + Create the requirements index organized by section and subsection. + + Args: + data: Aggregated compliance data. + + Returns: + List of ReportLab elements. + """ + elements = [] + + elements.append(Paragraph("Requirements Index", self.styles["h1"])) + + # Organize requirements by section and subsection + sections = {} + for req_id in data.attributes_by_requirement_id: + m = get_requirement_metadata(req_id, data.attributes_by_requirement_id) + if m: + section = getattr(m, "Section", "N/A") + subsection = getattr(m, "SubSection", "N/A") + title = getattr(m, "Title", "N/A") + + if section not in sections: + sections[section] = {} + if subsection not in sections[section]: + sections[section][subsection] = [] + + sections[section][subsection].append({"id": req_id, "title": title}) + + section_num = 1 + for section_name, subsections in sections.items(): + elements.append( + Paragraph(f"{section_num}. {section_name}", self.styles["h2"]) + ) + + for subsection_name, requirements in subsections.items(): + elements.append(Paragraph(f"{subsection_name}", self.styles["h3"])) + + for req in requirements: + elements.append( + Paragraph( + f"{req['id']} - {req['title']}", self.styles["normal"] + ) + ) + + section_num += 1 + elements.append(Spacer(1, 0.1 * inch)) + + return elements + + def _create_section_score_chart(self, data: ComplianceData): + """ + Create the section compliance score chart using weighted ThreatScore formula. + + The section score uses the same weighted formula as the overall ThreatScore: + Score = Σ(rate_i * total_findings_i * weight_i * rfac_i) / Σ(total_findings_i * weight_i * rfac_i) + Where rfac_i = 1 + 0.25 * risk_level + + Sections without findings are shown with 100% score. + + Args: + data: Aggregated compliance data. + + Returns: + BytesIO buffer containing the chart image. + """ + # First, collect ALL sections from requirements (including those without findings) + all_sections = set() + sections_data = {} + + for req in data.requirements: + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if m: + section = getattr(m, "Section", "Other") + all_sections.add(section) + + # Only calculate scores for requirements with findings + if req.total_findings == 0: + continue + + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) + # Ensure numeric types for calculations (compliance data may have str) + try: + risk_level = int(risk_level_raw) if risk_level_raw else 0 + except (ValueError, TypeError): + risk_level = 0 + try: + weight = int(weight_raw) if weight_raw else 0 + except (ValueError, TypeError): + weight = 0 + + # ThreatScore formula components + rate_i = req.passed_findings / req.total_findings + rfac_i = 1 + 0.25 * risk_level + + if section not in sections_data: + sections_data[section] = { + "numerator": 0, + "denominator": 0, + } + + sections_data[section]["numerator"] += ( + rate_i * req.total_findings * weight * rfac_i + ) + sections_data[section]["denominator"] += ( + req.total_findings * weight * rfac_i + ) + + # Calculate percentages for all sections + labels = [] + values = [] + for section in sorted(all_sections): + if section in sections_data and sections_data[section]["denominator"] > 0: + pct = ( + sections_data[section]["numerator"] + / sections_data[section]["denominator"] + ) * 100 + else: + # Sections without findings get 100% + pct = 100.0 + labels.append(section) + values.append(pct) + + return create_vertical_bar_chart( + labels=labels, + values=values, + ylabel="Compliance Score (%)", + xlabel="", + color_func=get_chart_color_for_percentage, + rotation=0, + ) + + def _calculate_threatscore(self, data: ComplianceData) -> float: + """ + Calculate the overall ThreatScore using the weighted formula. + + Args: + data: Aggregated compliance data. + + Returns: + Overall ThreatScore percentage. + """ + numerator = 0 + denominator = 0 + has_findings = False + + for req in data.requirements: + if req.total_findings == 0: + continue + + has_findings = True + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + + if m: + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) + # Ensure numeric types for calculations (compliance data may have str) + try: + risk_level = int(risk_level_raw) if risk_level_raw else 0 + except (ValueError, TypeError): + risk_level = 0 + try: + weight = int(weight_raw) if weight_raw else 0 + except (ValueError, TypeError): + weight = 0 + + rate_i = req.passed_findings / req.total_findings + rfac_i = 1 + 0.25 * risk_level + + numerator += rate_i * req.total_findings * weight * rfac_i + denominator += req.total_findings * weight * rfac_i + + if not has_findings: + return 100.0 + if denominator > 0: + return (numerator / denominator) * 100 + return 0.0 + + def _get_critical_failed_requirements( + self, data: ComplianceData, min_risk_level: int + ) -> list[dict]: + """ + Get critical failed requirements sorted by risk level and weight. + + Args: + data: Aggregated compliance data. + min_risk_level: Minimum risk level threshold. + + Returns: + List of critical failed requirement dictionaries. + """ + critical = [] + + for req in data.requirements: + if req.status != StatusChoices.FAIL: + continue + + m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + + if m: + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) + # Ensure numeric types for calculations (compliance data may have str) + try: + risk_level = int(risk_level_raw) if risk_level_raw else 0 + except (ValueError, TypeError): + risk_level = 0 + try: + weight = int(weight_raw) if weight_raw else 0 + except (ValueError, TypeError): + weight = 0 + + if risk_level >= min_risk_level: + critical.append( + { + "id": req.id, + "risk_level": risk_level, + "weight": weight, + "title": getattr(m, "Title", "N/A"), + "section": getattr(m, "Section", "N/A"), + } + ) + + critical.sort(key=lambda x: (x["risk_level"], x["weight"]), reverse=True) + return critical + + def _create_critical_requirements_table(self, critical_requirements: list) -> Table: + """ + Create the critical requirements table. + + Args: + critical_requirements: List of critical requirement dictionaries. + + Returns: + ReportLab Table element. + """ + table_data = [["Risk", "Weight", "Requirement ID", "Title", "Section"]] + + for req in critical_requirements: + title = req["title"] + if len(title) > 50: + title = title[:47] + "..." + + table_data.append( + [ + str(req["risk_level"]), + str(req["weight"]), + req["id"], + title, + req["section"], + ] + ) + + table = Table( + table_data, + colWidths=[0.7 * inch, 0.9 * inch, 1.3 * inch, 3.1 * inch, 1.5 * inch], + ) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("BACKGROUND", (0, 1), (0, -1), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 1), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 1), (0, -1), "FiraCode"), + ("ALIGN", (0, 1), (0, -1), "CENTER"), + ("FONTSIZE", (0, 1), (0, -1), 12), + ("ALIGN", (1, 1), (1, -1), "CENTER"), + ("FONTNAME", (1, 1), (1, -1), "FiraCode"), + ("FONTNAME", (2, 1), (2, -1), "FiraCode"), + ("FONTSIZE", (2, 1), (2, -1), 9), + ("FONTNAME", (3, 1), (-1, -1), "PlusJakartaSans"), + ("FONTSIZE", (3, 1), (-1, -1), 8), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.7, 0.7)), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("BACKGROUND", (1, 1), (-1, -1), colors.Color(0.98, 0.98, 0.98)), + ] + ) + ) + + # Color weight column based on value + for idx, req in enumerate(critical_requirements): + row_idx = idx + 1 + weight_color = get_color_for_weight(req["weight"]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (1, row_idx), (1, row_idx), weight_color), + ("TEXTCOLOR", (1, row_idx), (1, row_idx), COLOR_WHITE), + ] + ) + ) + + return table + + def _create_action_required_banner(self) -> Table: + """ + Create the 'Immediate Action Required' banner for critical requirements. + + Returns: + ReportLab Table element styled as a red-bordered alert banner. + """ + banner_style = ParagraphStyle( + "ActionRequired", + fontName="PlusJakartaSans", + fontSize=11, + textColor=COLOR_HIGH_RISK, + leading=16, + ) + + banner_content = Paragraph( + "IMMEDIATE ACTION REQUIRED:
" + "These requirements have the highest risk levels and have failed " + "compliance checks. Please prioritize addressing these issues to " + "improve your security posture.", + banner_style, + ) + + banner_table = Table( + [[banner_content]], + colWidths=[6.5 * inch], + ) + banner_table.setStyle( + TableStyle( + [ + ( + "BACKGROUND", + (0, 0), + (0, 0), + colors.Color(0.98, 0.92, 0.92), + ), + ("BOX", (0, 0), (0, 0), 2, COLOR_HIGH_RISK), + ("LEFTPADDING", (0, 0), (0, 0), 20), + ("RIGHTPADDING", (0, 0), (0, 0), 20), + ("TOPPADDING", (0, 0), (0, 0), 15), + ("BOTTOMPADDING", (0, 0), (0, 0), 15), + ] + ) + ) + + return banner_table diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 1486cb13ae..9697359065 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -1,23 +1,26 @@ import csv import io import json +import re import time import uuid from collections import defaultdict -from copy import deepcopy from datetime import datetime, timezone from typing import Any +import sentry_sdk from celery.utils.log import get_task_logger +from config.env import env from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS from django.db import IntegrityError, OperationalError -from django.db.models import Case, Count, IntegerField, Prefetch, Sum, When +from django.db.models import Case, Count, IntegerField, Prefetch, Q, Sum, When +from tasks.jobs.queries import ( + COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, + COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL, +) from tasks.utils import CustomEncoder -from api.compliance import ( - PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, - generate_scan_compliance, -) +from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import ( POSTGRES_TENANT_VAR, @@ -28,20 +31,28 @@ from api.db_utils import ( ) from api.exceptions import ProviderConnectionError from api.models import ( + AttackSurfaceOverview, + ComplianceOverviewSummary, ComplianceRequirementOverview, + DailySeveritySummary, Finding, + MuteRule, Processor, Provider, Resource, + ResourceFindingMapping, ResourceScanSummary, ResourceTag, Scan, + ScanCategorySummary, + ScanGroupSummary, ScanSummary, StateChoices, ) from api.models import StatusChoices as FindingStatus from api.utils import initialize_prowler_provider, return_prowler_provider from api.v1.serializers import ScanTaskSerializer +from prowler.lib.check.models import CheckMetadata from prowler.lib.outputs.finding import Finding as ProwlerFinding from prowler.lib.scan.scan import Scan as ProwlerScan @@ -68,6 +79,125 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = ( "total_findings", "scan_id", ) +# Controls how many findings we process per micro-batch before flushing to DB writes +FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000) +# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres +SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=500) + +ATTACK_SURFACE_PROVIDER_COMPATIBILITY = { + "internet-exposed": None, # Compatible with all providers + "secrets": None, # Compatible with all providers + "privilege-escalation": ["aws", "kubernetes"], + "ec2-imdsv1": ["aws"], +} + +_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {} + + +def aggregate_category_counts( + categories: list[str], + severity: str, + status: str, + delta: str | None, + muted: bool, + cache: dict[tuple[str, str], dict[str, int]], +) -> None: + """ + Increment category counters in-place for a finding. + + Args: + categories: List of categories from finding metadata. + severity: Severity level (e.g., "high", "medium"). + status: Finding status as string ("FAIL", "PASS"). + delta: Delta value as string ("new", "changed") or None. + muted: Whether the finding is muted. + cache: Dict {(category, severity): {"total", "failed", "new_failed"}} to update. + """ + is_failed = status == "FAIL" and not muted + is_new_failed = is_failed and delta == "new" + + for cat in categories: + key = (cat, severity) + if key not in cache: + cache[key] = {"total": 0, "failed": 0, "new_failed": 0} + if not muted: + cache[key]["total"] += 1 + if is_failed: + cache[key]["failed"] += 1 + if is_new_failed: + cache[key]["new_failed"] += 1 + + +def aggregate_resource_group_counts( + resource_group: str | None, + severity: str, + status: str, + delta: str | None, + muted: bool, + resource_uid: str, + cache: dict[tuple[str, str], dict[str, int]], + group_resources_cache: dict[str, set], +) -> None: + """ + Increment resource group counters in-place for a finding. + + Args: + resource_group: Resource group from check metadata (e.g., "database", "compute"). + severity: Severity level (e.g., "high", "medium"). + status: Finding status as string ("FAIL", "PASS"). + delta: Delta value as string ("new", "changed") or None. + muted: Whether the finding is muted. + resource_uid: Unique identifier for the resource to count distinct resources. + cache: Dict {(resource_group, severity): {"total", "failed", "new_failed"}} to update. + group_resources_cache: Dict {resource_group: set(resource_uids)} for group-level resource tracking. + """ + if not resource_group: + return + + is_failed = status == "FAIL" and not muted + is_new_failed = is_failed and delta == "new" + + key = (resource_group, severity) + if key not in cache: + cache[key] = {"total": 0, "failed": 0, "new_failed": 0} + if not muted: + cache[key]["total"] += 1 + if is_failed: + cache[key]["failed"] += 1 + if is_new_failed: + cache[key]["new_failed"] += 1 + + # Track resources at GROUP level (not per-severity) to avoid over-counting + if resource_uid and not muted: + group_resources_cache.setdefault(resource_group, set()).add(resource_uid) + + +def _get_attack_surface_mapping_from_provider(provider_type: str) -> dict: + global _ATTACK_SURFACE_MAPPING_CACHE + + if provider_type in _ATTACK_SURFACE_MAPPING_CACHE: + return _ATTACK_SURFACE_MAPPING_CACHE[provider_type] + + attack_surface_check_mappings = { + "internet-exposed": None, + "secrets": None, + "privilege-escalation": { + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_allows_privilege_escalation", + }, + "ec2-imdsv1": { + "ec2_instance_imdsv2_enabled" + }, # AWS only - IMDSv1 enabled findings + } + for category_name, check_ids in attack_surface_check_mappings.items(): + if check_ids is None: + sdk_check_ids = CheckMetadata.list( + provider=provider_type, category=category_name + ) + attack_surface_check_mappings[category_name] = sdk_check_ids + + _ATTACK_SURFACE_MAPPING_CACHE[provider_type] = attack_surface_check_mappings + return attack_surface_check_mappings def _create_finding_delta( @@ -200,25 +330,43 @@ def _copy_compliance_requirement_rows( def _persist_compliance_requirement_rows( - tenant_id: str, rows: list[dict[str, Any]] + tenant_id: str, rows: list[dict[str, Any]], batch_size: int = 10000 ) -> None: - """Persist compliance requirement rows using COPY with ORM fallback. + """Persist compliance requirement rows using batched COPY with ORM fallback. + + Splits large row sets into batches to reduce lock duration and improve concurrency. Args: tenant_id: Target tenant UUID. rows: Precomputed row dictionaries that reflect the compliance overview state for a scan. + batch_size: Number of rows per COPY batch (default: 10000). """ if not rows: return + total_rows = len(rows) + total_batches = (total_rows + batch_size - 1) // batch_size + try: - _copy_compliance_requirement_rows(tenant_id, rows) + # Process rows in batches to reduce lock duration + for batch_num in range(total_batches): + start_idx = batch_num * batch_size + end_idx = min(start_idx + batch_size, total_rows) + batch = rows[start_idx:end_idx] + + _copy_compliance_requirement_rows(tenant_id, batch) + + logger.info( + f"Compliance COPY batch {batch_num + 1}/{total_batches}: " + f"inserted {len(batch)} rows ({start_idx + len(batch)}/{total_rows} total)" + ) except Exception as error: logger.exception( "COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create", exc_info=error, ) + # Fallback: use ORM bulk_create for all remaining rows fallback_objects = [ ComplianceRequirementOverview( id=row["id"], @@ -246,12 +394,416 @@ def _persist_compliance_requirement_rows( ) +def _create_compliance_summaries( + tenant_id: str, scan_id: str, requirement_statuses: dict +) -> None: + """ + Create pre-aggregated compliance summaries from pre-computed requirement statuses. + + This computes the overall compliance scores across all regions for fast + lookup in the main compliance overview endpoint. + + Args: + tenant_id: Target tenant UUID + scan_id: Scan UUID + requirement_statuses: Pre-computed dict of {(compliance_id, requirement_id): {fail_count, pass_count, total_count}} + """ + # Determine per-requirement status and aggregate to compliance level + compliance_summaries = defaultdict( + lambda: { + "total_requirements": 0, + "requirements_passed": 0, + "requirements_failed": 0, + "requirements_manual": 0, + } + ) + + for (compliance_id, requirement_id), counts in requirement_statuses.items(): + # Apply business rule: any FAIL → requirement fails + if counts["fail_count"] > 0: + req_status = "FAIL" + elif counts["pass_count"] == counts["total_count"]: + req_status = "PASS" + else: + req_status = "MANUAL" + + # Aggregate to compliance level + compliance_summaries[compliance_id]["total_requirements"] += 1 + if req_status == "PASS": + compliance_summaries[compliance_id]["requirements_passed"] += 1 + elif req_status == "FAIL": + compliance_summaries[compliance_id]["requirements_failed"] += 1 + else: + compliance_summaries[compliance_id]["requirements_manual"] += 1 + + # Create summary objects + summary_objects = [] + for compliance_id, data in compliance_summaries.items(): + summary_objects.append( + ComplianceOverviewSummary( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + requirements_passed=data["requirements_passed"], + requirements_failed=data["requirements_failed"], + requirements_manual=data["requirements_manual"], + total_requirements=data["total_requirements"], + ) + ) + + # Bulk insert summaries + if summary_objects: + with rls_transaction(tenant_id): + ComplianceOverviewSummary.objects.bulk_create( + summary_objects, batch_size=500, ignore_conflicts=True + ) + + def _normalized_compliance_key(framework: str | None, version: str | None) -> str: """Return normalized identifier used to group compliance totals.""" - normalized_framework = (framework or "").lower().replace("-", "").replace("_", "") - normalized_version = (version or "").lower().replace("-", "").replace("_", "") - return f"{normalized_framework}{normalized_version}" + def _normalize(value: str | None) -> str: + if not value: + return "" + return re.sub(r"[^a-z0-9]", "", value.lower()) + + return f"{_normalize(framework)}{_normalize(version)}" + + +def _process_finding_micro_batch( + tenant_id: str, + findings_batch: list[ProwlerFinding], + scan_instance: Scan, + provider_instance: Provider, + resource_cache: dict, + tag_cache: dict, + last_status_cache: dict, + resource_failed_findings_cache: dict, + unique_resources: set, + scan_resource_cache: set, + mute_rules_cache: dict, + scan_categories_cache: dict[tuple[str, str], dict[str, int]], + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]], + group_resources_cache: dict[str, set], +) -> None: + """ + Process a micro-batch of findings and persist them using bulk operations. + + Each batch reuses caches (resources, tags, last statuses, mute rules) to avoid + redundant queries, updates denormalized resource data, and writes findings plus + resource mappings in as few transactions as possible. + + Args: + tenant_id: Tenant owning the scan. + findings_batch: Findings yielded by the Prowler scanner for this slice. + scan_instance: Scan ORM instance being updated. + provider_instance: Provider tied to the scan. + resource_cache: In-memory cache of provider resources indexed by UID. + tag_cache: Cache of `ResourceTag` instances keyed by (key, value). + last_status_cache: Cache of prior finding statuses keyed by finding UID. + resource_failed_findings_cache: Mutable counter of failed findings per resource. + unique_resources: Set tracking (uid, region) pairs seen in the scan. + scan_resource_cache: Set of tuples used to create `ResourceScanSummary` rows. + mute_rules_cache: Map of finding UID -> mute reason gathered before the scan. + scan_categories_cache: Dict tracking category counts {(category, severity): {"total", "failed", "new_failed"}}. + scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}. + group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}. + """ + # Accumulate objects for bulk operations + findings_to_create = [] + mappings_to_create = [] + dirty_resources = {} + resource_denormalized_data = [] # (finding_instance, resource_instance) pairs + skipped_findings_count = 0 # Track findings skipped due to UID length + + # Prefetch last statuses for all findings in this batch + # TEMPORARY WORKAROUND: Filter out UIDs > 300 chars to avoid query errors + finding_uids = [ + f.uid for f in findings_batch if f is not None and len(f.uid) <= 300 + ] + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + last_statuses = { + item["uid"]: (item["status"], item["first_seen_at"]) + for item in Finding.all_objects.filter( + tenant_id=tenant_id, uid__in=finding_uids + ) + .values("uid", "status", "first_seen_at") + .order_by("uid", "-inserted_at") + .distinct("uid") + } + # Update cache + for uid, data in last_statuses.items(): + if uid not in last_status_cache: + last_status_cache[uid] = data + + # Process each finding in the batch + for finding in findings_batch: + if finding is None: + logger.error(f"None finding detected on scan {scan_instance.id}.") + continue + + # Process resource with deadlock retry + for attempt in range(CELERY_DEADLOCK_ATTEMPTS): + try: + with rls_transaction(tenant_id): + resource_uid = finding.resource_uid + if resource_uid not in resource_cache: + check_metadata = finding.get_metadata() + group = check_metadata.get("resourcegroup") or None + resource_instance, _ = Resource.objects.get_or_create( + tenant_id=tenant_id, + provider=provider_instance, + uid=resource_uid, + defaults={ + "region": finding.region, + "service": finding.service_name, + "type": finding.resource_type, + "name": finding.resource_name, + "groups": [group] if group else None, + }, + ) + resource_cache[resource_uid] = resource_instance + resource_failed_findings_cache[resource_uid] = 0 + else: + resource_instance = resource_cache[resource_uid] + break + except (OperationalError, IntegrityError) as db_err: + if attempt < CELERY_DEADLOCK_ATTEMPTS - 1: + logger.warning( + f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} " + f"detected when processing resource {resource_uid} on scan {scan_instance.id}. Retrying..." + ) + time.sleep(0.1 * (2**attempt)) + continue + else: + raise db_err + + # Track resource field changes (defer save) + updated = False + check_metadata = finding.get_metadata() + group = check_metadata.get("resourcegroup") or None + if finding.region and resource_instance.region != finding.region: + resource_instance.region = finding.region + updated = True + if resource_instance.service != finding.service_name: + resource_instance.service = finding.service_name + updated = True + if resource_instance.type != finding.resource_type: + resource_instance.type = finding.resource_type + updated = True + if resource_instance.metadata != finding.resource_metadata: + resource_instance.metadata = json.dumps( + finding.resource_metadata, cls=CustomEncoder + ) + updated = True + if resource_instance.details != finding.resource_details: + resource_instance.details = finding.resource_details + updated = True + if resource_instance.partition != finding.partition: + resource_instance.partition = finding.partition + updated = True + if group and ( + not resource_instance.groups or group not in resource_instance.groups + ): + resource_instance.groups = (resource_instance.groups or []) + [group] + updated = True + + if updated: + dirty_resources[resource_uid] = resource_instance + + # Process tags + tags = [] + with rls_transaction(tenant_id): + for key, value in finding.resource_tags.items(): + tag_key = (key, value) + if tag_key not in tag_cache: + tag_instance, _ = ResourceTag.objects.get_or_create( + tenant_id=tenant_id, key=key, value=value + ) + tag_cache[tag_key] = tag_instance + else: + tag_instance = tag_cache[tag_key] + tags.append(tag_instance) + resource_instance.upsert_or_delete_tags(tags=tags) + + unique_resources.add((resource_instance.uid, resource_instance.region)) + + # Prepare finding data + finding_uid = finding.uid + + # TEMPORARY WORKAROUND: Skip findings with UID > 300 chars + # TODO: Remove this after implementing text field migration for finding.uid + if len(finding_uid) > 300: + skipped_findings_count += 1 + logger.warning( + f"Skipping finding with UID exceeding 300 characters. " + f"Length: {len(finding_uid)}, " + f"Check: {finding.check_id}, " + f"Resource: {finding.resource_name}, " + f"UID: {finding_uid}" + ) + continue + + last_status, last_first_seen_at = last_status_cache.get( + finding_uid, (None, None) + ) + + status = FindingStatus[finding.status] + delta = _create_finding_delta(last_status, status) + + if not last_first_seen_at: + last_first_seen_at = datetime.now(tz=timezone.utc) + + # Determine if finding should be muted and why + # Priority: mutelist processor (highest) > manual mute rules + is_muted = False + muted_reason = None + + # Check mutelist processor first (highest priority) + if finding.muted: + is_muted = True + muted_reason = "Muted by mutelist" + # If not muted by mutelist, check manual mute rules + elif finding_uid in mute_rules_cache: + is_muted = True + muted_reason = mute_rules_cache[finding_uid] + + # Increment failed_findings_count cache if needed + if status == FindingStatus.FAIL and not is_muted: + resource_failed_findings_cache[resource_uid] += 1 + + # Create finding object (don't save yet) + check_metadata = finding.get_metadata() + finding_instance = Finding( + tenant_id=tenant_id, + uid=finding_uid, + delta=delta, + check_metadata=check_metadata, + status=status, + status_extended=finding.status_extended, + severity=finding.severity, + impact=finding.severity, + raw_result=finding.raw, + check_id=finding.check_id, + scan=scan_instance, + first_seen_at=last_first_seen_at, + muted=is_muted, + muted_at=datetime.now(tz=timezone.utc) if is_muted else None, + muted_reason=muted_reason, + compliance=finding.compliance, + categories=check_metadata.get("categories", []) or [], + resource_groups=check_metadata.get("resourcegroup") or None, + ) + findings_to_create.append(finding_instance) + resource_denormalized_data.append((finding_instance, resource_instance)) + + # Track for scan summary + scan_resource_cache.add( + ( + str(resource_instance.id), + resource_instance.service, + resource_instance.region, + resource_instance.type, + ) + ) + + # Track categories with counts for ScanCategorySummary by (category, severity) + aggregate_category_counts( + categories=check_metadata.get("categories", []) or [], + severity=finding.severity.value, + status=status.value, + delta=delta.value if delta else None, + muted=is_muted, + cache=scan_categories_cache, + ) + + # Track resource groups with counts for ScanGroupSummary + aggregate_resource_group_counts( + resource_group=check_metadata.get("resourcegroup") or None, + severity=finding.severity.value, + status=status.value, + delta=delta.value if delta else None, + muted=is_muted, + resource_uid=resource_instance.uid if resource_instance else "", + cache=scan_resource_groups_cache, + group_resources_cache=group_resources_cache, + ) + + # Bulk operations within single transaction + with rls_transaction(tenant_id): + # Bulk create findings + if findings_to_create: + Finding.objects.bulk_create( + findings_to_create, batch_size=SCAN_DB_BATCH_SIZE + ) + + # Bulk create resource-finding mappings + for finding_instance, resource_instance in resource_denormalized_data: + mappings_to_create.append( + ResourceFindingMapping( + tenant_id=tenant_id, + resource=resource_instance, + finding=finding_instance, + ) + ) + + if mappings_to_create: + ResourceFindingMapping.objects.bulk_create( + mappings_to_create, + batch_size=SCAN_DB_BATCH_SIZE, + ignore_conflicts=True, + ) + + # Update finding denormalized arrays + findings_to_update = [] + for finding_instance, resource_instance in resource_denormalized_data: + if not finding_instance.resource_regions: + finding_instance.resource_regions = [] + if not finding_instance.resource_services: + finding_instance.resource_services = [] + if not finding_instance.resource_types: + finding_instance.resource_types = [] + + if resource_instance.region not in finding_instance.resource_regions: + finding_instance.resource_regions.append(resource_instance.region) + if resource_instance.service not in finding_instance.resource_services: + finding_instance.resource_services.append(resource_instance.service) + if resource_instance.type not in finding_instance.resource_types: + finding_instance.resource_types.append(resource_instance.type) + + findings_to_update.append(finding_instance) + + if findings_to_update: + Finding.objects.bulk_update( + findings_to_update, + ["resource_regions", "resource_services", "resource_types"], + batch_size=SCAN_DB_BATCH_SIZE, + ) + + # Bulk update dirty resources + if dirty_resources: + update_objects_in_batches( + tenant_id=tenant_id, + model=Resource, + objects=list(dirty_resources.values()), + fields=[ + "metadata", + "details", + "partition", + "region", + "service", + "type", + "groups", + ], + batch_size=1000, + ) + + # Log skipped findings summary + if skipped_findings_count > 0: + logger.warning( + f"Scan {scan_instance.id}: Skipped {skipped_findings_count} finding(s) " + f"due to UID length exceeding 300 characters in this micro-batch." + ) def perform_prowler_scan( @@ -261,24 +813,32 @@ def perform_prowler_scan( checks_to_execute: list[str] | None = None, ): """ - Perform a scan using Prowler and store the findings and resources in the database. + Run a Prowler scan and persist all generated resources, findings, and summaries. + + The scan stream is processed in micro-batches to keep memory bounded while still + benefiting from bulk writes. When the scan completes we also derive + `ResourceScanSummary` rows and return the serialized `Scan` payload used by the + API layer. Args: - tenant_id (str): The ID of the tenant for which the scan is performed. - scan_id (str): The ID of the scan instance. - provider_id (str): The ID of the provider to scan. - checks_to_execute (list[str], optional): A list of specific checks to execute. Defaults to None. + tenant_id: Tenant that owns the scan. + scan_id: UUID of the `Scan` row being executed. + provider_id: Provider to authenticate against. + checks_to_execute: Optional subset of check IDs to run. Returns: - dict: Serialized data of the completed scan instance. + Serialized `ScanTaskSerializer` data for the updated scan. Raises: - ValueError: If the provider cannot be connected. - + ProviderConnectionError: If the provider cannot be validated before scanning. + Exception: Any downstream persistence/processing error (re-raised after cleanup). """ exception = None unique_resources = set() scan_resource_cache: set[tuple[str, str, str, str]] = set() + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} start_time = time.time() exc = None @@ -301,6 +861,21 @@ def perform_prowler_scan( logger.error(f"Error processing mutelist rules: {e}") mutelist_processor = None + # Load enabled mute rules for this tenant + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + try: + active_mute_rules = MuteRule.objects.filter( + tenant_id=tenant_id, enabled=True + ).values_list("finding_uids", "reason") + + mute_rules_cache = {} + for finding_uids, reason in active_mute_rules: + for uid in finding_uids: + mute_rules_cache[uid] = reason + except Exception as e: + logger.error(f"Error loading mute rules: {e}") + mute_rules_cache = {} + try: with rls_transaction(tenant_id): try: @@ -333,159 +908,29 @@ def perform_prowler_scan( resource_failed_findings_cache = defaultdict(int) for progress, findings in prowler_scan.scan(): - for finding in findings: - if finding is None: - logger.error(f"None finding detected on scan {scan_id}.") - continue - for attempt in range(CELERY_DEADLOCK_ATTEMPTS): - try: - with rls_transaction(tenant_id): - # Process resource - resource_uid = finding.resource_uid - if resource_uid not in resource_cache: - # Get or create the resource - resource_instance, _ = Resource.objects.get_or_create( - tenant_id=tenant_id, - provider=provider_instance, - uid=resource_uid, - defaults={ - "region": finding.region, - "service": finding.service_name, - "type": finding.resource_type, - "name": finding.resource_name, - }, - ) - resource_cache[resource_uid] = resource_instance + # Process findings in micro-batches + findings_list = list(findings) + total_findings = len(findings_list) - # Initialize all processed resources in the cache - resource_failed_findings_cache[resource_uid] = 0 - else: - resource_instance = resource_cache[resource_uid] + # Chunk findings into micro-batches + for i in range(0, total_findings, FINDINGS_MICRO_BATCH_SIZE): + micro_batch = findings_list[i : i + FINDINGS_MICRO_BATCH_SIZE] - # Update resource fields if necessary - updated_fields = [] - if ( - finding.region - and resource_instance.region != finding.region - ): - resource_instance.region = finding.region - updated_fields.append("region") - if resource_instance.service != finding.service_name: - resource_instance.service = finding.service_name - updated_fields.append("service") - if resource_instance.type != finding.resource_type: - resource_instance.type = finding.resource_type - updated_fields.append("type") - if resource_instance.metadata != finding.resource_metadata: - resource_instance.metadata = json.dumps( - finding.resource_metadata, cls=CustomEncoder - ) - updated_fields.append("metadata") - if resource_instance.details != finding.resource_details: - resource_instance.details = finding.resource_details - updated_fields.append("details") - if resource_instance.partition != finding.partition: - resource_instance.partition = finding.partition - updated_fields.append("partition") - if updated_fields: - with rls_transaction(tenant_id): - resource_instance.save(update_fields=updated_fields) - except (OperationalError, IntegrityError) as db_err: - if attempt < CELERY_DEADLOCK_ATTEMPTS - 1: - logger.warning( - f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} " - f"detected when processing resource {resource_uid} on scan {scan_id}. Retrying..." - ) - time.sleep(0.1 * (2**attempt)) - continue - else: - raise db_err - - # Update tags - tags = [] - with rls_transaction(tenant_id): - for key, value in finding.resource_tags.items(): - tag_key = (key, value) - if tag_key not in tag_cache: - tag_instance, _ = ResourceTag.objects.get_or_create( - tenant_id=tenant_id, key=key, value=value - ) - tag_cache[tag_key] = tag_instance - else: - tag_instance = tag_cache[tag_key] - tags.append(tag_instance) - resource_instance.upsert_or_delete_tags(tags=tags) - - unique_resources.add((resource_instance.uid, resource_instance.region)) - - # Process finding - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - finding_uid = finding.uid - last_first_seen_at = None - if finding_uid not in last_status_cache: - most_recent_finding = ( - Finding.all_objects.filter( - tenant_id=tenant_id, uid=finding_uid - ) - .order_by("-inserted_at") - .values("status", "first_seen_at") - .first() - ) - last_status = None - if most_recent_finding: - last_status = most_recent_finding["status"] - last_first_seen_at = most_recent_finding["first_seen_at"] - last_status_cache[finding_uid] = last_status, last_first_seen_at - else: - last_status, last_first_seen_at = last_status_cache[finding_uid] - - status = FindingStatus[finding.status] - delta = _create_finding_delta(last_status, status) - # For the findings prior to the change, when a first finding is found with delta!="new" it will be - # assigned a current date as first_seen_at and the successive findings with the same UID will - # always get the date of the previous finding. - # For new findings, when a finding (delta="new") is found for the first time, the first_seen_at - # attribute will be assigned the current date, the following findings will get that date. - if not last_first_seen_at: - last_first_seen_at = datetime.now(tz=timezone.utc) - - # If the finding is muted at this time the reason must be the configured Mutelist - muted_reason = "Muted by mutelist" if finding.muted else None - - # Increment failed_findings_count cache if the finding status is FAIL and not muted - if status == FindingStatus.FAIL and not finding.muted: - resource_uid = finding.resource_uid - resource_failed_findings_cache[resource_uid] += 1 - - with rls_transaction(tenant_id): - # Create the finding - finding_instance = Finding.objects.create( - tenant_id=tenant_id, - uid=finding_uid, - delta=delta, - check_metadata=finding.get_metadata(), - status=status, - status_extended=finding.status_extended, - severity=finding.severity, - impact=finding.severity, - raw_result=finding.raw, - check_id=finding.check_id, - scan=scan_instance, - first_seen_at=last_first_seen_at, - muted=finding.muted, - muted_reason=muted_reason, - compliance=finding.compliance, - ) - finding_instance.add_resources([resource_instance]) - - # Update scan resource summaries - scan_resource_cache.add( - ( - str(resource_instance.id), - resource_instance.service, - resource_instance.region, - resource_instance.type, - ) + _process_finding_micro_batch( + tenant_id=tenant_id, + findings_batch=micro_batch, + scan_instance=scan_instance, + provider_instance=provider_instance, + resource_cache=resource_cache, + tag_cache=tag_cache, + last_status_cache=last_status_cache, + resource_failed_findings_cache=resource_failed_findings_cache, + unique_resources=unique_resources, + scan_resource_cache=scan_resource_cache, + mute_rules_cache=mute_rules_cache, + scan_categories_cache=scan_categories_cache, + scan_resource_groups_cache=scan_resource_groups_cache, + group_resources_cache=group_resources_cache, ) # Update scan progress @@ -545,47 +990,80 @@ def perform_prowler_scan( resource_scan_summaries, batch_size=500, ignore_conflicts=True ) except Exception as filter_exception: - import sentry_sdk - sentry_sdk.capture_exception(filter_exception) logger.error( f"Error storing filter values for scan {scan_id}: {filter_exception}" ) + try: + if scan_categories_cache: + category_summaries = [ + ScanCategorySummary( + tenant_id=tenant_id, + scan_id=scan_id, + category=category, + severity=severity, + total_findings=counts["total"], + failed_findings=counts["failed"], + new_failed_findings=counts["new_failed"], + ) + for (category, severity), counts in scan_categories_cache.items() + ] + with rls_transaction(tenant_id): + ScanCategorySummary.objects.bulk_create( + category_summaries, batch_size=500, ignore_conflicts=True + ) + except Exception as cat_exception: + sentry_sdk.capture_exception(cat_exception) + logger.error(f"Error storing categories for scan {scan_id}: {cat_exception}") + + try: + if scan_resource_groups_cache: + # Compute group-level resource counts (same value for all severity rows in a group) + group_resource_counts = { + grp: len(uids) for grp, uids in group_resources_cache.items() + } + resource_group_summaries = [ + ScanGroupSummary( + tenant_id=tenant_id, + scan_id=scan_id, + resource_group=grp, + severity=severity, + total_findings=counts["total"], + failed_findings=counts["failed"], + new_failed_findings=counts["new_failed"], + resources_count=group_resource_counts.get(grp, 0), + ) + for ( + grp, + severity, + ), counts in scan_resource_groups_cache.items() + ] + with rls_transaction(tenant_id): + ScanGroupSummary.objects.bulk_create( + resource_group_summaries, batch_size=500, ignore_conflicts=True + ) + except Exception as rg_exception: + sentry_sdk.capture_exception(rg_exception) + logger.error( + f"Error storing resource groups for scan {scan_id}: {rg_exception}" + ) + serializer = ScanTaskSerializer(instance=scan_instance) return serializer.data def aggregate_findings(tenant_id: str, scan_id: str): """ - Aggregates findings for a given scan and stores the results in the ScanSummary table. + Aggregate findings for a scan and populate `ScanSummary` rows. - This function retrieves all findings associated with a given `scan_id` and calculates various - metrics such as counts of failed, passed, and muted findings, as well as their deltas (new, - changed, unchanged). The results are grouped by `check_id`, `service`, `severity`, and `region`. - These aggregated metrics are then stored in the `ScanSummary` table. - - Additionally, it updates the failed_findings_count field for each resource based on the most - recent findings for each finding.uid. + We group findings by check/service/severity/region and compute pass/fail/muted + totals plus delta counts (new/changed/unchanged). The summary dataset feeds the + overview API and dashboards, so it is recomputed every time a scan finishes. Args: - tenant_id (str): The ID of the tenant to which the scan belongs. - scan_id (str): The ID of the scan for which findings need to be aggregated. - - Aggregated Metrics: - - fail: Total number of failed findings. - - _pass: Total number of passed findings. - - muted: Total number of muted findings. - - total: Total number of findings. - - new: Total number of new findings. - - changed: Total number of changed findings. - - unchanged: Total number of unchanged findings. - - fail_new: Failed findings with a delta of 'new'. - - fail_changed: Failed findings with a delta of 'changed'. - - pass_new: Passed findings with a delta of 'new'. - - pass_changed: Passed findings with a delta of 'changed'. - - muted_new: Muted findings with a delta of 'new'. - - muted_changed: Muted findings with a delta of 'changed'. + tenant_id: Tenant that owns the scan. + scan_id: Scan UUID whose findings should be aggregated. """ with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): findings = Finding.objects.filter(tenant_id=tenant_id, scan_id=scan_id) @@ -711,50 +1189,37 @@ def aggregate_findings(tenant_id: str, scan_id: str): ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000) -def create_compliance_requirements(tenant_id: str, scan_id: str): +def _aggregate_findings_by_region( + tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str +) -> tuple[dict, dict]: """ - Create detailed compliance requirement overview records for a scan. + Aggregate findings by region using optimized ORM queries. - This function processes the compliance data collected during a scan and creates - individual records for each compliance requirement in each region. These detailed - records provide a granular view of compliance status. + Replaces nested Python loops with efficient queries and aggregation. Args: - tenant_id (str): The ID of the tenant for which to create records. - scan_id (str): The ID of the scan for which to create records. + tenant_id: Tenant UUID + scan_id: Scan UUID + modeled_threatscore_compliance_id: ID for ThreatScore compliance framework Returns: - dict: A dictionary containing the number of requirements created and the regions processed. - - Raises: - ValidationError: If tenant_id is not a valid UUID. + tuple: (check_status_by_region, findings_count_by_compliance) + - check_status_by_region: {region: {check_id: status}} + - findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}} """ - try: - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - scan_instance = Scan.objects.get(pk=scan_id) - provider_instance = scan_instance.provider - prowler_provider = return_prowler_provider(provider_instance) + check_status_by_region = {} + findings_count_by_compliance = {} - compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ - provider_instance.provider - ] - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - threatscore_requirements_by_check: dict[str, set[str]] = {} - threatscore_framework = compliance_template.get( - modeled_threatscore_compliance_id - ) - if threatscore_framework: - for requirement_id, requirement in threatscore_framework[ - "requirements" - ].items(): - for check_id in requirement["checks"]: - threatscore_requirements_by_check.setdefault(check_id, set()).add( - requirement_id - ) - - # Get check status data by region from findings + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Fetch only PASS/FAIL findings (optimized query reduces data transfer) + # Other statuses are not needed for check_status or ThreatScore calculation findings = ( - Finding.all_objects.filter(scan_id=scan_id, muted=False) + Finding.all_objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + muted=False, + status__in=["PASS", "FAIL"], + ) .only("id", "check_id", "status", "compliance") .prefetch_related( Prefetch( @@ -763,120 +1228,521 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): to_attr="small_resources", ) ) - .iterator(chunk_size=1000) ) - findings_count_by_compliance = {} - check_status_by_region = {} + # Process findings in a single pass (more efficient than original nested loops) + normalized_id = re.sub( + r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() + ) + + for finding in findings: + status = finding.status + + for resource in finding.small_resources: + region = resource.region + + # Aggregate check status by region + current_status = check_status_by_region.setdefault(region, {}) + # Priority: FAIL > any other status + if current_status.get(finding.check_id) != "FAIL": + current_status[finding.check_id] = status + + # Aggregate ThreatScore compliance counts + if modeled_threatscore_compliance_id in (finding.compliance or {}): + compliance_key = findings_count_by_compliance.setdefault( + region, {} + ).setdefault(normalized_id, {}) + + for requirement_id in finding.compliance[ + modeled_threatscore_compliance_id + ]: + requirement_stats = compliance_key.setdefault( + requirement_id, {"total": 0, "pass": 0} + ) + requirement_stats["total"] += 1 + if status == "PASS": + requirement_stats["pass"] += 1 + + return check_status_by_region, findings_count_by_compliance + + +def create_compliance_requirements(tenant_id: str, scan_id: str): + """ + Materialize per-requirement compliance rows (and summaries) for a scan. + + Using the provider’s compliance template plus the scan’s findings, we compute a + row per (region, compliance, requirement) and write it to + `ComplianceRequirementOverview` via COPY. The same pass tally requirement + statuses so we can persist `ComplianceOverviewSummary` records for the fast + overview endpoint. + + Args: + tenant_id: Tenant running the scan. + scan_id: Scan identifier whose findings should be translated into compliance data. + + Returns: + dict: Counts/metadata about the generated rows (e.g., frameworks touched, regions processed). + """ + try: with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - for finding in findings: - for resource in finding.small_resources: - region = resource.region - current_status = check_status_by_region.setdefault(region, {}) - if current_status.get(finding.check_id) != "FAIL": - current_status[finding.check_id] = finding.status - if modeled_threatscore_compliance_id in finding.compliance: - for requirement_id in finding.compliance[ - modeled_threatscore_compliance_id - ]: - compliance_key = findings_count_by_compliance.setdefault( - region, {} - ).setdefault( - modeled_threatscore_compliance_id.lower().replace( - "-", "" - ), - {}, - ) - if requirement_id not in compliance_key: - compliance_key[requirement_id] = { - "total": 0, - "pass": 0, - } + scan_instance = Scan.objects.get(pk=scan_id) + provider_instance = scan_instance.provider + return_prowler_provider(provider_instance) - compliance_key[requirement_id]["total"] += 1 - if finding.status == "PASS": - compliance_key[requirement_id]["pass"] += 1 + compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ + provider_instance.provider + ] + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - try: - # Try to get regions from provider - regions = prowler_provider.get_regions() - except (AttributeError, Exception): - # If not available, use regions from findings - regions = set(check_status_by_region.keys()) - - # Create compliance data by region - compliance_overview_by_region = { - region: deepcopy(compliance_template) for region in regions - } - - # Apply check statuses to compliance data - for region, check_status in check_status_by_region.items(): - compliance_data = compliance_overview_by_region.setdefault( - region, deepcopy(compliance_template) - ) - for check_name, status in check_status.items(): - generate_scan_compliance( - compliance_data, - provider_instance.provider, - check_name, - status, - ) - - # Prepare compliance requirement rows - compliance_requirement_rows: list[dict[str, Any]] = [] - utc_datetime_now = datetime.now(tz=timezone.utc) - for region, compliance_data in compliance_overview_by_region.items(): - for compliance_id, compliance in compliance_data.items(): - modeled_compliance_id = _normalized_compliance_key( - compliance["framework"], compliance["version"] - ) - # Create an overview record for each requirement within each compliance framework - for requirement_id, requirement in compliance["requirements"].items(): - checks_status = requirement["checks_status"] - compliance_requirement_rows.append( - { - "id": uuid.uuid4(), - "tenant_id": tenant_id, - "inserted_at": utc_datetime_now, - "compliance_id": compliance_id, - "framework": compliance["framework"], - "version": compliance["version"] or "", - "description": requirement.get("description") or "", - "region": region, - "requirement_id": requirement_id, - "requirement_status": requirement["status"], - "passed_checks": checks_status["pass"], - "failed_checks": checks_status["fail"], - "total_checks": checks_status["total"], - "scan_id": scan_instance.id, - "passed_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("pass", 0), - "total_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("total", 0), - } + requirement_lookup: dict[str, list[tuple[str, str]]] = {} + for compliance_id, compliance in compliance_template.items(): + for requirement_id, requirement in compliance["requirements"].items(): + for check_id in requirement["checks"].keys(): + requirement_lookup.setdefault(check_id, []).append( + (compliance_id, requirement_id) ) - # Bulk create requirement records using PostgreSQL COPY - _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows) + compliance_requirement_rows: list[dict[str, Any]] = [] + regions = [] + requirement_statuses = defaultdict( + lambda: {"fail_count": 0, "pass_count": 0, "total_count": 0} + ) + + # Skip if provider has no compliance frameworks + if compliance_template: + # Aggregate findings by region using SQL for optimal performance + check_status_by_region, findings_count_by_compliance = ( + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + ) + + # Only process regions that have findings (optimization: reduces row count) + regions = list(check_status_by_region.keys()) + + region_requirement_stats: dict[ + str, dict[str, dict[str, dict[str, int]]] + ] = defaultdict(lambda: defaultdict(dict)) + for region, check_status in check_status_by_region.items(): + for check_name, status in check_status.items(): + targets = requirement_lookup.get(check_name) + if not targets: + continue + status_lower = (status or "").lower() + if status_lower not in {"pass", "fail"}: + continue + for compliance_id, requirement_id in targets: + compliance_stats = region_requirement_stats[region].setdefault( + compliance_id, {} + ) + requirement_stats = compliance_stats.setdefault( + requirement_id, {"passed_checks": 0, "failed_checks": 0} + ) + if status_lower == "pass": + requirement_stats["passed_checks"] += 1 + else: + requirement_stats["failed_checks"] += 1 + + # Prepare compliance requirement rows and compute summaries in single pass + utc_datetime_now = datetime.now(tz=timezone.utc) + + # Pre-compute shared strings (optimization: reduces string conversions) + tenant_id_str = str(tenant_id) + scan_id_str = str(scan_instance.id) + + for region in regions: + region_stats = region_requirement_stats.get(region, {}) + for compliance_id, compliance in compliance_template.items(): + modeled_compliance_id = _normalized_compliance_key( + compliance["framework"], compliance["version"] + ) + compliance_stats = region_stats.get(compliance_id, {}) + # Create an overview record for each requirement within each compliance framework + for requirement_id, requirement in compliance[ + "requirements" + ].items(): + stats = compliance_stats.get(requirement_id) + passed_checks = stats["passed_checks"] if stats else 0 + failed_checks = stats["failed_checks"] if stats else 0 + total_checks = len(requirement["checks"]) + if total_checks == 0: + requirement_status = "MANUAL" + elif failed_checks > 0: + requirement_status = "FAIL" + else: + requirement_status = "PASS" + + compliance_requirement_rows.append( + { + "id": uuid.uuid4(), + "tenant_id": tenant_id_str, + "inserted_at": utc_datetime_now, + "compliance_id": compliance_id, + "framework": compliance["framework"], + "version": compliance["version"] or "", + "description": requirement.get("description") or "", + "region": region, + "requirement_id": requirement_id, + "requirement_status": requirement_status, + "passed_checks": passed_checks, + "failed_checks": failed_checks, + "total_checks": total_checks, + "scan_id": scan_id_str, + "passed_findings": findings_count_by_compliance.get( + region, {} + ) + .get(modeled_compliance_id, {}) + .get(requirement_id, {}) + .get("pass", 0), + "total_findings": findings_count_by_compliance.get( + region, {} + ) + .get(modeled_compliance_id, {}) + .get(requirement_id, {}) + .get("total", 0), + } + ) + + # Update summary tracking (single-pass optimization) + key = (compliance_id, requirement_id) + requirement_statuses[key]["total_count"] += 1 + if requirement_status == "FAIL": + requirement_statuses[key]["fail_count"] += 1 + elif requirement_status == "PASS": + requirement_statuses[key]["pass_count"] += 1 + + # Bulk create requirement records using PostgreSQL COPY + _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows) + + # Create pre-aggregated summaries for fast compliance overview lookups + _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) return { "requirements_created": len(compliance_requirement_rows), "regions_processed": list(regions), "compliance_frameworks": ( - list(compliance_overview_by_region.get(list(regions)[0], {}).keys()) - if regions - else [] + list(compliance_template.keys()) if regions else [] ), } except Exception as e: logger.error(f"Error creating compliance requirements for scan {scan_id}: {e}") raise e + + +def aggregate_attack_surface(tenant_id: str, scan_id: str): + """ + Aggregate findings into attack surface overview records. + + Creates one AttackSurfaceOverview record per attack surface type + for the given scan, based on check_id mappings. + + Args: + tenant_id: Tenant that owns the scan. + scan_id: Scan UUID whose findings should be aggregated. + """ + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + scan_instance = Scan.all_objects.select_related("provider").get(pk=scan_id) + provider_type = scan_instance.provider.provider + + provider_attack_surface_mapping = _get_attack_surface_mapping_from_provider( + provider_type=provider_type + ) + + # Filter out attack surfaces that are not compatible or have no resolved check IDs + supported_mappings: dict[str, list[str]] = {} + for attack_surface_type, check_ids in provider_attack_surface_mapping.items(): + compatible_providers = ATTACK_SURFACE_PROVIDER_COMPATIBILITY.get( + attack_surface_type + ) + if ( + compatible_providers is not None + and provider_type not in compatible_providers + ): + logger.info( + f"Skipping {attack_surface_type} - not supported for {provider_type}" + ) + continue + + if not check_ids: + logger.info( + f"Skipping {attack_surface_type} - no check IDs resolved for {provider_type}" + ) + continue + + supported_mappings[attack_surface_type] = list(check_ids) + + if not supported_mappings: + logger.info( + f"No attack surface mappings available for scan {scan_id} and provider {provider_type}" + ) + logger.info(f"No attack surface overview records created for scan {scan_id}") + return + + # Map every check_id to its attack surface, so we can aggregate with a single query + check_id_to_surface: dict[str, str] = {} + for attack_surface_type, check_ids in supported_mappings.items(): + for check_id in check_ids: + check_id_to_surface[check_id] = attack_surface_type + + aggregated_counts = { + attack_surface_type: {"total": 0, "failed": 0, "muted": 0} + for attack_surface_type in supported_mappings.keys() + } + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + finding_stats = ( + Finding.all_objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + check_id__in=list(check_id_to_surface.keys()), + ) + .values("check_id") + .annotate( + total=Count("id"), + failed=Count("id", filter=Q(status="FAIL", muted=False)), + muted=Count("id", filter=Q(status="FAIL", muted=True)), + ) + ) + + for stats in finding_stats: + attack_surface_type = check_id_to_surface.get(stats["check_id"]) + if not attack_surface_type: + continue + + aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0 + aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0 + aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0 + + overview_objects = [] + for attack_surface_type, counts in aggregated_counts.items(): + total = counts["total"] + if not total: + continue + + overview_objects.append( + AttackSurfaceOverview( + tenant_id=tenant_id, + scan_id=scan_id, + attack_surface_type=attack_surface_type, + total_findings=total, + failed_findings=counts["failed"], + muted_failed_findings=counts["muted"], + ) + ) + + # Bulk create overview records + if overview_objects: + with rls_transaction(tenant_id): + AttackSurfaceOverview.objects.bulk_create(overview_objects, batch_size=500) + logger.info( + f"Created {len(overview_objects)} attack surface overview records for scan {scan_id}" + ) + else: + logger.info(f"No attack surface overview records created for scan {scan_id}") + + +def aggregate_daily_severity(tenant_id: str, scan_id: str): + """Aggregate scan severity counts into DailySeveritySummary (one record per provider/day).""" + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + scan = Scan.objects.filter( + tenant_id=tenant_id, + id=scan_id, + state=StateChoices.COMPLETED, + ).first() + + if not scan: + logger.warning(f"Scan {scan_id} not found or not completed") + return {"status": "scan is not completed"} + + provider_id = scan.provider_id + scan_date = scan.completed_at.date() + + severity_totals = ( + ScanSummary.objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + ) + .values("severity") + .annotate(total_fail=Sum("fail"), total_muted=Sum("muted")) + ) + + severity_data = { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "informational": 0, + "muted": 0, + } + + for row in severity_totals: + severity = row["severity"] + if severity in severity_data: + severity_data[severity] = row["total_fail"] or 0 + severity_data["muted"] += row["total_muted"] or 0 + + with rls_transaction(tenant_id): + summary, created = DailySeveritySummary.objects.update_or_create( + tenant_id=tenant_id, + provider_id=provider_id, + date=scan_date, + defaults={ + "scan_id": scan_id, + "critical": severity_data["critical"], + "high": severity_data["high"], + "medium": severity_data["medium"], + "low": severity_data["low"], + "informational": severity_data["informational"], + "muted": severity_data["muted"], + }, + ) + + action = "created" if created else "updated" + logger.info( + f"Daily severity summary {action} for provider {provider_id} on {scan_date}" + ) + + return { + "status": action, + "provider_id": str(provider_id), + "date": str(scan_date), + "severity_data": severity_data, + } + + +def update_provider_compliance_scores(tenant_id: str, scan_id: str): + """ + Update ProviderComplianceScore with requirement statuses from a completed scan. + + Uses atomic SQL upsert with ON CONFLICT for concurrency safety. Only updates + if the new scan is more recent than existing data. Also cleans up stale + requirements that no longer exist in the new scan. + + Reads from primary DB (not replica) to avoid replication lag issues since + this runs immediately after create_compliance_requirements_task. + + Args: + tenant_id: Tenant that owns the scan. + scan_id: Scan UUID whose compliance data should be materialized. + + Returns: + dict: Statistics about the upsert operation. + """ + with rls_transaction(tenant_id): + scan = ( + Scan.all_objects.filter( + tenant_id=tenant_id, + id=scan_id, + state=StateChoices.COMPLETED, + ) + .select_related("provider") + .first() + ) + + if not scan: + logger.warning( + f"Scan {scan_id} not found or not completed for compliance score update" + ) + return {"status": "skipped", "reason": "scan not completed"} + + if not scan.completed_at: + logger.warning(f"Scan {scan_id} has no completed_at timestamp") + return {"status": "skipped", "reason": "no completed_at"} + + provider_id = str(scan.provider_id) + scan_completed_at = scan.completed_at + + delete_stale_sql = """ + DELETE FROM provider_compliance_scores pcs + WHERE pcs.tenant_id = %s + AND pcs.provider_id = %s + AND pcs.scan_completed_at < %s + AND NOT EXISTS ( + SELECT 1 FROM compliance_requirements_overviews cro + WHERE cro.tenant_id = pcs.tenant_id + AND cro.scan_id = %s + AND cro.compliance_id = pcs.compliance_id + AND cro.requirement_id = pcs.requirement_id + ) + RETURNING compliance_id + """ + + compliance_ids_sql = """ + SELECT DISTINCT compliance_id + FROM compliance_requirements_overviews + WHERE tenant_id = %s AND scan_id = %s + """ + + try: + with psycopg_connection(MainRouter.default_db) as connection: + connection.autocommit = False + try: + with connection.cursor() as cursor: + cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id]) + + # Update requirement-level scores per provider + cursor.execute( + COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL, [tenant_id, scan_id] + ) + upserted_count = cursor.rowcount + + cursor.execute(compliance_ids_sql, [tenant_id, scan_id]) + scan_rows = cursor.fetchall() + if not isinstance(scan_rows, (list, tuple)): + scan_rows = [] + scan_compliance_ids = {row[0] for row in scan_rows} + + cursor.execute( + delete_stale_sql, + [tenant_id, provider_id, scan_completed_at, scan_id], + ) + deleted_rows = cursor.fetchall() + if not isinstance(deleted_rows, (list, tuple)): + deleted_rows = [] + deleted_ids = {row[0] for row in deleted_rows} + stale_deleted = len(deleted_ids) + + impacted_compliance_ids = sorted(scan_compliance_ids | deleted_ids) + + if impacted_compliance_ids: + # Advisory lock on tenant to prevent race conditions when + # multiple scans complete simultaneously for the same tenant + cursor.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s))", [tenant_id] + ) + + # Recalculate tenant-level summary (FAIL-dominant across all providers) + cursor.execute( + COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL, + [tenant_id, tenant_id, impacted_compliance_ids], + ) + tenant_summary_count = cursor.rowcount + else: + tenant_summary_count = 0 + + connection.commit() + except Exception: + connection.rollback() + raise + + logger.info( + f"Provider compliance scores updated for scan {scan_id}: " + f"{upserted_count} upserted, {stale_deleted} stale deleted, " + f"{tenant_summary_count} tenant summaries upserted" + ) + + return { + "status": "completed", + "scan_id": str(scan_id), + "provider_id": provider_id, + "upserted": upserted_count, + "stale_deleted": stale_deleted, + "tenant_summary_count": tenant_summary_count, + } + + except Exception as e: + logger.error( + f"Error updating provider compliance scores for scan {scan_id}: {e}" + ) + raise diff --git a/api/src/backend/tasks/jobs/threatscore.py b/api/src/backend/tasks/jobs/threatscore.py new file mode 100644 index 0000000000..a9a7516e55 --- /dev/null +++ b/api/src/backend/tasks/jobs/threatscore.py @@ -0,0 +1,216 @@ +from celery.utils.log import get_task_logger +from tasks.jobs.threatscore_utils import ( + _aggregate_requirement_statistics_from_database, + _calculate_requirements_data_from_statistics, +) + +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Provider, StatusChoices +from prowler.lib.check.compliance_models import Compliance + +logger = get_task_logger(__name__) + + +def compute_threatscore_metrics( + tenant_id: str, + scan_id: str, + provider_id: str, + compliance_id: str, + min_risk_level: int = 4, +) -> dict: + """ + Compute ThreatScore metrics for a given scan. + + This function calculates all the metrics needed for a ThreatScore snapshot: + - Overall ThreatScore percentage + - Section-by-section scores + - Critical failed requirements (risk >= min_risk_level) + - Summary statistics (requirements and findings counts) + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to analyze. + provider_id (str): The ID of the provider used in the scan. + compliance_id (str): Compliance framework ID (e.g., "prowler_threatscore_aws"). + min_risk_level (int): Minimum risk level for critical requirements. Defaults to 4. + + Returns: + dict: A dictionary containing: + - overall_score (float): Overall ThreatScore percentage (0-100) + - section_scores (dict): Section name -> score percentage mapping + - critical_requirements (list): List of critical failed requirement dicts + - total_requirements (int): Total number of requirements + - passed_requirements (int): Number of PASS requirements + - failed_requirements (int): Number of FAIL requirements + - manual_requirements (int): Number of MANUAL requirements + - total_findings (int): Total findings count + - passed_findings (int): Passed findings count + - failed_findings (int): Failed findings count + + Example: + >>> metrics = compute_threatscore_metrics( + ... tenant_id="tenant-123", + ... scan_id="scan-456", + ... provider_id="provider-789", + ... compliance_id="prowler_threatscore_aws" + ... ) + >>> print(f"Overall ThreatScore: {metrics['overall_score']:.2f}%") + """ + # Get provider and compliance information + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + provider_obj = Provider.objects.get(id=provider_id) + provider_type = provider_obj.provider + + frameworks_bulk = Compliance.get_bulk(provider_type) + compliance_obj = frameworks_bulk[compliance_id] + + # Aggregate requirement statistics from database + requirement_statistics_by_check_id = ( + _aggregate_requirement_statistics_from_database(tenant_id, scan_id) + ) + + # Calculate requirements data using aggregated statistics + attributes_by_requirement_id, requirements_list = ( + _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics_by_check_id + ) + ) + + # Initialize metrics + overall_numerator = 0 + overall_denominator = 0 + overall_has_findings = False + + sections_data = {} + + total_requirements = len(requirements_list) + passed_requirements = 0 + failed_requirements = 0 + manual_requirements = 0 + total_findings = 0 + passed_findings = 0 + failed_findings = 0 + + critical_requirements_list = [] + + # Process each requirement + for requirement in requirements_list: + requirement_id = requirement["id"] + requirement_status = requirement["attributes"]["status"] + requirement_attributes = attributes_by_requirement_id.get(requirement_id, {}) + + # Count requirements by status + if requirement_status == StatusChoices.PASS: + passed_requirements += 1 + elif requirement_status == StatusChoices.FAIL: + failed_requirements += 1 + elif requirement_status == StatusChoices.MANUAL: + manual_requirements += 1 + + # Get findings data + req_passed_findings = requirement["attributes"].get("passed_findings", 0) + req_total_findings = requirement["attributes"].get("total_findings", 0) + + # Accumulate findings counts + total_findings += req_total_findings + passed_findings += req_passed_findings + failed_findings += req_total_findings - req_passed_findings + + # Skip requirements with no findings + if req_total_findings == 0: + continue + + overall_has_findings = True + + # Get requirement metadata + metadata = requirement_attributes.get("attributes", {}).get( + "req_attributes", [] + ) + if not metadata or len(metadata) == 0: + continue + + m = metadata[0] + risk_level_raw = getattr(m, "LevelOfRisk", 0) + weight_raw = getattr(m, "Weight", 0) + section = getattr(m, "Section", "Unknown") + risk_level = int(risk_level_raw) if risk_level_raw else 0 + weight = int(weight_raw) if weight_raw else 0 + + # Calculate ThreatScore components using formula from UI + rate_i = req_passed_findings / req_total_findings + rfac_i = 1 + 0.25 * risk_level + + # Update overall score + overall_numerator += rate_i * req_total_findings * weight * rfac_i + overall_denominator += req_total_findings * weight * rfac_i + + # Update section scores + if section not in sections_data: + sections_data[section] = { + "numerator": 0, + "denominator": 0, + "has_findings": False, + } + + sections_data[section]["has_findings"] = True + sections_data[section]["numerator"] += ( + rate_i * req_total_findings * weight * rfac_i + ) + sections_data[section]["denominator"] += req_total_findings * weight * rfac_i + + # Identify critical failed requirements + if requirement_status == StatusChoices.FAIL and risk_level >= min_risk_level: + critical_requirements_list.append( + { + "requirement_id": requirement_id, + "title": getattr(m, "Title", "N/A"), + "section": section, + "subsection": getattr(m, "SubSection", "N/A"), + "risk_level": risk_level, + "weight": weight, + "passed_findings": req_passed_findings, + "total_findings": req_total_findings, + "description": getattr(m, "AttributeDescription", "N/A"), + } + ) + + # Calculate overall ThreatScore + if not overall_has_findings: + overall_score = 100.0 + elif overall_denominator > 0: + overall_score = (overall_numerator / overall_denominator) * 100 + else: + overall_score = 0.0 + + # Calculate section scores + section_scores = {} + for section, data in sections_data.items(): + if data["has_findings"] and data["denominator"] > 0: + section_scores[section] = (data["numerator"] / data["denominator"]) * 100 + else: + section_scores[section] = 100.0 + + # Sort critical requirements by risk level (desc) and weight (desc) + critical_requirements_list.sort( + key=lambda x: (x["risk_level"], x["weight"]), reverse=True + ) + + logger.info( + f"ThreatScore computed: {overall_score:.2f}% " + f"({passed_requirements}/{total_requirements} requirements passed, " + f"{len(critical_requirements_list)} critical failures)" + ) + + return { + "overall_score": round(overall_score, 2), + "section_scores": {k: round(v, 2) for k, v in section_scores.items()}, + "critical_requirements": critical_requirements_list, + "total_requirements": total_requirements, + "passed_requirements": passed_requirements, + "failed_requirements": failed_requirements, + "manual_requirements": manual_requirements, + "total_findings": total_findings, + "passed_findings": passed_findings, + "failed_findings": failed_findings, + } diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py new file mode 100644 index 0000000000..c46c279bf4 --- /dev/null +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -0,0 +1,246 @@ +from celery.utils.log import get_task_logger +from config.django.base import DJANGO_FINDINGS_BATCH_SIZE +from django.db.models import Count, Q + +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Finding, StatusChoices +from prowler.lib.outputs.finding import Finding as FindingOutput + +logger = get_task_logger(__name__) + + +def _aggregate_requirement_statistics_from_database( + tenant_id: str, scan_id: str +) -> dict[str, dict[str, int]]: + """ + Aggregate finding statistics by check_id using database aggregation. + + This function uses Django ORM aggregation to calculate pass/fail statistics + entirely in the database, avoiding the need to load findings into memory. + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to retrieve findings for. + + Returns: + dict[str, dict[str, int]]: Dictionary mapping check_id to statistics: + - 'passed' (int): Number of passed findings for this check + - 'total' (int): Total number of findings for this check + + Example: + { + 'aws_iam_user_mfa_enabled': {'passed': 10, 'total': 15}, + 'aws_s3_bucket_public_access': {'passed': 0, 'total': 5} + } + """ + requirement_statistics_by_check_id = {} + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + aggregated_statistics_queryset = ( + Finding.all_objects.filter( + tenant_id=tenant_id, scan_id=scan_id, muted=False + ) + .values("check_id") + .annotate( + total_findings=Count( + "id", + filter=Q(status__in=[StatusChoices.PASS, StatusChoices.FAIL]), + ), + passed_findings=Count("id", filter=Q(status=StatusChoices.PASS)), + ) + ) + + for aggregated_stat in aggregated_statistics_queryset: + check_id = aggregated_stat["check_id"] + requirement_statistics_by_check_id[check_id] = { + "passed": aggregated_stat["passed_findings"], + "total": aggregated_stat["total_findings"], + } + + logger.info( + f"Aggregated statistics for {len(requirement_statistics_by_check_id)} unique checks" + ) + return requirement_statistics_by_check_id + + +def _calculate_requirements_data_from_statistics( + compliance_obj, requirement_statistics_by_check_id: dict[str, dict[str, int]] +) -> tuple[dict[str, dict], list[dict]]: + """ + Calculate requirement status and statistics using pre-aggregated database statistics. + + Args: + compliance_obj: The compliance framework object containing requirements. + requirement_statistics_by_check_id (dict[str, dict[str, int]]): Pre-aggregated statistics + mapping check_id to {'passed': int, 'total': int} counts. + + Returns: + tuple[dict[str, dict], list[dict]]: A tuple containing: + - attributes_by_requirement_id: Dictionary mapping requirement IDs to their attributes. + - requirements_list: List of requirement dictionaries with status and statistics. + """ + attributes_by_requirement_id = {} + requirements_list = [] + + compliance_framework = getattr(compliance_obj, "Framework", "N/A") + compliance_version = getattr(compliance_obj, "Version", "N/A") + + for requirement in compliance_obj.Requirements: + requirement_id = requirement.Id + requirement_description = getattr(requirement, "Description", "") + requirement_checks = getattr(requirement, "Checks", []) + requirement_attributes = getattr(requirement, "Attributes", []) + + attributes_by_requirement_id[requirement_id] = { + "attributes": { + "req_attributes": requirement_attributes, + "checks": requirement_checks, + }, + "description": requirement_description, + } + + total_passed_findings = 0 + total_findings_count = 0 + + for check_id in requirement_checks: + if check_id in requirement_statistics_by_check_id: + check_statistics = requirement_statistics_by_check_id[check_id] + total_findings_count += check_statistics["total"] + total_passed_findings += check_statistics["passed"] + + if total_findings_count > 0: + if total_passed_findings == total_findings_count: + requirement_status = StatusChoices.PASS + else: + requirement_status = StatusChoices.FAIL + else: + requirement_status = StatusChoices.MANUAL + + requirements_list.append( + { + "id": requirement_id, + "attributes": { + "framework": compliance_framework, + "version": compliance_version, + "status": requirement_status, + "description": requirement_description, + "passed_findings": total_passed_findings, + "total_findings": total_findings_count, + }, + } + ) + + return attributes_by_requirement_id, requirements_list + + +def _load_findings_for_requirement_checks( + tenant_id: str, + scan_id: str, + check_ids: list[str], + prowler_provider, + findings_cache: dict[str, list[FindingOutput]] | None = None, +) -> dict[str, list[FindingOutput]]: + """ + Load findings for specific check IDs on-demand with optional caching. + + This function loads only the findings needed for a specific set of checks, + minimizing memory usage by avoiding loading all findings at once. This is used + when generating detailed findings tables for specific requirements in the PDF. + + Supports optional caching to avoid duplicate queries when generating multiple + reports for the same scan. + + Memory optimizations: + - Uses database iterator with chunk_size for streaming large result sets + - Shares references between cache and return dict (no duplication) + - Only selects required fields from database + - Processes findings in batches to reduce memory pressure + + Args: + tenant_id (str): The tenant ID for Row-Level Security context. + scan_id (str): The ID of the scan to retrieve findings for. + check_ids (list[str]): List of check IDs to load findings for. + prowler_provider: The initialized Prowler provider instance. + findings_cache (dict, optional): Cache of already loaded findings. + If provided, checks are first looked up in cache before querying database. + + Returns: + dict[str, list[FindingOutput]]: Dictionary mapping check_id to list of FindingOutput objects. + + Example: + { + 'aws_iam_user_mfa_enabled': [FindingOutput(...), FindingOutput(...)], + 'aws_s3_bucket_public_access': [FindingOutput(...)] + } + """ + if not check_ids: + return {} + + # Initialize cache if not provided + if findings_cache is None: + findings_cache = {} + + # Deduplicate check_ids to avoid redundant processing + unique_check_ids = list(set(check_ids)) + + # Separate cached and non-cached check_ids + check_ids_to_load = [] + cache_hits = 0 + + for check_id in unique_check_ids: + if check_id in findings_cache: + cache_hits += 1 + else: + check_ids_to_load.append(check_id) + + if cache_hits > 0: + total_checks = len(unique_check_ids) + logger.info( + f"Findings cache: {cache_hits}/{total_checks} hits " + f"({cache_hits / total_checks * 100:.1f}% hit rate)" + ) + + # Load missing check_ids from database + if check_ids_to_load: + logger.info( + f"Loading findings for {len(check_ids_to_load)} checks from database" + ) + + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Use iterator with chunk_size for memory-efficient streaming + # chunk_size controls how many rows Django fetches from DB at once + findings_queryset = ( + Finding.all_objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + check_id__in=check_ids_to_load, + ) + .order_by("check_id", "uid") + .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + ) + + # Pre-initialize empty lists for all check_ids to load + # This avoids repeated dict lookups and 'if not in' checks + for check_id in check_ids_to_load: + findings_cache[check_id] = [] + + findings_count = 0 + for finding_model in findings_queryset: + finding_output = FindingOutput.transform_api_finding( + finding_model, prowler_provider + ) + findings_cache[finding_output.check_id].append(finding_output) + findings_count += 1 + + logger.info( + f"Loaded {findings_count} findings for {len(check_ids_to_load)} checks" + ) + + # Build result dict using cache references (no data duplication) + # This shares the same list objects between cache and result + result = { + check_id: findings_cache.get(check_id, []) for check_id in unique_check_ids + } + + return result diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 97b320b71d..cbe44ab304 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -8,7 +8,19 @@ from celery.utils.log import get_task_logger from config.celery import RLSTask from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY from django_celery_beat.models import PeriodicTask -from tasks.jobs.backfill import backfill_resource_scan_summaries +from tasks.jobs.attack_paths import ( + attack_paths_scan, + db_utils as attack_paths_db_utils, + can_provider_run_attack_paths_scan, +) +from tasks.jobs.backfill import ( + backfill_compliance_summaries, + backfill_daily_severity_summaries, + backfill_provider_compliance_scores, + backfill_resource_scan_summaries, + backfill_scan_category_summaries, + backfill_scan_resource_group_summaries, +) from tasks.jobs.connection import ( check_integration_connection, check_lighthouse_connection, @@ -31,18 +43,26 @@ from tasks.jobs.lighthouse_providers import ( check_lighthouse_provider_connection, refresh_lighthouse_provider_models, ) -from tasks.jobs.report import generate_threatscore_report_job +from tasks.jobs.muting import mute_historical_findings +from tasks.jobs.report import generate_compliance_reports_job from tasks.jobs.scan import ( + aggregate_attack_surface, + aggregate_daily_severity, aggregate_findings, create_compliance_requirements, perform_prowler_scan, + update_provider_compliance_scores, +) +from tasks.utils import ( + _get_or_create_scheduled_scan, + batched, + get_next_execution_datetime, ) -from tasks.utils import 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 set_tenant +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 @@ -53,6 +73,58 @@ from prowler.lib.outputs.finding import Finding as FindingOutput logger = get_task_logger(__name__) +def _cleanup_orphan_scheduled_scans( + tenant_id: str, + provider_id: str, + scheduler_task_id: int, +) -> int: + """ + TEMPORARY WORKAROUND: Clean up orphan AVAILABLE scans. + + Detects and removes AVAILABLE scans that were never used due to an + issue during the first scheduled scan setup. + + An AVAILABLE scan is considered orphan if there's also a SCHEDULED scan for + the same provider with the same scheduler_task_id. This situation indicates + that the first scan execution didn't find the AVAILABLE scan (because it + wasn't committed yet, probably) and created a new one, leaving the AVAILABLE orphaned. + + Args: + tenant_id: The tenant ID. + provider_id: The provider ID. + scheduler_task_id: The PeriodicTask ID that triggers these scans. + + Returns: + Number of orphan scans deleted (0 if none found). + """ + orphan_available_scans = Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=scheduler_task_id, + ) + + scheduled_scan_exists = Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduler_task_id=scheduler_task_id, + ).exists() + + if scheduled_scan_exists and orphan_available_scans.exists(): + orphan_count = orphan_available_scans.count() + logger.warning( + f"[WORKAROUND] Found {orphan_count} orphan AVAILABLE scan(s) for " + f"provider {provider_id} alongside a SCHEDULED scan. Cleaning up orphans..." + ) + orphan_available_scans.delete() + return orphan_count + + return 0 + + def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str): """ Helper function to perform tasks after a scan is completed. @@ -62,16 +134,24 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str) scan_id (str): The ID of the scan that was performed. provider_id (str): The primary key of the Provider instance that was scanned. """ - create_compliance_requirements_task.apply_async( + chain( + create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id), + update_provider_compliance_scores_task.si(tenant_id=tenant_id, scan_id=scan_id), + ).apply_async() + aggregate_attack_surface_task.apply_async( kwargs={"tenant_id": tenant_id, "scan_id": scan_id} ) chain( perform_scan_summary_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 + 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 + ), ), group( - generate_threatscore_report_task.si( + # Use optimized task that generates both reports with shared queries + generate_compliance_reports_task.si( tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id ), check_integrations_task.si( @@ -82,6 +162,11 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str) ), ).apply_async() + if can_provider_run_attack_paths_scan(tenant_id, provider_id): + perform_attack_paths_scan_task.apply_async( + kwargs={"tenant_id": tenant_id, "scan_id": scan_id} + ) + @shared_task(base=RLSTask, name="provider-connection-check") @set_tenant @@ -135,6 +220,7 @@ def delete_provider_task(provider_id: str, tenant_id: str): @shared_task(base=RLSTask, name="scan-perform", queue="scans") +@handle_provider_deletion def perform_scan_task( tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None ): @@ -167,6 +253,7 @@ def perform_scan_task( @shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans") +@handle_provider_deletion def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): """ Task to perform a scheduled Prowler scan on a given provider. @@ -192,57 +279,52 @@ 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 - next_scan_datetime = get_next_execution_datetime(task_id, provider_id) - scan_instance, _ = Scan.objects.get_or_create( + executed_scan = Scan.objects.filter( 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), - }, + 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( + tenant_id=tenant_id, + provider_id=provider_id, + scheduler_task_id=periodic_task_instance.id, + ) + + scan_instance = _get_or_create_scheduled_scan( + tenant_id=tenant_id, + provider_id=provider_id, + scheduler_task_id=periodic_task_instance.id, + scheduled_at=current_scan_datetime, + ) scan_instance.task_id = task_id scan_instance.save() @@ -252,18 +334,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) @@ -272,10 +355,51 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): @shared_task(name="scan-summary", queue="overview") +@handle_provider_deletion def perform_scan_summary_task(tenant_id: str, scan_id: str): return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id) +class AttackPathsScanRLSTask(RLSTask): + """ + RLS task that marks the `AttackPathsScan` DB row as `FAILED` when the Celery task fails. + + Covers failures that happen outside the job's own try/except (e.g. provider lookup, + SDK initialization, or Neo4j configuration errors during setup). + """ + + def on_failure(self, exc, task_id, args, kwargs, _einfo): + tenant_id = kwargs.get("tenant_id") + scan_id = kwargs.get("scan_id") + + if tenant_id and scan_id: + logger.error(f"Attack paths scan task {task_id} failed: {exc}") + attack_paths_db_utils.fail_attack_paths_scan(tenant_id, scan_id, str(exc)) + + +@shared_task( + base=AttackPathsScanRLSTask, + bind=True, + name="attack-paths-scan-perform", + queue="attack-paths-scans", +) +def perform_attack_paths_scan_task(self, tenant_id: str, scan_id: str): + """ + Execute an Attack Paths scan for the given provider within the current tenant RLS context. + + Args: + self: The task instance (automatically passed when bind=True). + tenant_id (str): The tenant identifier for RLS context. + scan_id (str): The Prowler scan identifier for obtaining the tenant and provider context. + + Returns: + Any: The result from `attack_paths_scan`, including any per-scan failure details. + """ + return attack_paths_scan( + tenant_id=tenant_id, scan_id=scan_id, task_id=self.request.id + ) + + @shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,)) def delete_tenant_task(tenant_id: str): return delete_tenant(pk=tenant_id) @@ -287,6 +411,7 @@ def delete_tenant_task(tenant_id: str): queue="scan-reports", ) @set_tenant(keep_tenant=True) +@handle_provider_deletion def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): """ Process findings in batches and generate output files in multiple formats. @@ -315,7 +440,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): frameworks_bulk = Compliance.get_bulk(provider_type) frameworks_avail = get_compliance_frameworks(provider_type) - out_dir, comp_dir, _ = _generate_output_directory( + out_dir, comp_dir = _generate_output_directory( DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id ) @@ -482,6 +607,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): @shared_task(name="backfill-scan-resource-summaries", queue="backfill") +@handle_provider_deletion def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str): """ Tries to backfill the resource scan summaries table for a given scan. @@ -493,7 +619,74 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str): return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id) +@shared_task(name="backfill-compliance-summaries", queue="backfill") +@handle_provider_deletion +def backfill_compliance_summaries_task(tenant_id: str, scan_id: str): + """ + Tries to backfill compliance overview summaries for a completed scan. + + This task aggregates compliance requirement data across regions + to create pre-computed summary records for fast compliance overview queries. + + Args: + tenant_id (str): The tenant identifier. + scan_id (str): The scan identifier. + """ + return backfill_compliance_summaries(tenant_id=tenant_id, scan_id=scan_id) + + +@shared_task(name="backfill-daily-severity-summaries", queue="backfill") +def backfill_daily_severity_summaries_task(tenant_id: str, days: int = None): + """Backfill DailySeveritySummary from historical scans. Use days param to limit scope.""" + return backfill_daily_severity_summaries(tenant_id=tenant_id, days=days) + + +@shared_task(name="backfill-scan-category-summaries", queue="backfill") +@handle_provider_deletion +def backfill_scan_category_summaries_task(tenant_id: str, scan_id: str): + """ + Backfill ScanCategorySummary for a completed scan. + + Aggregates unique categories from findings and creates a summary row. + + Args: + tenant_id (str): The tenant identifier. + scan_id (str): The scan identifier. + """ + return backfill_scan_category_summaries(tenant_id=tenant_id, scan_id=scan_id) + + +@shared_task(name="backfill-scan-resource-group-summaries", queue="backfill") +@handle_provider_deletion +def backfill_scan_resource_group_summaries_task(tenant_id: str, scan_id: str): + """ + Backfill ScanGroupSummary for a completed scan. + + Aggregates unique resource groups from findings and creates a summary row. + + Args: + tenant_id (str): The tenant identifier. + scan_id (str): The scan identifier. + """ + return backfill_scan_resource_group_summaries(tenant_id=tenant_id, scan_id=scan_id) + + +@shared_task(name="backfill-provider-compliance-scores", queue="backfill") +def backfill_provider_compliance_scores_task(tenant_id: str): + """ + Backfill ProviderComplianceScore from latest completed scan per provider. + + Used to populate the compliance watchlist materialized table for tenants + that had scans before the feature was deployed. + + Args: + tenant_id: Target tenant UUID. + """ + return backfill_provider_compliance_scores(tenant_id=tenant_id) + + @shared_task(base=RLSTask, name="scan-compliance-overviews", queue="compliance") +@handle_provider_deletion def create_compliance_requirements_task(tenant_id: str, scan_id: str): """ Creates detailed compliance requirement records for a scan. @@ -509,6 +702,44 @@ def create_compliance_requirements_task(tenant_id: str, scan_id: str): return create_compliance_requirements(tenant_id=tenant_id, scan_id=scan_id) +@shared_task(name="scan-attack-surface-overviews", queue="overview") +@handle_provider_deletion +def aggregate_attack_surface_task(tenant_id: str, scan_id: str): + """ + Creates attack surface overview records for a scan. + + This task processes findings and aggregates them into attack surface categories + (internet-exposed, secrets, privilege-escalation, ec2-imdsv1) for quick overview queries. + + Args: + tenant_id (str): The tenant ID for which to create records. + scan_id (str): The ID of the scan for which to create records. + """ + return aggregate_attack_surface(tenant_id=tenant_id, scan_id=scan_id) + + +@shared_task(name="scan-provider-compliance-scores", queue="compliance") +def update_provider_compliance_scores_task(tenant_id: str, scan_id: str): + """ + Update provider compliance scores from a completed scan. + + This task materializes compliance requirement statuses into ProviderComplianceScore + for efficient watchlist queries. Uses atomic upsert with concurrency protection. + + Args: + tenant_id (str): The tenant ID for which to update scores. + scan_id (str): The ID of the scan whose data should be materialized. + """ + return update_provider_compliance_scores(tenant_id=tenant_id, scan_id=scan_id) + + +@shared_task(name="scan-daily-severity", queue="overview") +@handle_provider_deletion +def aggregate_daily_severity_task(tenant_id: str, scan_id: str): + """Aggregate scan severity into DailySeveritySummary for findings_severity/timeseries endpoint.""" + return aggregate_daily_severity(tenant_id=tenant_id, scan_id=scan_id) + + @shared_task(base=RLSTask, name="lighthouse-connection-check") @set_tenant def check_lighthouse_connection_task(lighthouse_config_id: str, tenant_id: str = None): @@ -547,6 +778,7 @@ def refresh_lighthouse_provider_models_task( @shared_task(name="integration-check") +@handle_provider_deletion def check_integrations_task(tenant_id: str, provider_id: str, scan_id: str = None): """ Check and execute all configured integrations for a provider. @@ -611,6 +843,7 @@ def check_integrations_task(tenant_id: str, provider_id: str, scan_id: str = Non name="integration-s3", queue="integrations", ) +@handle_provider_deletion def s3_integration_task( tenant_id: str, provider_id: str, @@ -667,17 +900,54 @@ def jira_integration_task( @shared_task( base=RLSTask, - name="scan-threatscore-report", + name="scan-compliance-reports", queue="scan-reports", ) -def generate_threatscore_report_task(tenant_id: str, scan_id: str, provider_id: str): +@handle_provider_deletion +def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str): """ - Task to generate a threatscore report for a given scan. + Optimized task to generate ThreatScore, ENS, and NIS2 reports with shared queries. + + This task is more efficient than running separate report tasks because it reuses database queries: + - Provider object fetched once (instead of three times) + - Requirement statistics aggregated once (instead of three times) + - Can reduce database load by up to 50-70% + Args: tenant_id (str): The tenant identifier. scan_id (str): The scan identifier. provider_id (str): The provider identifier. + + Returns: + dict: Results for all reports containing upload status and paths. """ - return generate_threatscore_report_job( - tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id + return generate_compliance_reports_job( + tenant_id=tenant_id, + scan_id=scan_id, + provider_id=provider_id, + generate_threatscore=True, + generate_ens=True, + generate_nis2=True, ) + + +@shared_task(name="findings-mute-historical") +def mute_historical_findings_task(tenant_id: str, mute_rule_id: str): + """ + Background task to mute all historical findings matching a mute rule. + + This task processes findings in batches to avoid memory issues with large datasets. + It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields + for all findings whose UID is in the mute rule's finding_uids list. + + Args: + tenant_id (str): The tenant ID for RLS context. + mute_rule_id (str): The primary key of the MuteRule to apply. + + Returns: + dict: A dictionary containing: + - 'findings_muted' (int): Total number of findings muted. + - 'rule_id' (str): The mute rule ID. + - 'status' (str): Final status ('completed'). + """ + return mute_historical_findings(tenant_id, mute_rule_id) diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py new file mode 100644 index 0000000000..dee0a2d3e1 --- /dev/null +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -0,0 +1,1024 @@ +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +import pytest +from tasks.jobs.attack_paths import findings as findings_module +from tasks.jobs.attack_paths import internet as internet_module +from tasks.jobs.attack_paths.scan import run as attack_paths_run + +from api.models import ( + AttackPathsScan, + Finding, + Provider, + Resource, + ResourceFindingMapping, + Scan, + StateChoices, + StatusChoices, +) +from prowler.lib.check.models import Severity + + +@pytest.mark.django_db +class TestAttackPathsRun: + # Patching with decorators as we got a `SyntaxError: too many statically nested blocks` error if we use context managers + @patch("tasks.jobs.attack_paths.scan.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.scan.utils.call_within_event_loop", + side_effect=lambda fn, *a, **kw: fn(*a, **kw), + ) + @patch( + "tasks.jobs.attack_paths.scan.db_utils.get_old_attack_paths_scans", + return_value=[], + ) + @patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") + @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") + @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") + @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.internet.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @patch("tasks.jobs.attack_paths.scan.cartography_ontology.run") + @patch("tasks.jobs.attack_paths.scan.cartography_analysis.run") + @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run") + @patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache") + @patch("tasks.jobs.attack_paths.scan.graph_database.create_database") + @patch( + "tasks.jobs.attack_paths.scan.graph_database.get_uri", + return_value="bolt://neo4j", + ) + @patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ) + @patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_run_success_flow( + self, + mock_init_provider, + mock_get_uri, + mock_create_db, + mock_clear_cache, + mock_cartography_indexes, + mock_cartography_analysis, + mock_cartography_ontology, + mock_findings_indexes, + mock_findings_analysis, + mock_internet_analysis, + mock_sync_indexes, + mock_drop_subgraph, + mock_sync, + mock_starting, + mock_update_progress, + mock_finish, + mock_get_old_scans, + mock_event_loop, + mock_drop_db, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.SCHEDULED, + ) + + mock_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + ingestion_result = {"organizations": "warning"} + ingestion_fn = MagicMock(return_value=ingestion_result) + + with ( + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + side_effect=["db-scan-id", "tenant-db"], + ) as mock_get_db_name, + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ) as mock_get_session, + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ) as mock_retrieve_scan, + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=ingestion_fn, + ) as mock_get_ingestion, + ): + result = attack_paths_run(str(tenant.id), str(scan.id), "task-123") + + assert result == ingestion_result + mock_retrieve_scan.assert_called_once_with(str(tenant.id), str(scan.id)) + mock_starting.assert_called_once() + config = mock_starting.call_args[0][2] + assert config.neo4j_database == "tenant-db" + mock_get_db_name.assert_has_calls( + [call(attack_paths_scan.id, temporary=True), call(provider.tenant_id)] + ) + + mock_create_db.assert_has_calls([call("db-scan-id"), call("tenant-db")]) + mock_get_session.assert_has_calls([call("db-scan-id"), call("tenant-db")]) + assert mock_cartography_indexes.call_count == 2 + mock_findings_indexes.assert_has_calls([call(mock_session), call(mock_session)]) + mock_sync_indexes.assert_called_once_with(mock_session) + # These use tmp_cartography_config (neo4j_database="db-scan-id") + mock_cartography_analysis.assert_called_once() + mock_cartography_ontology.assert_called_once() + mock_internet_analysis.assert_called_once() + mock_findings_analysis.assert_called_once() + mock_drop_subgraph.assert_called_once_with( + database="tenant-db", + provider_id=str(provider.id), + ) + mock_sync.assert_called_once_with( + source_database="db-scan-id", + target_database="tenant-db", + provider_id=str(provider.id), + ) + mock_get_ingestion.assert_called_once_with(provider.provider) + mock_event_loop.assert_called_once() + mock_update_progress.assert_any_call(attack_paths_scan, 1) + mock_update_progress.assert_any_call(attack_paths_scan, 2) + mock_update_progress.assert_any_call(attack_paths_scan, 95) + mock_update_progress.assert_any_call(attack_paths_scan, 97) + mock_update_progress.assert_any_call(attack_paths_scan, 98) + mock_update_progress.assert_any_call(attack_paths_scan, 99) + mock_finish.assert_called_once_with( + attack_paths_scan, StateChoices.COMPLETED, ingestion_result + ) + + def test_run_failure_marks_scan_failed( + self, tenants_fixture, providers_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.SCHEDULED, + ) + + mock_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom")) + + with ( + patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ), + patch("tasks.jobs.attack_paths.scan.graph_database.get_uri"), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + return_value="db-scan-id", + ), + patch("tasks.jobs.attack_paths.scan.graph_database.create_database"), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"), + patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"), + patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes"), + patch("tasks.jobs.attack_paths.scan.internet.analysis"), + patch("tasks.jobs.attack_paths.scan.findings.analysis"), + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ), + patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"), + patch( + "tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress" + ), + patch( + "tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan" + ) as mock_finish, + patch("tasks.jobs.attack_paths.scan.graph_database.drop_database"), + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=ingestion_fn, + ), + patch( + "tasks.jobs.attack_paths.scan.utils.call_within_event_loop", + side_effect=lambda fn, *a, **kw: fn(*a, **kw), + ), + patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Cartography failed: ingestion boom", + ), + ): + with pytest.raises(RuntimeError, match="ingestion boom"): + attack_paths_run(str(tenant.id), str(scan.id), "task-456") + + failure_args = mock_finish.call_args[0] + assert failure_args[0] is attack_paths_scan + assert failure_args[1] == StateChoices.FAILED + assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"} + + def test_run_failure_marks_scan_failed_even_when_drop_database_fails( + self, tenants_fixture, providers_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.SCHEDULED, + ) + + mock_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom")) + + with ( + patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ), + patch("tasks.jobs.attack_paths.scan.graph_database.get_uri"), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + return_value="db-scan-id", + ), + patch("tasks.jobs.attack_paths.scan.graph_database.create_database"), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"), + patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"), + patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes"), + patch("tasks.jobs.attack_paths.scan.internet.analysis"), + patch("tasks.jobs.attack_paths.scan.findings.analysis"), + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ), + patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"), + patch( + "tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress" + ), + patch( + "tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan" + ) as mock_finish, + patch( + "tasks.jobs.attack_paths.scan.graph_database.drop_database", + side_effect=ConnectionError("neo4j down"), + ), + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=ingestion_fn, + ), + patch( + "tasks.jobs.attack_paths.scan.utils.call_within_event_loop", + side_effect=lambda fn, *a, **kw: fn(*a, **kw), + ), + patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Cartography failed: ingestion boom", + ), + ): + with pytest.raises(RuntimeError, match="ingestion boom"): + attack_paths_run(str(tenant.id), str(scan.id), "task-789") + + failure_args = mock_finish.call_args[0] + assert failure_args[0] is attack_paths_scan + assert failure_args[1] == StateChoices.FAILED + assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"} + + def test_run_returns_early_for_unsupported_provider(self, tenants_fixture): + tenant = tenants_fixture[0] + provider = Provider.objects.create( + provider=Provider.ProviderChoices.GCP, + uid="gcp-account", + alias="gcp", + tenant_id=tenant.id, + ) + scan = Scan.objects.create( + name="GCP Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + tenant_id=tenant.id, + ) + + with ( + patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(), + ), + patch( + "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function", + return_value=None, + ) as mock_get_ingestion, + patch( + "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan" + ) as mock_retrieve, + ): + mock_retrieve.return_value = None + result = attack_paths_run(str(tenant.id), str(scan.id), "task-789") + + assert result == { + "global_error": "Provider gcp is not supported for Attack Paths scans" + } + mock_get_ingestion.assert_called_once_with(provider.provider) + mock_retrieve.assert_called_once_with(str(tenant.id), str(scan.id)) + + +@pytest.mark.django_db +class TestFailAttackPathsScan: + def test_marks_executing_scan_as_failed( + self, tenants_fixture, providers_fixture, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import ( + fail_attack_paths_scan, + ) + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.EXECUTING, + ) + + with ( + patch( + "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ) as mock_retrieve, + patch( + "tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan" + ) as mock_finish, + ): + fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded") + + mock_retrieve.assert_called_once_with(str(tenant.id), str(scan.id)) + mock_finish.assert_called_once_with( + attack_paths_scan, + StateChoices.FAILED, + {"global_error": "setup exploded"}, + ) + + def test_skips_already_failed_scan( + self, tenants_fixture, providers_fixture, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import ( + fail_attack_paths_scan, + ) + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan = scans_fixture[0] + scan.provider = provider + scan.save() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.FAILED, + ) + + with ( + patch( + "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", + return_value=attack_paths_scan, + ), + patch( + "tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan" + ) as mock_finish, + ): + fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded") + + mock_finish.assert_not_called() + + def test_skips_when_no_scan_found(self, tenants_fixture): + from tasks.jobs.attack_paths.db_utils import ( + fail_attack_paths_scan, + ) + + tenant = tenants_fixture[0] + + with ( + patch( + "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", + return_value=None, + ), + patch( + "tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan" + ) as mock_finish, + ): + fail_attack_paths_scan(str(tenant.id), "nonexistent", "setup exploded") + + mock_finish.assert_not_called() + + +class TestAttackPathsScanRLSTaskOnFailure: + def test_on_failure_delegates_to_fail_attack_paths_scan(self): + from tasks.tasks import AttackPathsScanRLSTask + + task = AttackPathsScanRLSTask() + + with patch( + "tasks.tasks.attack_paths_db_utils.fail_attack_paths_scan" + ) as mock_fail: + task.on_failure( + exc=RuntimeError("boom"), + task_id="task-abc", + args=(), + kwargs={"tenant_id": "t-1", "scan_id": "s-1"}, + _einfo=None, + ) + + mock_fail.assert_called_once_with("t-1", "s-1", "boom") + + def test_on_failure_skips_when_missing_kwargs(self): + from tasks.tasks import AttackPathsScanRLSTask + + task = AttackPathsScanRLSTask() + + with patch( + "tasks.tasks.attack_paths_db_utils.fail_attack_paths_scan" + ) as mock_fail: + task.on_failure( + exc=RuntimeError("boom"), + task_id="task-abc", + args=(), + kwargs={}, + _einfo=None, + ) + + mock_fail.assert_not_called() + + +@pytest.mark.django_db +class TestAttackPathsFindingsHelpers: + def test_create_findings_indexes_executes_all_statements(self): + mock_session = MagicMock() + with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write: + findings_module.create_findings_indexes(mock_session) + + from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS + + assert mock_run_write.call_count == len(FINDINGS_INDEX_STATEMENTS) + mock_run_write.assert_has_calls( + [call(mock_session, stmt) for stmt in FINDINGS_INDEX_STATEMENTS] + ) + + def test_load_findings_batches_requests(self, providers_fixture): + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + # Create mock Finding objects with to_dict() method + mock_finding_1 = MagicMock() + mock_finding_1.to_dict.return_value = {"id": "1", "resource_uid": "r-1"} + mock_finding_2 = MagicMock() + mock_finding_2.to_dict.return_value = {"id": "2", "resource_uid": "r-2"} + + # Create a generator that yields two batches of Finding instances + def findings_generator(): + yield [mock_finding_1] + yield [mock_finding_2] + + config = SimpleNamespace(update_tag=12345) + mock_session = MagicMock() + + with ( + patch( + "tasks.jobs.attack_paths.findings.get_root_node_label", + return_value="AWSAccount", + ), + patch( + "tasks.jobs.attack_paths.findings.get_node_uid_field", + return_value="arn", + ), + patch( + "tasks.jobs.attack_paths.findings.get_provider_resource_label", + return_value="AWSResource", + ), + ): + findings_module.load_findings( + mock_session, findings_generator(), provider, config + ) + + assert mock_session.run.call_count == 2 + for call_args in mock_session.run.call_args_list: + params = call_args.args[1] + assert params["provider_uid"] == str(provider.uid) + assert params["last_updated"] == config.update_tag + assert "findings_data" in params + + def test_cleanup_findings_runs_batches(self, providers_fixture): + provider = providers_fixture[0] + config = SimpleNamespace(update_tag=1024) + mock_session = MagicMock() + + first_batch = MagicMock() + first_batch.single.return_value = {"deleted_findings_count": 3} + second_batch = MagicMock() + second_batch.single.return_value = {"deleted_findings_count": 0} + mock_session.run.side_effect = [first_batch, second_batch] + + findings_module.cleanup_findings(mock_session, provider, config) + + assert mock_session.run.call_count == 2 + params = mock_session.run.call_args.args[1] + assert params["provider_uid"] == str(provider.uid) + assert params["last_updated"] == config.update_tag + + def test_stream_findings_with_resources_returns_latest_scan_data( + self, + tenants_fixture, + providers_fixture, + ): + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="resource-uid", + name="Resource", + region="us-east-1", + service="ec2", + type="instance", + ) + + older_scan = Scan.objects.create( + name="Older", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + ) + old_finding = Finding.objects.create( + tenant_id=tenant.id, + uid="older-finding", + scan=older_scan, + delta=Finding.DeltaChoices.NEW, + status=StatusChoices.PASS, + status_extended="ok", + severity=Severity.low, + impact=Severity.low, + impact_extended="", + raw_result={}, + check_id="check-old", + check_metadata={"checktitle": "Old"}, + first_seen_at=older_scan.inserted_at, + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + resource=resource, + finding=old_finding, + ) + + latest_scan = Scan.objects.create( + name="Latest", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + ) + finding = Finding.objects.create( + tenant_id=tenant.id, + uid="finding-uid", + scan=latest_scan, + delta=Finding.DeltaChoices.NEW, + status=StatusChoices.FAIL, + status_extended="failed", + severity=Severity.high, + impact=Severity.high, + impact_extended="", + raw_result={}, + check_id="check-1", + check_metadata={"checktitle": "Check title"}, + first_seen_at=latest_scan.inserted_at, + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + resource=resource, + finding=finding, + ) + + latest_scan.refresh_from_db() + + with ( + patch( + "tasks.jobs.attack_paths.findings.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ), + patch( + "tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS", + "default", + ), + ): + # Generator yields batches, collect all findings from all batches + findings_batches = findings_module.stream_findings_with_resources( + provider, + str(latest_scan.id), + ) + findings_data = [] + for batch in findings_batches: + findings_data.extend(batch) + + assert len(findings_data) == 1 + finding_result = findings_data[0] + assert finding_result.id == str(finding.id) + assert finding_result.resource_uid == resource.uid + assert finding_result.check_title == "Check title" + assert finding_result.scan_id == str(latest_scan.id) + + def test_enrich_batch_with_resources_single_resource( + self, + tenants_fixture, + providers_fixture, + ): + """One finding + one resource = one output Finding instance""" + 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_batch_with_resources queries ResourceFindingMapping directly + # No RLS mock needed - test DB doesn't enforce RLS policies + with patch( + "tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS", + "default", + ): + result = findings_module._enrich_batch_with_resources( + [finding_dict], str(tenant.id) + ) + + 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_batch_with_resources_multiple_resources( + self, + tenants_fixture, + providers_fixture, + ): + """One finding + three resources = three output Finding instances""" + 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_batch_with_resources queries ResourceFindingMapping directly + # No RLS mock needed - test DB doesn't enforce RLS policies + with patch( + "tasks.jobs.attack_paths.findings.READ_REPLICA_ALIAS", + "default", + ): + result = findings_module._enrich_batch_with_resources( + [finding_dict], str(tenant.id) + ) + + 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_batch_with_resources_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.findings.READ_REPLICA_ALIAS", + "default", + ), + patch("tasks.jobs.attack_paths.findings.logger") as mock_logger, + ): + result = findings_module._enrich_batch_with_resources( + [finding_dict], str(tenant.id) + ) + + 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.findings.rls_transaction") as mock_rls, + patch("tasks.jobs.attack_paths.findings.Finding") as mock_finding, + ): + # Create generator but don't iterate + findings_module.stream_findings_with_resources(provider, scan_id) + + # Nothing should be called yet + mock_rls.assert_not_called() + mock_finding.objects.filter.assert_not_called() + + def test_load_findings_empty_generator(self, providers_fixture): + """Empty generator should not call neo4j""" + 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.findings.get_root_node_label", + return_value="AWSAccount", + ), + patch( + "tasks.jobs.attack_paths.findings.get_node_uid_field", + return_value="arn", + ), + patch( + "tasks.jobs.attack_paths.findings.get_provider_resource_label", + return_value="AWSResource", + ), + ): + findings_module.load_findings(mock_session, empty_gen(), provider, config) + + mock_session.run.assert_not_called() + + +class TestInternetAnalysis: + def _make_provider_and_config(self): + provider = MagicMock() + provider.provider = "aws" + provider.uid = "123456789012" + config = SimpleNamespace(update_tag=1234567890) + return provider, config + + def test_analysis_creates_node_and_relationships(self): + """Verify both Cypher statements are executed and relationship count returned.""" + mock_session = MagicMock() + mock_result = MagicMock() + mock_result.single.return_value = {"relationships_merged": 3} + mock_session.run.side_effect = [None, mock_result] + provider, config = self._make_provider_and_config() + + with patch( + "tasks.jobs.attack_paths.internet.get_root_node_label", + return_value="AWSAccount", + ): + result = internet_module.analysis(mock_session, provider, config) + + assert mock_session.run.call_count == 2 + assert result == 3 + + def test_analysis_zero_exposed_resources(self): + """When no resources are exposed, zero relationships are created.""" + mock_session = MagicMock() + mock_result = MagicMock() + mock_result.single.return_value = {"relationships_merged": 0} + mock_session.run.side_effect = [None, mock_result] + provider, config = self._make_provider_and_config() + + with patch( + "tasks.jobs.attack_paths.internet.get_root_node_label", + return_value="AWSAccount", + ): + result = internet_module.analysis(mock_session, provider, config) + + assert result == 0 diff --git a/api/src/backend/tasks/tests/test_backfill.py b/api/src/backend/tasks/tests/test_backfill.py index b436f13151..04b3158d22 100644 --- a/api/src/backend/tasks/tests/test_backfill.py +++ b/api/src/backend/tasks/tests/test_backfill.py @@ -1,43 +1,102 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest -from tasks.jobs.backfill import backfill_resource_scan_summaries +from tasks.jobs.backfill import ( + backfill_compliance_summaries, + backfill_provider_compliance_scores, + backfill_resource_scan_summaries, + backfill_scan_category_summaries, + backfill_scan_resource_group_summaries, +) -from api.models import ResourceScanSummary, Scan, StateChoices +from api.models import ( + ComplianceOverviewSummary, + Finding, + ResourceScanSummary, + Scan, + ScanCategorySummary, + ScanGroupSummary, + StateChoices, +) +from prowler.lib.check.models import Severity +from prowler.lib.outputs.finding import Status + + +@pytest.fixture(scope="function") +def resource_scan_summary_data(scans_fixture): + scan = scans_fixture[0] + return ResourceScanSummary.objects.create( + tenant_id=scan.tenant_id, + scan_id=scan.id, + resource_id=str(uuid4()), + service="aws", + region="us-east-1", + resource_type="instance", + ) + + +@pytest.fixture(scope="function") +def get_not_completed_scans(providers_fixture): + provider_id = providers_fixture[0].id + tenant_id = providers_fixture[0].tenant_id + scan_1 = Scan.objects.create( + tenant_id=tenant_id, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.EXECUTING, + provider_id=provider_id, + ) + scan_2 = Scan.objects.create( + tenant_id=tenant_id, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + provider_id=provider_id, + ) + return scan_1, scan_2 + + +@pytest.fixture(scope="function") +def findings_with_categories_fixture(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource = resources_fixture[0] + + finding = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_with_categories", + scan=scan, + delta="new", + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="test_check", + check_metadata={"CheckId": "test_check"}, + categories=["gen-ai", "security"], + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + return finding + + +@pytest.fixture(scope="function") +def scan_category_summary_fixture(scans_fixture): + scan = scans_fixture[0] + return ScanCategorySummary.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + category="existing-category", + severity=Severity.critical, + total_findings=1, + failed_findings=0, + new_failed_findings=0, + ) @pytest.mark.django_db class TestBackfillResourceScanSummaries: - @pytest.fixture(scope="function") - def resource_scan_summary_data(self, scans_fixture): - scan = scans_fixture[0] - return ResourceScanSummary.objects.create( - tenant_id=scan.tenant_id, - scan_id=scan.id, - resource_id=str(uuid4()), - service="aws", - region="us-east-1", - resource_type="instance", - ) - - @pytest.fixture(scope="function") - def get_not_completed_scans(self, providers_fixture): - provider_id = providers_fixture[0].id - tenant_id = providers_fixture[0].tenant_id - scan_1 = Scan.objects.create( - tenant_id=tenant_id, - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.EXECUTING, - provider_id=provider_id, - ) - scan_2 = Scan.objects.create( - tenant_id=tenant_id, - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.AVAILABLE, - provider_id=provider_id, - ) - return scan_1, scan_2 - def test_already_backfilled(self, resource_scan_summary_data): tenant_id = resource_scan_summary_data.tenant_id scan_id = resource_scan_summary_data.scan_id @@ -77,3 +136,279 @@ class TestBackfillResourceScanSummaries: assert summary.service == resource.service assert summary.region == resource.region assert summary.resource_type == resource.type + + def test_no_resources_to_backfill(self, scans_fixture): + scan = scans_fixture[1] # Failed scan with no findings/resources + tenant_id = str(scan.tenant_id) + scan_id = str(scan.id) + + result = backfill_resource_scan_summaries(tenant_id, scan_id) + + assert result == {"status": "no resources to backfill"} + + +@pytest.mark.django_db +class TestBackfillComplianceSummaries: + def test_already_backfilled(self, scans_fixture): + scan = scans_fixture[0] + tenant_id = str(scan.tenant_id) + ComplianceOverviewSummary.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + compliance_id="aws_account_security_onboarding_aws", + requirements_passed=1, + requirements_failed=0, + requirements_manual=0, + total_requirements=1, + ) + + result = backfill_compliance_summaries(tenant_id, str(scan.id)) + + assert result == {"status": "already backfilled"} + + def test_not_completed_scan(self, get_not_completed_scans): + for scan in get_not_completed_scans: + result = backfill_compliance_summaries(str(scan.tenant_id), str(scan.id)) + assert result == {"status": "scan is not completed"} + + def test_no_compliance_data(self, scans_fixture): + scan = scans_fixture[1] # Failed scan with no compliance rows + + result = backfill_compliance_summaries(str(scan.tenant_id), str(scan.id)) + + assert result == {"status": "no compliance data to backfill"} + + def test_backfill_creates_compliance_summaries( + self, tenants_fixture, scans_fixture, compliance_requirements_overviews_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + result = backfill_compliance_summaries(str(tenant.id), str(scan.id)) + + expected = { + "aws_account_security_onboarding_aws": { + "requirements_passed": 1, + "requirements_failed": 1, + "requirements_manual": 1, + "total_requirements": 3, + }, + "cis_1.4_aws": { + "requirements_passed": 0, + "requirements_failed": 1, + "requirements_manual": 0, + "total_requirements": 1, + }, + "mitre_attack_aws": { + "requirements_passed": 0, + "requirements_failed": 1, + "requirements_manual": 0, + "total_requirements": 1, + }, + } + + assert result == {"status": "backfilled", "inserted": len(expected)} + + summaries = ComplianceOverviewSummary.objects.filter( + tenant_id=str(tenant.id), scan_id=str(scan.id) + ) + assert summaries.count() == len(expected) + + for summary in summaries: + assert summary.compliance_id in expected + expected_counts = expected[summary.compliance_id] + assert summary.requirements_passed == expected_counts["requirements_passed"] + assert summary.requirements_failed == expected_counts["requirements_failed"] + assert summary.requirements_manual == expected_counts["requirements_manual"] + assert summary.total_requirements == expected_counts["total_requirements"] + + +@pytest.mark.django_db +class TestBackfillScanCategorySummaries: + def test_already_backfilled(self, scan_category_summary_fixture): + tenant_id = scan_category_summary_fixture.tenant_id + scan_id = scan_category_summary_fixture.scan_id + + result = backfill_scan_category_summaries(str(tenant_id), str(scan_id)) + + assert result == {"status": "already backfilled"} + + def test_not_completed_scan(self, get_not_completed_scans): + for scan in get_not_completed_scans: + result = backfill_scan_category_summaries(str(scan.tenant_id), str(scan.id)) + assert result == {"status": "scan is not completed"} + + def test_no_categories_to_backfill(self, scans_fixture): + scan = scans_fixture[1] # Failed scan with no findings + result = backfill_scan_category_summaries(str(scan.tenant_id), str(scan.id)) + assert result == {"status": "no categories to backfill"} + + def test_successful_backfill(self, findings_with_categories_fixture): + finding = findings_with_categories_fixture + tenant_id = str(finding.tenant_id) + scan_id = str(finding.scan_id) + + result = backfill_scan_category_summaries(tenant_id, scan_id) + + # 2 categories × 1 severity = 2 rows + assert result == {"status": "backfilled", "categories_count": 2} + + summaries = ScanCategorySummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ) + assert summaries.count() == 2 + categories = set(summaries.values_list("category", flat=True)) + assert categories == {"gen-ai", "security"} + + for summary in summaries: + assert summary.severity == Severity.critical + assert summary.total_findings == 1 + assert summary.failed_findings == 1 + assert summary.new_failed_findings == 1 + + +@pytest.fixture(scope="function") +def findings_with_group_fixture(scans_fixture, resources_fixture): + scan = scans_fixture[0] + resource = resources_fixture[0] + + finding = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="finding_with_group", + scan=scan, + delta="new", + status=Status.FAIL, + status_extended="test status", + impact=Severity.high, + impact_extended="test impact", + severity=Severity.high, + raw_result={"status": Status.FAIL}, + check_id="test_check", + check_metadata={"CheckId": "test_check"}, + resource_groups="ai_ml", + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + return finding + + +@pytest.fixture(scope="function") +def scan_resource_group_summary_fixture(scans_fixture): + scan = scans_fixture[0] + return ScanGroupSummary.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + resource_group="existing-group", + severity=Severity.high, + total_findings=1, + failed_findings=0, + new_failed_findings=0, + resources_count=1, + ) + + +@pytest.mark.django_db +class TestBackfillScanGroupSummaries: + def test_already_backfilled(self, scan_resource_group_summary_fixture): + tenant_id = scan_resource_group_summary_fixture.tenant_id + scan_id = scan_resource_group_summary_fixture.scan_id + + result = backfill_scan_resource_group_summaries(str(tenant_id), str(scan_id)) + + assert result == {"status": "already backfilled"} + + def test_not_completed_scan(self, get_not_completed_scans): + for scan in get_not_completed_scans: + result = backfill_scan_resource_group_summaries( + str(scan.tenant_id), str(scan.id) + ) + assert result == {"status": "scan is not completed"} + + def test_no_resource_groups_to_backfill(self, scans_fixture): + scan = scans_fixture[1] # Failed scan with no findings + result = backfill_scan_resource_group_summaries( + str(scan.tenant_id), str(scan.id) + ) + assert result == {"status": "no resource groups to backfill"} + + def test_successful_backfill(self, findings_with_group_fixture): + finding = findings_with_group_fixture + tenant_id = str(finding.tenant_id) + scan_id = str(finding.scan_id) + + result = backfill_scan_resource_group_summaries(tenant_id, scan_id) + + # 1 resource group × 1 severity = 1 row + assert result == {"status": "backfilled", "resource_groups_count": 1} + + summaries = ScanGroupSummary.objects.filter( + tenant_id=tenant_id, scan_id=scan_id + ) + assert summaries.count() == 1 + + summary = summaries.first() + assert summary.resource_group == "ai_ml" + assert summary.severity == Severity.high + assert summary.total_findings == 1 + assert summary.failed_findings == 1 + assert summary.new_failed_findings == 1 + assert summary.resources_count == 1 + + +@pytest.mark.django_db +class TestBackfillProviderComplianceScores: + def test_no_completed_scans(self, tenants_fixture): + tenant = tenants_fixture[2] + result = backfill_provider_compliance_scores(str(tenant.id)) + assert result == {"status": "no completed scans"} + + def test_no_scans_to_process(self, tenants_fixture, scans_fixture): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + scan.completed_at = None + scan.save() + + result = backfill_provider_compliance_scores(str(tenant.id)) + assert result == {"status": "no completed scans"} + + @patch("tasks.jobs.backfill.psycopg_connection") + def test_successful_backfill_executes_sql_queries( + self, + mock_psycopg_connection, + tenants_fixture, + scans_fixture, + settings, + ): + """Test successful backfill executes SQL queries and returns correct stats.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + # Set completed_at to make the scan eligible for backfill + scan.completed_at = datetime.now(timezone.utc) + scan.save() + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + connection.autocommit = True + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + cursor.rowcount = 5 + + result = backfill_provider_compliance_scores(str(tenant.id)) + + assert result["status"] == "backfilled" + assert result["providers_processed"] == 1 + assert result["providers_skipped"] == 0 + assert result["total_upserted"] == 5 + assert result["tenant_summary_count"] == 5 diff --git a/api/src/backend/tasks/tests/test_beat.py b/api/src/backend/tasks/tests/test_beat.py index 7a1656553f..5c25e97340 100644 --- a/api/src/backend/tasks/tests/test_beat.py +++ b/api/src/backend/tasks/tests/test_beat.py @@ -28,6 +28,7 @@ class TestScheduleProviderScan: "tenant_id": str(provider_instance.tenant_id), "provider_id": str(provider_instance.id), }, + countdown=5, ) task_name = f"scan-perform-scheduled-{provider_instance.id}" diff --git a/api/src/backend/tasks/tests/test_connection.py b/api/src/backend/tasks/tests/test_connection.py index 30973f98bf..e5e39d8778 100644 --- a/api/src/backend/tasks/tests/test_connection.py +++ b/api/src/backend/tasks/tests/test_connection.py @@ -82,7 +82,7 @@ def test_check_provider_connection_exception( [ { "name": "OpenAI", - "api_key_decoded": "sk-test1234567890T3BlbkFJtest1234567890", + "api_key_decoded": "sk-fake-test-key-for-unit-testing-only", "model": "gpt-4o", "temperature": 0, "max_tokens": 4000, diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index 81cdb44daa..843ccb5df8 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -1,27 +1,60 @@ +from unittest.mock import call, patch + import pytest + from django.core.exceptions import ObjectDoesNotExist -from tasks.jobs.deletion import delete_provider, delete_tenant from api.models import Provider, Tenant +from tasks.jobs.deletion import delete_provider, delete_tenant @pytest.mark.django_db class TestDeleteProvider: def test_delete_provider_success(self, providers_fixture): - instance = providers_fixture[0] - tenant_id = str(instance.tenant_id) - result = delete_provider(tenant_id, instance.id) + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ) as mock_get_database_name, + patch( + "tasks.jobs.deletion.graph_database.drop_subgraph" + ) as mock_drop_subgraph, + ): + instance = providers_fixture[0] + tenant_id = str(instance.tenant_id) + result = delete_provider(tenant_id, instance.id) - assert result - with pytest.raises(ObjectDoesNotExist): - Provider.objects.get(pk=instance.id) + assert result + with pytest.raises(ObjectDoesNotExist): + Provider.objects.get(pk=instance.id) + + mock_get_database_name.assert_called_once_with(tenant_id) + mock_drop_subgraph.assert_called_once_with( + "tenant-db", + str(instance.id), + ) def test_delete_provider_does_not_exist(self, tenants_fixture): - tenant_id = str(tenants_fixture[0].id) - non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645" + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ) as mock_get_database_name, + patch( + "tasks.jobs.deletion.graph_database.drop_subgraph" + ) as mock_drop_subgraph, + ): + tenant_id = str(tenants_fixture[0].id) + non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645" - with pytest.raises(ObjectDoesNotExist): - delete_provider(tenant_id, non_existent_pk) + with pytest.raises(ObjectDoesNotExist): + delete_provider(tenant_id, non_existent_pk) + + mock_get_database_name.assert_called_once_with(tenant_id) + mock_drop_subgraph.assert_called_once_with( + "tenant-db", + non_existent_pk, + ) @pytest.mark.django_db @@ -30,33 +63,82 @@ class TestDeleteTenant: """ Test successful deletion of a tenant and its related data. """ - tenant = tenants_fixture[0] - providers = Provider.objects.filter(tenant_id=tenant.id) + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ) as mock_get_database_name, + patch( + "tasks.jobs.deletion.graph_database.drop_subgraph" + ) as mock_drop_subgraph, + patch( + "tasks.jobs.deletion.graph_database.drop_database" + ) as mock_drop_database, + ): + tenant = tenants_fixture[0] + providers = list(Provider.objects.filter(tenant_id=tenant.id)) - # Ensure the tenant and related providers exist before deletion - assert Tenant.objects.filter(id=tenant.id).exists() - assert providers.exists() + # Ensure the tenant and related providers exist before deletion + assert Tenant.objects.filter(id=tenant.id).exists() + assert providers - # Call the function and validate the result - deletion_summary = delete_tenant(tenant.id) + # Call the function and validate the result + deletion_summary = delete_tenant(tenant.id) - assert deletion_summary is not None - assert not Tenant.objects.filter(id=tenant.id).exists() - assert not Provider.objects.filter(tenant_id=tenant.id).exists() + assert deletion_summary is not None + assert not Tenant.objects.filter(id=tenant.id).exists() + assert not Provider.objects.filter(tenant_id=tenant.id).exists() + + # get_database_name is called once per provider + once for drop_database + expected_get_db_calls = [call(tenant.id) for _ in providers] + [ + call(tenant.id) + ] + mock_get_database_name.assert_has_calls( + expected_get_db_calls, any_order=True + ) + assert mock_get_database_name.call_count == len(expected_get_db_calls) + + expected_drop_subgraph_calls = [ + call("tenant-db", str(provider.id)) for provider in providers + ] + mock_drop_subgraph.assert_has_calls( + expected_drop_subgraph_calls, + any_order=True, + ) + assert mock_drop_subgraph.call_count == len(expected_drop_subgraph_calls) + + mock_drop_database.assert_called_once_with("tenant-db") def test_delete_tenant_with_no_providers(self, tenants_fixture): """ Test deletion of a tenant with no related providers. """ - tenant = tenants_fixture[1] # Assume this tenant has no providers - providers = Provider.objects.filter(tenant_id=tenant.id) + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ) as mock_get_database_name, + patch( + "tasks.jobs.deletion.graph_database.drop_subgraph" + ) as mock_drop_subgraph, + patch( + "tasks.jobs.deletion.graph_database.drop_database" + ) as mock_drop_database, + ): + tenant = tenants_fixture[1] # Assume this tenant has no providers + providers = Provider.objects.filter(tenant_id=tenant.id) - # Ensure the tenant exists but has no related providers - assert Tenant.objects.filter(id=tenant.id).exists() - assert not providers.exists() + # Ensure the tenant exists but has no related providers + assert Tenant.objects.filter(id=tenant.id).exists() + assert not providers.exists() - # Call the function and validate the result - deletion_summary = delete_tenant(tenant.id) + # Call the function and validate the result + deletion_summary = delete_tenant(tenant.id) - assert deletion_summary == {} # No providers, so empty summary - assert not Tenant.objects.filter(id=tenant.id).exists() + assert deletion_summary == {} # No providers, so empty summary + assert not Tenant.objects.filter(id=tenant.id).exists() + + # get_database_name is called once for drop_database + mock_get_database_name.assert_called_once_with(tenant.id) + mock_drop_subgraph.assert_not_called() + mock_drop_database.assert_called_once_with("tenant-db") diff --git a/api/src/backend/tasks/tests/test_export.py b/api/src/backend/tasks/tests/test_export.py index f1e7120989..416361d95e 100644 --- a/api/src/backend/tasks/tests/test_export.py +++ b/api/src/backend/tasks/tests/test_export.py @@ -9,6 +9,7 @@ import pytest from botocore.exceptions import ClientError from tasks.jobs.export import ( _compress_output_files, + _generate_compliance_output_directory, _generate_output_directory, _upload_to_s3, get_s3_client, @@ -147,10 +148,11 @@ class TestOutputs: ) mock_logger.assert_called() + @patch("tasks.jobs.export.set_output_timestamp") @patch("tasks.jobs.export.rls_transaction") @patch("tasks.jobs.export.Scan") def test_generate_output_directory_creates_paths( - self, mock_scan, mock_rls_transaction, tmpdir + self, mock_scan, mock_rls_transaction, mock_set_timestamp, tmpdir ): # Mock the scan object with a started_at timestamp mock_scan_instance = MagicMock() @@ -168,22 +170,40 @@ class TestOutputs: provider = "aws" expected_timestamp = "20230615103045" - path, compliance, threatscore = _generate_output_directory( + # Test _generate_output_directory (returns standard and compliance paths) + path, compliance = _generate_output_directory( base_dir, provider, tenant_id, scan_id ) assert os.path.isdir(os.path.dirname(path)) assert os.path.isdir(os.path.dirname(compliance)) - assert os.path.isdir(os.path.dirname(threatscore)) - assert path.endswith(f"{provider}-{expected_timestamp}") assert compliance.endswith(f"{provider}-{expected_timestamp}") - assert threatscore.endswith(f"{provider}-{expected_timestamp}") + assert "/compliance/" in compliance + # Test _generate_compliance_output_directory with "threatscore" + threatscore = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="threatscore" + ) + + assert os.path.isdir(os.path.dirname(threatscore)) + assert threatscore.endswith(f"{provider}-{expected_timestamp}") + assert "/threatscore/" in threatscore + + # Test _generate_compliance_output_directory with "ens" + ens = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="ens" + ) + + assert os.path.isdir(os.path.dirname(ens)) + assert ens.endswith(f"{provider}-{expected_timestamp}") + assert "/ens/" in ens + + @patch("tasks.jobs.export.set_output_timestamp") @patch("tasks.jobs.export.rls_transaction") @patch("tasks.jobs.export.Scan") def test_generate_output_directory_invalid_character( - self, mock_scan, mock_rls_transaction, tmpdir + self, mock_scan, mock_rls_transaction, mock_set_timestamp, tmpdir ): # Mock the scan object with a started_at timestamp mock_scan_instance = MagicMock() @@ -201,14 +221,25 @@ class TestOutputs: provider = "aws/test@check" expected_timestamp = "20230615103045" - path, compliance, threatscore = _generate_output_directory( + # Test provider name sanitization with _generate_output_directory + path, compliance = _generate_output_directory( base_dir, provider, tenant_id, scan_id ) assert os.path.isdir(os.path.dirname(path)) assert os.path.isdir(os.path.dirname(compliance)) - assert os.path.isdir(os.path.dirname(threatscore)) - assert path.endswith(f"aws-test-check-{expected_timestamp}") assert compliance.endswith(f"aws-test-check-{expected_timestamp}") + + # Test provider name sanitization with _generate_compliance_output_directory + threatscore = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="threatscore" + ) + ens = _generate_compliance_output_directory( + base_dir, provider, tenant_id, scan_id, compliance_framework="ens" + ) + + assert os.path.isdir(os.path.dirname(threatscore)) + assert os.path.isdir(os.path.dirname(ens)) assert threatscore.endswith(f"aws-test-check-{expected_timestamp}") + assert ens.endswith(f"aws-test-check-{expected_timestamp}") diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index b8e06c9e7b..954c645998 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -9,6 +9,7 @@ from tasks.jobs.integrations import ( upload_security_hub_integration, ) +from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.models import Integration from api.utils import prowler_integration_connection_test from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection @@ -416,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 @@ -428,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") ) @@ -880,7 +877,8 @@ class TestSecurityHubIntegrationUploads: # Verify RLS transaction was used correctly # Should be called twice: once for getting provider info, once for resetting regions assert mock_rls.call_count == 2 - mock_rls.assert_any_call(tenant_id) + mock_rls.assert_any_call(tenant_id, using=READ_REPLICA_ALIAS) + mock_rls.assert_any_call(tenant_id, using=MainRouter.default_db) # Verify test_connection was called with integration credentials (not provider's) mock_test_connection.assert_called_once_with( @@ -1197,9 +1195,6 @@ class TestSecurityHubIntegrationUploads: ) assert result is False - # Integration should be marked as disconnected - integration.save.assert_called_once() - assert integration.connected is False @patch("tasks.jobs.integrations.ASFF") @patch("tasks.jobs.integrations.FindingOutput") diff --git a/api/src/backend/tasks/tests/test_muting.py b/api/src/backend/tasks/tests/test_muting.py new file mode 100644 index 0000000000..d8ae310f2e --- /dev/null +++ b/api/src/backend/tasks/tests/test_muting.py @@ -0,0 +1,532 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from django.core.exceptions import ObjectDoesNotExist +from tasks.jobs.muting import mute_historical_findings + +from api.models import Finding, MuteRule +from prowler.lib.check.models import Severity +from prowler.lib.outputs.finding import Status + + +@pytest.mark.django_db +class TestMuteHistoricalFindings: + """ + Test suite for the mute_historical_findings function. + + This class tests the batch processing of findings to update their muted status + based on MuteRule criteria. + """ + + @pytest.fixture(scope="function") + def test_user(self, create_test_user): + """Create a test user for mute rule creation.""" + return create_test_user + + @pytest.fixture(scope="function") + def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user): + """ + Create a mute rule that targets the first finding in the fixture. + """ + tenant = tenants_fixture[0] + finding = findings_fixture[0] + + mute_rule = MuteRule.objects.create( + tenant_id=tenant.id, + name="Test Mute Rule", + reason="Testing mute functionality", + enabled=True, + created_by=test_user, + finding_uids=[finding.uid], + ) + + return mute_rule + + @pytest.fixture(scope="function") + def mute_rule_multiple_findings(self, scans_fixture, test_user): + """ + Create multiple unmuted findings and a mute rule targeting all of them. + """ + scan = scans_fixture[0] + tenant_id = scan.tenant_id + + # Create 5 unmuted findings + finding_uids = [] + for i in range(5): + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"test_finding_uid_mute_{i}", + scan=scan, + status=Status.FAIL, + status_extended=f"Test status {i}", + impact=Severity.high, + severity=Severity.high, + raw_result={ + "status": Status.FAIL, + "impact": Severity.high, + "severity": Severity.high, + }, + check_id=f"test_check_id_{i}", + check_metadata={ + "CheckId": f"test_check_id_{i}", + "Description": f"Test description {i}", + }, + muted=False, + ) + finding_uids.append(finding.uid) + + # Create mute rule targeting all findings + mute_rule = MuteRule.objects.create( + tenant_id=tenant_id, + name="Test Multiple Findings Mute Rule", + reason="Testing batch muting", + enabled=True, + created_by=test_user, + finding_uids=finding_uids, + ) + + return mute_rule, finding_uids + + @pytest.fixture(scope="function") + def mute_rule_already_muted(self, findings_fixture, test_user): + """ + Create a mute rule that targets an already-muted finding. + """ + tenant_id = findings_fixture[1].tenant_id + already_muted_finding = findings_fixture[1] + + mute_rule = MuteRule.objects.create( + tenant_id=tenant_id, + name="Test Already Muted Rule", + reason="Testing already muted findings", + enabled=True, + created_by=test_user, + finding_uids=[already_muted_finding.uid], + ) + + return mute_rule + + @pytest.fixture(scope="function") + def mute_rule_mixed_findings(self, scans_fixture, test_user): + """ + Create a mute rule with a mix of muted and unmuted findings. + """ + scan = scans_fixture[0] + tenant_id = scan.tenant_id + + # Create 3 unmuted findings + unmuted_uids = [] + for i in range(3): + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"unmuted_finding_{i}", + scan=scan, + status=Status.FAIL, + status_extended=f"Unmuted status {i}", + impact=Severity.medium, + severity=Severity.medium, + raw_result={ + "status": Status.FAIL, + "impact": Severity.medium, + "severity": Severity.medium, + }, + check_id=f"unmuted_check_{i}", + check_metadata={ + "CheckId": f"unmuted_check_{i}", + "Description": f"Unmuted description {i}", + }, + muted=False, + ) + unmuted_uids.append(finding.uid) + + # Create 2 already muted findings + muted_uids = [] + for i in range(2): + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"muted_finding_{i}", + scan=scan, + status=Status.FAIL, + status_extended=f"Muted status {i}", + impact=Severity.low, + severity=Severity.low, + raw_result={ + "status": Status.FAIL, + "impact": Severity.low, + "severity": Severity.low, + }, + check_id=f"muted_check_{i}", + check_metadata={ + "CheckId": f"muted_check_{i}", + "Description": f"Muted description {i}", + }, + muted=True, + muted_at=datetime.now(timezone.utc), + muted_reason="Already muted", + ) + muted_uids.append(finding.uid) + + # Create mute rule targeting all findings + all_uids = unmuted_uids + muted_uids + mute_rule = MuteRule.objects.create( + tenant_id=tenant_id, + name="Test Mixed Findings Rule", + reason="Testing mixed muted/unmuted findings", + enabled=True, + created_by=test_user, + finding_uids=all_uids, + ) + + return mute_rule, unmuted_uids, muted_uids + + @pytest.fixture(scope="function") + def mute_rule_batch_test(self, scans_fixture, test_user): + """ + Create enough findings to test batch processing (>1000 for default batch size). + """ + scan = scans_fixture[0] + tenant_id = scan.tenant_id + + # Create 1500 findings to exceed default batch size of 1000 + finding_uids = [] + for i in range(1500): + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"batch_test_finding_{i}", + scan=scan, + status=Status.FAIL, + status_extended=f"Batch test status {i}", + impact=Severity.critical, + severity=Severity.critical, + raw_result={ + "status": Status.FAIL, + "impact": Severity.critical, + "severity": Severity.critical, + }, + check_id=f"batch_test_check_{i}", + check_metadata={ + "CheckId": f"batch_test_check_{i}", + "Description": f"Batch test description {i}", + }, + muted=False, + ) + finding_uids.append(finding.uid) + + # Create mute rule targeting all findings + mute_rule = MuteRule.objects.create( + tenant_id=tenant_id, + name="Test Batch Processing Rule", + reason="Testing batch processing functionality", + enabled=True, + created_by=test_user, + finding_uids=finding_uids, + ) + + return mute_rule, finding_uids + + def test_mute_historical_findings_single_finding( + self, mute_rule_with_findings, findings_fixture + ): + """ + Test muting a single historical finding. + """ + mute_rule = mute_rule_with_findings + tenant_id = str(mute_rule.tenant_id) + finding = findings_fixture[0] + + # Ensure the finding is not muted before execution + finding.refresh_from_db() + assert finding.muted is False + assert finding.muted_at is None + assert finding.muted_reason is None + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify return value + assert result["findings_muted"] == 1 + assert result["rule_id"] == str(mute_rule.id) + + # Verify the finding was muted + finding.refresh_from_db() + assert finding.muted is True + assert finding.muted_at == mute_rule.inserted_at + assert finding.muted_reason == mute_rule.reason + + def test_mute_historical_findings_multiple_findings( + self, mute_rule_multiple_findings + ): + """ + Test muting multiple historical findings. + """ + mute_rule, finding_uids = mute_rule_multiple_findings + tenant_id = str(mute_rule.tenant_id) + + # Verify all findings are unmuted + findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) + assert findings.count() == 5 + for finding in findings: + assert finding.muted is False + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify return value + assert result["findings_muted"] == 5 + assert result["rule_id"] == str(mute_rule.id) + + # Verify all findings were muted + findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) + for finding in findings: + assert finding.muted is True + assert finding.muted_at == mute_rule.inserted_at + assert finding.muted_reason == mute_rule.reason + + def test_mute_historical_findings_already_muted( + self, mute_rule_already_muted, findings_fixture + ): + """ + Test that already-muted findings are not counted or updated. + """ + mute_rule = mute_rule_already_muted + tenant_id = str(mute_rule.tenant_id) + finding = findings_fixture[1] + + # Verify the finding is already muted + finding.refresh_from_db() + assert finding.muted is True + original_muted_at = finding.muted_at + original_muted_reason = finding.muted_reason + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify no findings were muted + assert result["findings_muted"] == 0 + assert result["rule_id"] == str(mute_rule.id) + + # Verify the finding's mute status did not change + finding.refresh_from_db() + assert finding.muted is True + assert finding.muted_at == original_muted_at + assert finding.muted_reason == original_muted_reason + + def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings): + """ + Test muting when some findings are already muted and others are not. + """ + mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings + tenant_id = str(mute_rule.tenant_id) + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify only unmuted findings were counted + assert result["findings_muted"] == 3 + assert result["rule_id"] == str(mute_rule.id) + + # Verify unmuted findings are now muted + unmuted_findings = Finding.objects.filter( + tenant_id=tenant_id, uid__in=unmuted_uids + ) + for finding in unmuted_findings: + assert finding.muted is True + assert finding.muted_at == mute_rule.inserted_at + assert finding.muted_reason == mute_rule.reason + + # Verify already-muted findings remained unchanged + already_muted_findings = Finding.objects.filter( + tenant_id=tenant_id, uid__in=muted_uids + ) + for finding in already_muted_findings: + assert finding.muted is True + assert finding.muted_reason == "Already muted" + + def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture): + """ + Test that a nonexistent mute rule raises ObjectDoesNotExist. + """ + tenant_id = str(tenants_fixture[0].id) + nonexistent_rule_id = str(uuid4()) + + with pytest.raises(ObjectDoesNotExist): + mute_historical_findings(tenant_id, nonexistent_rule_id) + + def test_mute_historical_findings_no_matching_findings( + self, tenants_fixture, test_user + ): + """ + Test muting when no findings match the rule's UIDs. + """ + tenant_id = str(tenants_fixture[0].id) + + # Create a mute rule with non-existent finding UIDs + mute_rule = MuteRule.objects.create( + tenant_id=tenant_id, + name="Test No Match Rule", + reason="Testing no matching findings", + enabled=True, + created_by=test_user, + finding_uids=[ + "nonexistent_uid_1", + "nonexistent_uid_2", + "nonexistent_uid_3", + ], + ) + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify no findings were muted + assert result["findings_muted"] == 0 + assert result["rule_id"] == str(mute_rule.id) + + def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test): + """ + Test that large numbers of findings are processed in batches correctly. + """ + mute_rule, finding_uids = mute_rule_batch_test + tenant_id = str(mute_rule.tenant_id) + + # Verify all findings exist and are unmuted + findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) + assert findings.count() == 1500 + for finding in findings: + assert finding.muted is False + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify return value + assert result["findings_muted"] == 1500 + assert result["rule_id"] == str(mute_rule.id) + + # Verify all findings were muted + findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) + for finding in findings: + assert finding.muted is True + assert finding.muted_at == mute_rule.inserted_at + assert finding.muted_reason == mute_rule.reason + + def test_mute_historical_findings_preserves_muted_at_timestamp( + self, mute_rule_with_findings, findings_fixture + ): + """ + Test that muted_at is set to the rule's inserted_at, not the current time. + """ + mute_rule = mute_rule_with_findings + tenant_id = str(mute_rule.tenant_id) + finding = findings_fixture[0] + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify the finding was muted + assert result["findings_muted"] == 1 + + # Verify muted_at matches the rule's inserted_at timestamp + finding.refresh_from_db() + assert finding.muted_at == mute_rule.inserted_at + assert finding.muted_at is not None + + def test_mute_historical_findings_partial_match(self, scans_fixture, test_user): + """ + Test muting when only some of the rule's UIDs exist as findings. + """ + scan = scans_fixture[0] + tenant_id = str(scan.tenant_id) + + # Create 3 findings + existing_uids = [] + for i in range(3): + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"partial_match_finding_{i}", + scan=scan, + status=Status.FAIL, + status_extended=f"Partial match status {i}", + impact=Severity.high, + severity=Severity.high, + raw_result={ + "status": Status.FAIL, + "impact": Severity.high, + "severity": Severity.high, + }, + check_id=f"partial_match_check_{i}", + check_metadata={ + "CheckId": f"partial_match_check_{i}", + "Description": f"Partial match description {i}", + }, + muted=False, + ) + existing_uids.append(finding.uid) + + # Create a mute rule with both existing and non-existing UIDs + all_uids = existing_uids + [ + "nonexistent_uid_1", + "nonexistent_uid_2", + ] + mute_rule = MuteRule.objects.create( + tenant_id=tenant_id, + name="Test Partial Match Rule", + reason="Testing partial matching", + enabled=True, + created_by=test_user, + finding_uids=all_uids, + ) + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify only existing findings were muted + assert result["findings_muted"] == 3 + assert result["rule_id"] == str(mute_rule.id) + + # Verify the existing findings were muted + findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids) + assert findings.count() == 3 + for finding in findings: + assert finding.muted is True + assert finding.muted_at == mute_rule.inserted_at + assert finding.muted_reason == mute_rule.reason + + def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user): + """ + Test muting when the rule has an empty finding_uids array. + """ + tenant_id = str(tenants_fixture[0].id) + + # Create a mute rule with empty finding_uids + mute_rule = MuteRule.objects.create( + tenant_id=tenant_id, + name="Test Empty UIDs Rule", + reason="Testing empty UIDs", + enabled=True, + created_by=test_user, + finding_uids=[], + ) + + # Execute the muting function + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify no findings were muted + assert result["findings_muted"] == 0 + assert result["rule_id"] == str(mute_rule.id) + + def test_mute_historical_findings_return_format(self, mute_rule_with_findings): + """ + Test that the return value has the correct format and fields. + """ + mute_rule = mute_rule_with_findings + tenant_id = str(mute_rule.tenant_id) + + result = mute_historical_findings(tenant_id, str(mute_rule.id)) + + # Verify return value structure + assert isinstance(result, dict) + assert "findings_muted" in result + assert "rule_id" in result + assert isinstance(result["findings_muted"], int) + assert isinstance(result["rule_id"], str) + assert result["rule_id"] == str(mute_rule.id) diff --git a/api/src/backend/tasks/tests/test_report.py b/api/src/backend/tasks/tests/test_report.py deleted file mode 100644 index a472067b2d..0000000000 --- a/api/src/backend/tasks/tests/test_report.py +++ /dev/null @@ -1,963 +0,0 @@ -import uuid -from pathlib import Path -from unittest.mock import MagicMock, patch - -import matplotlib -import pytest -from tasks.jobs.report import ( - _aggregate_requirement_statistics_from_database, - _calculate_requirements_data_from_statistics, - _load_findings_for_requirement_checks, - generate_threatscore_report, - generate_threatscore_report_job, -) -from tasks.tasks import generate_threatscore_report_task - -from api.models import Finding, StatusChoices -from prowler.lib.check.models import Severity - -matplotlib.use("Agg") # Use non-interactive backend for tests - - -@pytest.mark.django_db -class TestGenerateThreatscoreReport: - def setup_method(self): - self.scan_id = str(uuid.uuid4()) - self.provider_id = str(uuid.uuid4()) - self.tenant_id = str(uuid.uuid4()) - - def test_no_findings_returns_early(self): - with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter: - mock_filter.return_value.exists.return_value = False - - result = generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": False} - mock_filter.assert_called_once_with(scan_id=self.scan_id) - - @patch("tasks.jobs.report.rmtree") - @patch("tasks.jobs.report._upload_to_s3") - @patch("tasks.jobs.report.generate_threatscore_report") - @patch("tasks.jobs.report._generate_output_directory") - @patch("tasks.jobs.report.Provider.objects.get") - @patch("tasks.jobs.report.ScanSummary.objects.filter") - def test_generate_threatscore_report_happy_path( - self, - mock_scan_summary_filter, - mock_provider_get, - mock_generate_output_directory, - mock_generate_report, - mock_upload, - mock_rmtree, - ): - mock_scan_summary_filter.return_value.exists.return_value = True - - mock_provider = MagicMock() - mock_provider.uid = "provider-uid" - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - mock_generate_output_directory.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - mock_upload.return_value = "s3://bucket/threatscore_report.pdf" - - result = generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": True} - mock_generate_report.assert_called_once_with( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id="prowler_threatscore_aws", - output_path="/tmp/threatscore_path_threatscore_report.pdf", - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - mock_upload.assert_called_once_with( - self.tenant_id, - self.scan_id, - "/tmp/threatscore_path_threatscore_report.pdf", - "threatscore/threatscore_path_threatscore_report.pdf", - ) - mock_rmtree.assert_called_once_with( - Path("/tmp/threatscore_path_threatscore_report.pdf").parent, - ignore_errors=True, - ) - - def test_generate_threatscore_report_fails_upload(self): - with ( - patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, - patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, - patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, - patch("tasks.jobs.report.generate_threatscore_report"), - patch("tasks.jobs.report._upload_to_s3", return_value=None), - ): - mock_filter.return_value.exists.return_value = True - - # Mock provider - mock_provider = MagicMock() - mock_provider.uid = "aws-provider-uid" - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - mock_gen_dir.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - result = generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": False} - - def test_generate_threatscore_report_logs_rmtree_exception(self, caplog): - with ( - patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, - patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, - patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, - patch("tasks.jobs.report.generate_threatscore_report"), - patch( - "tasks.jobs.report._upload_to_s3", return_value="s3://bucket/report.pdf" - ), - patch( - "tasks.jobs.report.rmtree", side_effect=Exception("Test deletion error") - ), - ): - mock_filter.return_value.exists.return_value = True - - # Mock provider - mock_provider = MagicMock() - mock_provider.uid = "aws-provider-uid" - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - mock_gen_dir.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - with caplog.at_level("ERROR"): - generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - assert "Error deleting output files" in caplog.text - - def test_generate_threatscore_report_azure_provider(self): - with ( - patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter, - patch("tasks.jobs.report.Provider.objects.get") as mock_provider_get, - patch("tasks.jobs.report._generate_output_directory") as mock_gen_dir, - patch("tasks.jobs.report.generate_threatscore_report") as mock_generate, - patch( - "tasks.jobs.report._upload_to_s3", return_value="s3://bucket/report.pdf" - ), - patch("tasks.jobs.report.rmtree"), - ): - mock_filter.return_value.exists.return_value = True - - mock_provider = MagicMock() - mock_provider.uid = "azure-provider-uid" - mock_provider.provider = "azure" - mock_provider_get.return_value = mock_provider - - mock_gen_dir.return_value = ( - "/tmp/output", - "/tmp/compressed", - "/tmp/threatscore_path", - ) - - generate_threatscore_report_job( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - mock_generate.assert_called_once_with( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id="prowler_threatscore_azure", - output_path="/tmp/threatscore_path_threatscore_report.pdf", - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - - -@pytest.mark.django_db -class TestAggregateRequirementStatistics: - """Test suite for _aggregate_requirement_statistics_from_database function.""" - - def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture): - """Verify correct pass/total counts per check are aggregated from database.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create findings with different check_ids and statuses - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-1", - check_id="check_1", - status=StatusChoices.PASS, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-2", - check_id="check_1", - status=StatusChoices.FAIL, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-3", - check_id="check_2", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == { - "check_1": {"passed": 1, "total": 2}, - "check_2": {"passed": 1, "total": 1}, - } - - def test_handles_empty_scan(self, tenants_fixture, scans_fixture): - """Return empty dict when no findings exist for the scan.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == {} - - def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture): - """Aggregate multiple findings for same check_id correctly.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create 5 findings for same check, 3 passed - for i in range(3): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-pass-{i}", - check_id="check_same", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - for i in range(2): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-fail-{i}", - check_id="check_same", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == {"check_same": {"passed": 3, "total": 5}} - - def test_only_failed_findings(self, tenants_fixture, scans_fixture): - """Correctly count when all findings are FAIL status.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-fail-1", - check_id="check_fail", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-fail-2", - check_id="check_fail", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - assert result == {"check_fail": {"passed": 0, "total": 2}} - - def test_mixed_statuses(self, tenants_fixture, scans_fixture): - """Test with PASS, FAIL, and MANUAL statuses mixed.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-pass", - check_id="check_mixed", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-fail", - check_id="check_mixed", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-manual", - check_id="check_mixed", - status=StatusChoices.MANUAL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - result = _aggregate_requirement_statistics_from_database( - str(tenant.id), str(scan.id) - ) - - # Only PASS status is counted as passed - assert result == {"check_mixed": {"passed": 1, "total": 3}} - - -@pytest.mark.django_db -class TestLoadFindingsForChecks: - """Test suite for _load_findings_for_requirement_checks function.""" - - def test_loads_only_requested_checks( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Verify only findings for specified check_ids are loaded.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - providers_fixture[0] - - # Create findings with different check_ids - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-1", - check_id="check_requested", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-2", - check_id="check_not_requested", - status=StatusChoices.FAIL, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.report.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_requested" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_requested"], mock_provider - ) - - # Only one finding should be loaded - assert "check_requested" in result - assert "check_not_requested" not in result - assert len(result["check_requested"]) == 1 - assert mock_transform.call_count == 1 - - def test_empty_check_ids_returns_empty( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Return empty dict when check_ids list is empty.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - mock_provider = MagicMock() - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), [], mock_provider - ) - - assert result == {} - - def test_groups_by_check_id( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Multiple findings for same check are grouped correctly.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create multiple findings for same check - for i in range(3): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-{i}", - check_id="check_group", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.report.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_group" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_group"], mock_provider - ) - - assert len(result["check_group"]) == 3 - - def test_transforms_to_finding_output( - self, tenants_fixture, scans_fixture, providers_fixture - ): - """Findings are transformed using FindingOutput.transform_api_finding.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-transform", - check_id="check_transform", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.report.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_transform" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_transform"], mock_provider - ) - - # Verify transform was called - mock_transform.assert_called_once() - # Verify the transformed output is in the result - assert result["check_transform"][0] == mock_finding_output - - def test_batched_iteration(self, tenants_fixture, scans_fixture, providers_fixture): - """Works correctly with multiple batches of findings.""" - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - # Create enough findings to ensure batching (assuming batch size > 1) - for i in range(10): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-batch-{i}", - check_id="check_batch", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, - ) - - mock_provider = MagicMock() - - with patch( - "tasks.jobs.report.FindingOutput.transform_api_finding" - ) as mock_transform: - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_batch" - mock_transform.return_value = mock_finding_output - - result = _load_findings_for_requirement_checks( - str(tenant.id), str(scan.id), ["check_batch"], mock_provider - ) - - # All 10 findings should be loaded regardless of batching - assert len(result["check_batch"]) == 10 - assert mock_transform.call_count == 10 - - -@pytest.mark.django_db -class TestCalculateRequirementsData: - """Test suite for _calculate_requirements_data_from_statistics function.""" - - def test_requirement_status_all_pass(self): - """Status is PASS when all findings for requirement checks pass.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_1" - mock_requirement.Description = "Test requirement" - mock_requirement.Checks = ["check_1", "check_2"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = { - "check_1": {"passed": 5, "total": 5}, - "check_2": {"passed": 3, "total": 3}, - } - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - assert requirements_list[0]["attributes"]["status"] == StatusChoices.PASS - assert requirements_list[0]["attributes"]["passed_findings"] == 8 - assert requirements_list[0]["attributes"]["total_findings"] == 8 - - def test_requirement_status_some_fail(self): - """Status is FAIL when some findings fail.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_2" - mock_requirement.Description = "Test requirement with failures" - mock_requirement.Checks = ["check_3"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = { - "check_3": {"passed": 2, "total": 5}, - } - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL - assert requirements_list[0]["attributes"]["passed_findings"] == 2 - assert requirements_list[0]["attributes"]["total_findings"] == 5 - - def test_requirement_status_no_findings(self): - """Status is MANUAL when no findings exist for requirement.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_3" - mock_requirement.Description = "Manual requirement" - mock_requirement.Checks = ["check_nonexistent"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = {} - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - assert requirements_list[0]["attributes"]["status"] == StatusChoices.MANUAL - assert requirements_list[0]["attributes"]["passed_findings"] == 0 - assert requirements_list[0]["attributes"]["total_findings"] == 0 - - def test_aggregates_multiple_checks(self): - """Correctly sum stats across multiple checks in requirement.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_requirement = MagicMock() - mock_requirement.Id = "req_4" - mock_requirement.Description = "Multi-check requirement" - mock_requirement.Checks = ["check_a", "check_b", "check_c"] - mock_requirement.Attributes = [MagicMock()] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = { - "check_a": {"passed": 10, "total": 15}, - "check_b": {"passed": 5, "total": 10}, - "check_c": {"passed": 0, "total": 5}, - } - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - assert len(requirements_list) == 1 - # 10 + 5 + 0 = 15 passed - assert requirements_list[0]["attributes"]["passed_findings"] == 15 - # 15 + 10 + 5 = 30 total - assert requirements_list[0]["attributes"]["total_findings"] == 30 - # Not all passed, so should be FAIL - assert requirements_list[0]["attributes"]["status"] == StatusChoices.FAIL - - def test_returns_correct_structure(self): - """Verify tuple structure and dict keys are correct.""" - mock_compliance = MagicMock() - mock_compliance.Framework = "TestFramework" - mock_compliance.Version = "1.0" - - mock_attribute = MagicMock() - mock_requirement = MagicMock() - mock_requirement.Id = "req_5" - mock_requirement.Description = "Structure test" - mock_requirement.Checks = ["check_struct"] - mock_requirement.Attributes = [mock_attribute] - - mock_compliance.Requirements = [mock_requirement] - - requirement_statistics = {"check_struct": {"passed": 1, "total": 1}} - - attributes_by_id, requirements_list = ( - _calculate_requirements_data_from_statistics( - mock_compliance, requirement_statistics - ) - ) - - # Verify attributes_by_id structure - assert "req_5" in attributes_by_id - assert "attributes" in attributes_by_id["req_5"] - assert "description" in attributes_by_id["req_5"] - assert "req_attributes" in attributes_by_id["req_5"]["attributes"] - assert "checks" in attributes_by_id["req_5"]["attributes"] - - # Verify requirements_list structure - assert len(requirements_list) == 1 - req = requirements_list[0] - assert "id" in req - assert "attributes" in req - assert "framework" in req["attributes"] - assert "version" in req["attributes"] - assert "status" in req["attributes"] - assert "description" in req["attributes"] - assert "passed_findings" in req["attributes"] - assert "total_findings" in req["attributes"] - - -@pytest.mark.django_db -class TestGenerateThreatscoreReportFunction: - def setup_method(self): - self.scan_id = str(uuid.uuid4()) - self.provider_id = str(uuid.uuid4()) - self.tenant_id = str(uuid.uuid4()) - self.compliance_id = "prowler_threatscore_aws" - self.output_path = "/tmp/test_threatscore_report.pdf" - - @patch("tasks.jobs.report.initialize_prowler_provider") - @patch("tasks.jobs.report.Provider.objects.get") - @patch("tasks.jobs.report.Compliance.get_bulk") - @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") - @patch("tasks.jobs.report._calculate_requirements_data_from_statistics") - @patch("tasks.jobs.report._load_findings_for_requirement_checks") - @patch("tasks.jobs.report.SimpleDocTemplate") - @patch("tasks.jobs.report.Image") - @patch("tasks.jobs.report.Spacer") - @patch("tasks.jobs.report.Paragraph") - @patch("tasks.jobs.report.PageBreak") - @patch("tasks.jobs.report.Table") - @patch("tasks.jobs.report.TableStyle") - @patch("tasks.jobs.report.plt.subplots") - @patch("tasks.jobs.report.plt.savefig") - @patch("tasks.jobs.report.io.BytesIO") - def test_generate_threatscore_report_success( - self, - mock_bytesio, - mock_savefig, - mock_subplots, - mock_table_style, - mock_table, - mock_page_break, - mock_paragraph, - mock_spacer, - mock_image, - mock_doc_template, - mock_load_findings, - mock_calculate_requirements, - mock_aggregate_statistics, - mock_compliance_get_bulk, - mock_provider_get, - mock_initialize_provider, - ): - """Test the updated generate_threatscore_report using new memory-efficient architecture.""" - mock_provider = MagicMock() - mock_provider.provider = "aws" - mock_provider_get.return_value = mock_provider - - prowler_provider = MagicMock() - mock_initialize_provider.return_value = prowler_provider - - # Mock compliance object with requirements - mock_compliance_obj = MagicMock() - mock_compliance_obj.Framework = "ProwlerThreatScore" - mock_compliance_obj.Version = "1.0" - mock_compliance_obj.Description = "Test Description" - - # Configure requirement with properly set numeric attributes for chart generation - mock_requirement = MagicMock() - mock_requirement.Id = "req_1" - mock_requirement.Description = "Test requirement" - mock_requirement.Checks = ["check_1"] - - # Create a properly configured attribute mock with numeric values - mock_requirement_attr = MagicMock() - mock_requirement_attr.Section = "1. IAM" - mock_requirement_attr.SubSection = "1.1 Identity" - mock_requirement_attr.Title = "Test Requirement Title" - mock_requirement_attr.LevelOfRisk = 3 - mock_requirement_attr.Weight = 100 - mock_requirement_attr.AttributeDescription = "Test requirement description" - mock_requirement_attr.AdditionalInformation = "Additional test information" - - mock_requirement.Attributes = [mock_requirement_attr] - mock_compliance_obj.Requirements = [mock_requirement] - - mock_compliance_get_bulk.return_value = { - self.compliance_id: mock_compliance_obj - } - - # Mock the aggregated statistics from database - mock_aggregate_statistics.return_value = {"check_1": {"passed": 5, "total": 10}} - - # Mock the calculated requirements data with properly configured attributes - mock_attributes_by_id = { - "req_1": { - "attributes": { - "req_attributes": [mock_requirement_attr], - "checks": ["check_1"], - }, - "description": "Test requirement", - } - } - mock_requirements_list = [ - { - "id": "req_1", - "attributes": { - "framework": "ProwlerThreatScore", - "version": "1.0", - "status": StatusChoices.FAIL, - "description": "Test requirement", - "passed_findings": 5, - "total_findings": 10, - }, - } - ] - mock_calculate_requirements.return_value = ( - mock_attributes_by_id, - mock_requirements_list, - ) - - # Mock the on-demand loaded findings - mock_finding_output = MagicMock() - mock_finding_output.check_id = "check_1" - mock_finding_output.status = "FAIL" - mock_finding_output.metadata = MagicMock() - mock_finding_output.metadata.CheckTitle = "Test Check" - mock_finding_output.metadata.Severity = "HIGH" - mock_finding_output.resource_name = "test-resource" - mock_finding_output.region = "us-east-1" - - mock_load_findings.return_value = {"check_1": [mock_finding_output]} - - # Mock PDF generation components - mock_doc = MagicMock() - mock_doc_template.return_value = mock_doc - - mock_fig, mock_ax = MagicMock(), MagicMock() - mock_subplots.return_value = (mock_fig, mock_ax) - mock_buffer = MagicMock() - mock_bytesio.return_value = mock_buffer - - mock_image.return_value = MagicMock() - mock_spacer.return_value = MagicMock() - mock_paragraph.return_value = MagicMock() - mock_page_break.return_value = MagicMock() - mock_table.return_value = MagicMock() - mock_table_style.return_value = MagicMock() - - # Execute the function - generate_threatscore_report( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id=self.compliance_id, - output_path=self.output_path, - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - - # Verify the new workflow was followed - mock_provider_get.assert_called_once_with(id=self.provider_id) - mock_initialize_provider.assert_called_once_with(mock_provider) - mock_compliance_get_bulk.assert_called_once_with("aws") - - # Verify the new functions were called in correct order with correct parameters - mock_aggregate_statistics.assert_called_once_with(self.tenant_id, self.scan_id) - mock_calculate_requirements.assert_called_once_with( - mock_compliance_obj, {"check_1": {"passed": 5, "total": 10}} - ) - mock_load_findings.assert_called_once_with( - self.tenant_id, self.scan_id, ["check_1"], prowler_provider - ) - - # Verify PDF was built - mock_doc_template.assert_called_once() - mock_doc.build.assert_called_once() - - @patch("tasks.jobs.report.initialize_prowler_provider") - @patch("tasks.jobs.report.Provider.objects.get") - @patch("tasks.jobs.report.Compliance.get_bulk") - @patch("tasks.jobs.report.Finding.all_objects.filter") - def test_generate_threatscore_report_exception_handling( - self, - mock_finding_filter, - mock_compliance_get_bulk, - mock_provider_get, - mock_initialize_provider, - ): - mock_provider_get.side_effect = Exception("Provider not found") - - with pytest.raises(Exception, match="Provider not found"): - generate_threatscore_report( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - compliance_id=self.compliance_id, - output_path=self.output_path, - provider_id=self.provider_id, - only_failed=True, - min_risk_level=4, - ) - - -@pytest.mark.django_db -class TestGenerateThreatscoreReportTask: - def setup_method(self): - self.scan_id = str(uuid.uuid4()) - self.provider_id = str(uuid.uuid4()) - self.tenant_id = str(uuid.uuid4()) - - @patch("tasks.tasks.generate_threatscore_report_job") - def test_generate_threatscore_report_task_calls_job(self, mock_generate_job): - mock_generate_job.return_value = {"upload": True} - - result = generate_threatscore_report_task( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - assert result == {"upload": True} - mock_generate_job.assert_called_once_with( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) - - @patch("tasks.tasks.generate_threatscore_report_job") - def test_generate_threatscore_report_task_handles_job_exception( - self, mock_generate_job - ): - mock_generate_job.side_effect = Exception("Job failed") - - with pytest.raises(Exception, match="Job failed"): - generate_threatscore_report_task( - tenant_id=self.tenant_id, - scan_id=self.scan_id, - provider_id=self.provider_id, - ) diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py new file mode 100644 index 0000000000..530e0af472 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports.py @@ -0,0 +1,410 @@ +import uuid +from unittest.mock import Mock, patch + +import matplotlib +import pytest +from reportlab.lib import colors +from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report +from tasks.jobs.reports import ( + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COLOR_BLUE, + COLOR_ENS_ALTO, + COLOR_HIGH_RISK, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_NIS2_PRIMARY, + COLOR_SAFE, + create_pdf_styles, + get_chart_color_for_percentage, + get_color_for_compliance, + get_color_for_risk_level, + get_color_for_weight, +) +from tasks.jobs.threatscore_utils import ( + _aggregate_requirement_statistics_from_database, + _load_findings_for_requirement_checks, +) + +from api.models import Finding, StatusChoices +from prowler.lib.check.models import Severity + +matplotlib.use("Agg") # Use non-interactive backend for tests + + +@pytest.mark.django_db +class TestAggregateRequirementStatistics: + """Test suite for _aggregate_requirement_statistics_from_database function.""" + + def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture): + """Verify correct pass/total counts per check are aggregated from database.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_1", + status=StatusChoices.PASS, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_1", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-3", + check_id="check_2", + status=StatusChoices.PASS, + severity=Severity.medium, + impact=Severity.medium, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert "check_1" in result + assert result["check_1"]["passed"] == 1 + assert result["check_1"]["total"] == 2 + + assert "check_2" in result + assert result["check_2"]["passed"] == 1 + assert result["check_2"]["total"] == 1 + + def test_handles_empty_scan(self, tenants_fixture, scans_fixture): + """Verify empty result is returned for scan with no findings.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result == {} + + def test_only_failed_findings(self, tenants_fixture, scans_fixture): + """Verify correct counts when all findings are FAIL.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_1", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_1", + status=StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result["check_1"]["passed"] == 0 + assert result["check_1"]["total"] == 2 + + def test_multiple_findings_same_check(self, tenants_fixture, scans_fixture): + """Verify multiple findings for same check are correctly aggregated.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + for i in range(5): + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=f"finding-{i}", + check_id="check_1", + status=StatusChoices.PASS if i % 2 == 0 else StatusChoices.FAIL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result["check_1"]["passed"] == 3 + assert result["check_1"]["total"] == 5 + + def test_mixed_statuses(self, tenants_fixture, scans_fixture): + """Verify MANUAL status is counted in total but not passed.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-1", + check_id="check_1", + status=StatusChoices.PASS, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid="finding-2", + check_id="check_1", + status=StatusChoices.MANUAL, + severity=Severity.high, + impact=Severity.high, + check_metadata={}, + raw_result={}, + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + # MANUAL findings are excluded from the aggregation query + # since it only counts PASS and FAIL statuses + assert result["check_1"]["passed"] == 1 + assert result["check_1"]["total"] == 1 + + +class TestColorHelperFunctions: + """Test suite for color helper functions.""" + + def test_get_color_for_risk_level_high(self): + """Test high risk level returns correct color.""" + result = get_color_for_risk_level(5) + assert result == COLOR_HIGH_RISK + + def test_get_color_for_risk_level_medium_high(self): + """Test risk level 4 returns high risk color.""" + result = get_color_for_risk_level(4) + assert result == COLOR_HIGH_RISK # >= 4 is high risk + + def test_get_color_for_risk_level_medium(self): + """Test risk level 3 returns medium risk color.""" + result = get_color_for_risk_level(3) + assert result == COLOR_MEDIUM_RISK # >= 3 is medium risk + + def test_get_color_for_risk_level_low(self): + """Test low risk level returns safe color.""" + result = get_color_for_risk_level(1) + assert result == COLOR_SAFE # < 2 is safe + + def test_get_color_for_weight_high(self): + """Test high weight returns correct color.""" + result = get_color_for_weight(150) + assert result == COLOR_HIGH_RISK # > 100 is high risk + + def test_get_color_for_weight_medium(self): + """Test medium weight returns low risk color.""" + result = get_color_for_weight(100) + assert result == COLOR_LOW_RISK # 51-100 is low risk + + def test_get_color_for_weight_low(self): + """Test low weight returns safe color.""" + result = get_color_for_weight(50) + assert result == COLOR_SAFE # <= 50 is safe + + def test_get_color_for_compliance_high(self): + """Test high compliance returns green color.""" + result = get_color_for_compliance(85) + assert result == COLOR_SAFE + + def test_get_color_for_compliance_medium(self): + """Test medium compliance returns yellow color.""" + result = get_color_for_compliance(70) + assert result == COLOR_LOW_RISK + + def test_get_color_for_compliance_low(self): + """Test low compliance returns red color.""" + result = get_color_for_compliance(50) + assert result == COLOR_HIGH_RISK + + def test_get_chart_color_for_percentage_excellent(self): + """Test excellent percentage returns correct chart color.""" + result = get_chart_color_for_percentage(90) + assert result == CHART_COLOR_GREEN_1 + + def test_get_chart_color_for_percentage_good(self): + """Test good percentage returns correct chart color.""" + result = get_chart_color_for_percentage(70) + assert result == CHART_COLOR_GREEN_2 + + def test_get_chart_color_for_percentage_fair(self): + """Test fair percentage returns correct chart color.""" + result = get_chart_color_for_percentage(50) + assert result == CHART_COLOR_YELLOW + + def test_get_chart_color_for_percentage_poor(self): + """Test poor percentage returns correct chart color.""" + result = get_chart_color_for_percentage(30) + assert result == CHART_COLOR_ORANGE + + def test_get_chart_color_for_percentage_critical(self): + """Test critical percentage returns correct chart color.""" + result = get_chart_color_for_percentage(10) + assert result == CHART_COLOR_RED + + +class TestPDFStylesCreation: + """Test suite for PDF styles creation.""" + + def test_create_pdf_styles_returns_dict(self): + """Test that create_pdf_styles returns a dictionary.""" + result = create_pdf_styles() + assert isinstance(result, dict) + + def test_create_pdf_styles_caches_result(self): + """Test that create_pdf_styles caches the result.""" + result1 = create_pdf_styles() + result2 = create_pdf_styles() + assert result1 is result2 + + def test_pdf_styles_have_correct_keys(self): + """Test that PDF styles dictionary has expected keys.""" + result = create_pdf_styles() + expected_keys = ["title", "h1", "h2", "h3", "normal", "normal_center"] + for key in expected_keys: + assert key in result + + +@pytest.mark.django_db +class TestLoadFindingsForChecks: + """Test suite for _load_findings_for_requirement_checks function.""" + + def test_empty_check_ids_returns_empty(self, tenants_fixture, providers_fixture): + """Test that empty check_ids list returns empty dict.""" + tenant = tenants_fixture[0] + + mock_prowler_provider = Mock() + mock_prowler_provider.identity.account = "test-account" + + result = _load_findings_for_requirement_checks( + str(tenant.id), str(uuid.uuid4()), [], mock_prowler_provider + ) + + assert result == {} + + +@pytest.mark.django_db +class TestGenerateThreatscoreReportFunction: + """Test suite for generate_threatscore_report function.""" + + @patch("tasks.jobs.reports.base.initialize_prowler_provider") + def test_generate_threatscore_report_exception_handling( + self, + mock_initialize_provider, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test that exceptions during report generation are properly handled.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + mock_initialize_provider.side_effect = Exception("Test exception") + + with pytest.raises(Exception) as exc_info: + generate_threatscore_report( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + compliance_id="prowler_threatscore_aws", + output_path="/tmp/test_report.pdf", + provider_id=str(provider.id), + ) + + assert "Test exception" in str(exc_info.value) + + +@pytest.mark.django_db +class TestGenerateComplianceReportsOptimized: + """Test suite for generate_compliance_reports function.""" + + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_threatscore_report") + @patch("tasks.jobs.report.generate_ens_report") + @patch("tasks.jobs.report.generate_nis2_report") + def test_no_findings_returns_early_for_both_reports( + self, + mock_nis2, + mock_ens, + mock_threatscore, + mock_upload, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test that function returns early when scan has no findings.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + result = generate_compliance_reports( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + provider_id=str(provider.id), + generate_threatscore=True, + generate_ens=True, + generate_nis2=True, + ) + + assert result["threatscore"]["upload"] is False + assert result["ens"]["upload"] is False + assert result["nis2"]["upload"] is False + + mock_threatscore.assert_not_called() + mock_ens.assert_not_called() + mock_nis2.assert_not_called() + + +class TestOptimizationImprovements: + """Test suite for optimization-related functionality.""" + + def test_chart_color_constants_are_strings(self): + """Verify chart color constants are valid hex color strings.""" + assert CHART_COLOR_GREEN_1.startswith("#") + assert CHART_COLOR_GREEN_2.startswith("#") + assert CHART_COLOR_YELLOW.startswith("#") + assert CHART_COLOR_ORANGE.startswith("#") + assert CHART_COLOR_RED.startswith("#") + + def test_color_constants_are_color_objects(self): + """Verify color constants are Color objects.""" + assert isinstance(COLOR_BLUE, colors.Color) + assert isinstance(COLOR_HIGH_RISK, colors.Color) + assert isinstance(COLOR_SAFE, colors.Color) + assert isinstance(COLOR_ENS_ALTO, colors.Color) + assert isinstance(COLOR_NIS2_PRIMARY, colors.Color) diff --git a/api/src/backend/tasks/tests/test_reports_base.py b/api/src/backend/tasks/tests/test_reports_base.py new file mode 100644 index 0000000000..d2fda4f830 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_base.py @@ -0,0 +1,1346 @@ +import io + +import pytest +from reportlab.lib.units import inch +from reportlab.platypus import Image, LongTable, Paragraph, Spacer, Table +from tasks.jobs.reports import ( # Configuration; Colors; Components; Charts; Base + CHART_COLOR_GREEN_1, + CHART_COLOR_GREEN_2, + CHART_COLOR_ORANGE, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COLOR_BLUE, + COLOR_DARK_GRAY, + COLOR_HIGH_RISK, + COLOR_LOW_RISK, + COLOR_MEDIUM_RISK, + COLOR_SAFE, + FRAMEWORK_REGISTRY, + BaseComplianceReportGenerator, + ColumnConfig, + ComplianceData, + FrameworkConfig, + RequirementData, + create_badge, + create_data_table, + create_findings_table, + create_horizontal_bar_chart, + create_info_table, + create_multi_badge_row, + create_pdf_styles, + create_pie_chart, + create_radar_chart, + create_risk_component, + create_section_header, + create_stacked_bar_chart, + create_status_badge, + create_summary_table, + create_vertical_bar_chart, + get_chart_color_for_percentage, + get_color_for_compliance, + get_color_for_risk_level, + get_color_for_weight, + get_framework_config, + get_status_color, +) + +# ============================================================================= +# Configuration Tests +# ============================================================================= + + +class TestFrameworkConfig: + """Tests for FrameworkConfig dataclass.""" + + def test_framework_config_creation(self): + """Test creating a FrameworkConfig with required fields.""" + config = FrameworkConfig( + name="test_framework", + display_name="Test Framework", + ) + + assert config.name == "test_framework" + assert config.display_name == "Test Framework" + assert config.logo_filename is None + assert config.language == "en" + assert config.has_risk_levels is False + + def test_framework_config_with_all_fields(self): + """Test creating a FrameworkConfig with all fields.""" + config = FrameworkConfig( + name="custom", + display_name="Custom Framework", + logo_filename="custom_logo.png", + primary_color=COLOR_BLUE, + secondary_color=COLOR_SAFE, + attribute_fields=["Section", "SubSection"], + sections=["1. Security", "2. Compliance"], + language="es", + has_risk_levels=True, + has_dimensions=True, + has_niveles=True, + has_weight=True, + ) + + assert config.name == "custom" + assert config.logo_filename == "custom_logo.png" + assert config.language == "es" + assert config.has_risk_levels is True + assert config.has_dimensions is True + assert len(config.attribute_fields) == 2 + assert len(config.sections) == 2 + + +class TestFrameworkRegistry: + """Tests for the framework registry.""" + + def test_registry_contains_threatscore(self): + """Test that ThreatScore is in the registry.""" + assert "prowler_threatscore" in FRAMEWORK_REGISTRY + config = FRAMEWORK_REGISTRY["prowler_threatscore"] + assert config.has_risk_levels is True + assert config.has_weight is True + + def test_registry_contains_ens(self): + """Test that ENS is in the registry.""" + assert "ens" in FRAMEWORK_REGISTRY + config = FRAMEWORK_REGISTRY["ens"] + assert config.language == "es" + assert config.has_niveles is True + assert config.has_dimensions is True + + def test_registry_contains_nis2(self): + """Test that NIS2 is in the registry.""" + assert "nis2" in FRAMEWORK_REGISTRY + config = FRAMEWORK_REGISTRY["nis2"] + assert config.language == "en" + + def test_get_framework_config_threatscore(self): + """Test getting ThreatScore config.""" + config = get_framework_config("prowler_threatscore_aws") + assert config is not None + assert config.name == "prowler_threatscore" + + def test_get_framework_config_ens(self): + """Test getting ENS config.""" + config = get_framework_config("ens_rd2022_aws") + assert config is not None + assert config.name == "ens" + + def test_get_framework_config_nis2(self): + """Test getting NIS2 config.""" + config = get_framework_config("nis2_aws") + assert config is not None + assert config.name == "nis2" + + def test_get_framework_config_unknown(self): + """Test getting unknown framework returns None.""" + config = get_framework_config("unknown_framework") + assert config is None + + +# ============================================================================= +# Color Helper Tests +# ============================================================================= + + +class TestColorHelpers: + """Tests for color helper functions.""" + + def test_get_color_for_risk_level_high(self): + """Test high risk level returns red.""" + assert get_color_for_risk_level(5) == COLOR_HIGH_RISK + assert get_color_for_risk_level(4) == COLOR_HIGH_RISK + + def test_get_color_for_risk_level_very_high(self): + """Test very high risk level (>5) still returns high risk color.""" + assert get_color_for_risk_level(10) == COLOR_HIGH_RISK + assert get_color_for_risk_level(100) == COLOR_HIGH_RISK + + def test_get_color_for_risk_level_medium(self): + """Test medium risk level returns orange.""" + assert get_color_for_risk_level(3) == COLOR_MEDIUM_RISK + + def test_get_color_for_risk_level_low(self): + """Test low risk level returns yellow.""" + assert get_color_for_risk_level(2) == COLOR_LOW_RISK + + def test_get_color_for_risk_level_safe(self): + """Test safe risk level returns green.""" + assert get_color_for_risk_level(1) == COLOR_SAFE + assert get_color_for_risk_level(0) == COLOR_SAFE + + def test_get_color_for_risk_level_negative(self): + """Test negative risk level returns safe color.""" + assert get_color_for_risk_level(-1) == COLOR_SAFE + + def test_get_color_for_weight_high(self): + """Test high weight returns red.""" + assert get_color_for_weight(150) == COLOR_HIGH_RISK + assert get_color_for_weight(101) == COLOR_HIGH_RISK + + def test_get_color_for_weight_medium(self): + """Test medium weight returns yellow.""" + assert get_color_for_weight(100) == COLOR_LOW_RISK + assert get_color_for_weight(51) == COLOR_LOW_RISK + + def test_get_color_for_weight_low(self): + """Test low weight returns green.""" + assert get_color_for_weight(50) == COLOR_SAFE + assert get_color_for_weight(0) == COLOR_SAFE + + def test_get_color_for_compliance_high(self): + """Test high compliance returns green.""" + assert get_color_for_compliance(100) == COLOR_SAFE + assert get_color_for_compliance(80) == COLOR_SAFE + + def test_get_color_for_compliance_medium(self): + """Test medium compliance returns yellow.""" + assert get_color_for_compliance(79) == COLOR_LOW_RISK + assert get_color_for_compliance(60) == COLOR_LOW_RISK + + def test_get_color_for_compliance_low(self): + """Test low compliance returns red.""" + assert get_color_for_compliance(59) == COLOR_HIGH_RISK + assert get_color_for_compliance(0) == COLOR_HIGH_RISK + + def test_get_status_color_pass(self): + """Test PASS status returns green.""" + assert get_status_color("PASS") == COLOR_SAFE + assert get_status_color("pass") == COLOR_SAFE + + def test_get_status_color_fail(self): + """Test FAIL status returns red.""" + assert get_status_color("FAIL") == COLOR_HIGH_RISK + assert get_status_color("fail") == COLOR_HIGH_RISK + + def test_get_status_color_manual(self): + """Test MANUAL status returns gray.""" + assert get_status_color("MANUAL") == COLOR_DARK_GRAY + + +class TestChartColorHelpers: + """Tests for chart color functions.""" + + def test_chart_color_for_high_percentage(self): + """Test high percentage returns green.""" + assert get_chart_color_for_percentage(100) == CHART_COLOR_GREEN_1 + assert get_chart_color_for_percentage(80) == CHART_COLOR_GREEN_1 + + def test_chart_color_for_medium_high_percentage(self): + """Test medium-high percentage returns light green.""" + assert get_chart_color_for_percentage(79) == CHART_COLOR_GREEN_2 + assert get_chart_color_for_percentage(60) == CHART_COLOR_GREEN_2 + + def test_chart_color_for_medium_percentage(self): + """Test medium percentage returns yellow.""" + assert get_chart_color_for_percentage(59) == CHART_COLOR_YELLOW + assert get_chart_color_for_percentage(40) == CHART_COLOR_YELLOW + + def test_chart_color_for_medium_low_percentage(self): + """Test medium-low percentage returns orange.""" + assert get_chart_color_for_percentage(39) == CHART_COLOR_ORANGE + assert get_chart_color_for_percentage(20) == CHART_COLOR_ORANGE + + def test_chart_color_for_low_percentage(self): + """Test low percentage returns red.""" + assert get_chart_color_for_percentage(19) == CHART_COLOR_RED + assert get_chart_color_for_percentage(0) == CHART_COLOR_RED + + def test_chart_color_boundary_values(self): + """Test chart color at exact boundary values.""" + # Exact boundaries + assert get_chart_color_for_percentage(80) == CHART_COLOR_GREEN_1 + assert get_chart_color_for_percentage(60) == CHART_COLOR_GREEN_2 + assert get_chart_color_for_percentage(40) == CHART_COLOR_YELLOW + assert get_chart_color_for_percentage(20) == CHART_COLOR_ORANGE + + +# ============================================================================= +# Component Tests +# ============================================================================= + + +class TestBadgeComponents: + """Tests for badge component functions.""" + + def test_create_badge_returns_table(self): + """Test create_badge returns a Table object.""" + badge = create_badge("Test", COLOR_BLUE) + assert isinstance(badge, Table) + + def test_create_badge_with_custom_width(self): + """Test create_badge with custom width.""" + badge = create_badge("Test", COLOR_BLUE, width=2 * inch) + assert badge is not None + + def test_create_status_badge_pass(self): + """Test status badge for PASS.""" + badge = create_status_badge("PASS") + assert isinstance(badge, Table) + + def test_create_status_badge_fail(self): + """Test status badge for FAIL.""" + badge = create_status_badge("FAIL") + assert badge is not None + + def test_create_multi_badge_row_with_badges(self): + """Test multi-badge row with data.""" + badges = [ + ("A", COLOR_BLUE), + ("B", COLOR_SAFE), + ] + table = create_multi_badge_row(badges) + assert isinstance(table, Table) + + def test_create_multi_badge_row_empty(self): + """Test multi-badge row with empty list.""" + table = create_multi_badge_row([]) + assert table is not None + + +class TestRiskComponent: + """Tests for risk component function.""" + + def test_create_risk_component_returns_table(self): + """Test risk component returns a Table.""" + component = create_risk_component(risk_level=4, weight=100, score=50) + assert isinstance(component, Table) + + def test_create_risk_component_high_risk(self): + """Test risk component with high risk level.""" + component = create_risk_component(risk_level=5, weight=150, score=100) + assert component is not None + + def test_create_risk_component_low_risk(self): + """Test risk component with low risk level.""" + component = create_risk_component(risk_level=1, weight=10, score=10) + assert component is not None + + +class TestTableComponents: + """Tests for table component functions.""" + + def test_create_info_table(self): + """Test info table creation.""" + rows = [ + ("Label 1:", "Value 1"), + ("Label 2:", "Value 2"), + ] + table = create_info_table(rows) + assert isinstance(table, Table) + + def test_create_info_table_with_custom_widths(self): + """Test info table with custom column widths.""" + rows = [("Test:", "Value")] + table = create_info_table(rows, label_width=3 * inch, value_width=3 * inch) + assert table is not None + + def test_create_data_table(self): + """Test data table creation.""" + data = [ + {"name": "Item 1", "value": "100"}, + {"name": "Item 2", "value": "200"}, + ] + columns = [ + ColumnConfig("Name", 2 * inch, "name"), + ColumnConfig("Value", 1 * inch, "value"), + ] + table = create_data_table(data, columns) + assert isinstance(table, Table) + + def test_create_data_table_with_callable_field(self): + """Test data table with callable field.""" + data = [{"raw_value": 100}] + columns = [ + ColumnConfig("Formatted", 2 * inch, lambda x: f"${x['raw_value']}"), + ] + table = create_data_table(data, columns) + assert table is not None + + def test_create_summary_table(self): + """Test summary table creation.""" + table = create_summary_table( + label="Score:", + value="85%", + value_color=COLOR_SAFE, + ) + assert isinstance(table, Table) + + def test_create_summary_table_with_custom_widths(self): + """Test summary table with custom widths.""" + table = create_summary_table( + label="ThreatScore:", + value="92.5%", + value_color=COLOR_SAFE, + label_width=3 * inch, + value_width=2.5 * inch, + ) + assert isinstance(table, Table) + + +class TestFindingsTable: + """Tests for findings table component.""" + + def test_create_findings_table_with_dicts(self): + """Test findings table creation with dict data.""" + findings = [ + { + "title": "Finding 1", + "resource_name": "resource-1", + "severity": "HIGH", + "status": "FAIL", + "region": "us-east-1", + }, + { + "title": "Finding 2", + "resource_name": "resource-2", + "severity": "LOW", + "status": "PASS", + "region": "eu-west-1", + }, + ] + table = create_findings_table(findings) + assert isinstance(table, Table) + + def test_create_findings_table_with_custom_columns(self): + """Test findings table with custom column configuration.""" + findings = [{"name": "Test", "value": "100"}] + columns = [ + ColumnConfig("Name", 2 * inch, "name"), + ColumnConfig("Value", 1 * inch, "value"), + ] + table = create_findings_table(findings, columns=columns) + assert table is not None + + def test_create_findings_table_empty(self): + """Test findings table with empty list.""" + table = create_findings_table([]) + assert table is not None + + +class TestSectionHeader: + """Tests for section header component.""" + + def test_create_section_header_with_spacer(self): + """Test section header with spacer.""" + styles = create_pdf_styles() + elements = create_section_header("Test Header", styles["h1"]) + + assert len(elements) == 2 + assert isinstance(elements[0], Paragraph) + assert isinstance(elements[1], Spacer) + + def test_create_section_header_without_spacer(self): + """Test section header without spacer.""" + styles = create_pdf_styles() + elements = create_section_header("Test Header", styles["h1"], add_spacer=False) + + assert len(elements) == 1 + assert isinstance(elements[0], Paragraph) + + def test_create_section_header_custom_spacer_height(self): + """Test section header with custom spacer height.""" + styles = create_pdf_styles() + elements = create_section_header("Test Header", styles["h2"], spacer_height=0.5) + + assert len(elements) == 2 + + +# ============================================================================= +# Chart Tests +# ============================================================================= + + +class TestChartCreation: + """Tests for chart creation functions.""" + + def test_create_vertical_bar_chart(self): + """Test vertical bar chart creation.""" + buffer = create_vertical_bar_chart( + labels=["A", "B", "C"], + values=[80, 60, 40], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() # Not empty + + def test_create_vertical_bar_chart_with_options(self): + """Test vertical bar chart with custom options.""" + buffer = create_vertical_bar_chart( + labels=["Section 1", "Section 2"], + values=[90, 70], + ylabel="Compliance", + title="Test Chart", + figsize=(8, 6), + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_horizontal_bar_chart(self): + """Test horizontal bar chart creation.""" + buffer = create_horizontal_bar_chart( + labels=["Category 1", "Category 2", "Category 3"], + values=[85, 65, 45], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_horizontal_bar_chart_with_options(self): + """Test horizontal bar chart with custom options.""" + buffer = create_horizontal_bar_chart( + labels=["A", "B"], + values=[100, 50], + xlabel="Percentage", + title="Custom Chart", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_radar_chart(self): + """Test radar chart creation.""" + buffer = create_radar_chart( + labels=["Dim 1", "Dim 2", "Dim 3", "Dim 4", "Dim 5"], + values=[80, 70, 60, 90, 75], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_radar_chart_with_options(self): + """Test radar chart with custom options.""" + buffer = create_radar_chart( + labels=["A", "B", "C"], + values=[50, 60, 70], + color="#FF0000", + fill_alpha=0.5, + title="Custom Radar", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_pie_chart(self): + """Test pie chart creation.""" + buffer = create_pie_chart( + labels=["Pass", "Fail"], + values=[80, 20], + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_pie_chart_with_options(self): + """Test pie chart with custom options.""" + buffer = create_pie_chart( + labels=["Pass", "Fail", "Manual"], + values=[60, 30, 10], + colors=["#4CAF50", "#F44336", "#9E9E9E"], + title="Status Distribution", + autopct="%1.0f%%", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_stacked_bar_chart(self): + """Test stacked bar chart creation.""" + buffer = create_stacked_bar_chart( + labels=["Section 1", "Section 2", "Section 3"], + data_series={ + "Pass": [8, 6, 4], + "Fail": [2, 4, 6], + }, + ) + assert isinstance(buffer, io.BytesIO) + assert buffer.getvalue() + + def test_create_stacked_bar_chart_with_options(self): + """Test stacked bar chart with custom options.""" + buffer = create_stacked_bar_chart( + labels=["A", "B"], + data_series={ + "Pass": [10, 5], + "Fail": [2, 3], + "Manual": [1, 2], + }, + colors={ + "Pass": "#4CAF50", + "Fail": "#F44336", + "Manual": "#9E9E9E", + }, + xlabel="Categories", + ylabel="Requirements", + title="Requirements by Status", + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_stacked_bar_chart_without_legend(self): + """Test stacked bar chart without legend.""" + buffer = create_stacked_bar_chart( + labels=["X", "Y"], + data_series={"A": [1, 2]}, + show_legend=False, + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_vertical_bar_chart_without_labels(self): + """Test vertical bar chart without value labels.""" + buffer = create_vertical_bar_chart( + labels=["A", "B"], + values=[50, 75], + show_labels=False, + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_vertical_bar_chart_with_explicit_colors(self): + """Test vertical bar chart with explicit color list.""" + buffer = create_vertical_bar_chart( + labels=["Pass", "Fail"], + values=[80, 20], + colors=["#4CAF50", "#F44336"], + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_horizontal_bar_chart_auto_figsize(self): + """Test horizontal bar chart auto-calculates figure size for many items.""" + labels = [f"Item {i}" for i in range(20)] + values = [50 + i * 2 for i in range(20)] + buffer = create_horizontal_bar_chart( + labels=labels, + values=values, + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_horizontal_bar_chart_with_explicit_colors(self): + """Test horizontal bar chart with explicit colors.""" + buffer = create_horizontal_bar_chart( + labels=["A", "B", "C"], + values=[80, 60, 40], + colors=["#4CAF50", "#FFEB3B", "#F44336"], + ) + assert isinstance(buffer, io.BytesIO) + + def test_create_radar_chart_with_custom_ticks(self): + """Test radar chart with custom y-axis ticks.""" + buffer = create_radar_chart( + labels=["A", "B", "C", "D"], + values=[25, 50, 75, 100], + y_ticks=[0, 25, 50, 75, 100], + ) + assert isinstance(buffer, io.BytesIO) + + +# ============================================================================= +# Data Class Tests +# ============================================================================= + + +class TestDataClasses: + """Tests for data classes.""" + + def test_requirement_data_creation(self): + """Test RequirementData creation.""" + req = RequirementData( + id="REQ-001", + description="Test requirement", + status="PASS", + passed_findings=10, + total_findings=10, + ) + assert req.id == "REQ-001" + assert req.status == "PASS" + assert req.passed_findings == 10 + + def test_requirement_data_with_failed_findings(self): + """Test RequirementData with failed findings.""" + req = RequirementData( + id="REQ-002", + description="Failed requirement", + status="FAIL", + passed_findings=3, + failed_findings=7, + total_findings=10, + ) + assert req.failed_findings == 7 + assert req.total_findings == 10 + + def test_requirement_data_defaults(self): + """Test RequirementData default values.""" + req = RequirementData( + id="REQ-003", + description="Minimal requirement", + status="MANUAL", + ) + assert req.passed_findings == 0 + assert req.failed_findings == 0 + assert req.total_findings == 0 + + def test_compliance_data_creation(self): + """Test ComplianceData creation.""" + data = ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="test_compliance", + framework="Test", + name="Test Compliance", + version="1.0", + description="Test description", + ) + assert data.tenant_id == "tenant-123" + assert data.framework == "Test" + assert data.requirements == [] + + def test_compliance_data_with_requirements(self): + """Test ComplianceData with requirements list.""" + reqs = [ + RequirementData(id="R1", description="Req 1", status="PASS"), + RequirementData(id="R2", description="Req 2", status="FAIL"), + ] + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + requirements=reqs, + ) + assert len(data.requirements) == 2 + assert data.requirements[0].id == "R1" + + def test_compliance_data_with_attributes(self): + """Test ComplianceData with attributes dictionary.""" + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + attributes_by_requirement_id={ + "R1": {"attributes": {"key": "value"}}, + }, + ) + assert "R1" in data.attributes_by_requirement_id + assert data.attributes_by_requirement_id["R1"]["attributes"]["key"] == "value" + + +# ============================================================================= +# PDF Styles Tests +# ============================================================================= + + +class TestPDFStyles: + """Tests for PDF styles.""" + + def test_create_pdf_styles_returns_dict(self): + """Test that create_pdf_styles returns a dictionary.""" + styles = create_pdf_styles() + assert isinstance(styles, dict) + + def test_create_pdf_styles_has_required_keys(self): + """Test that styles dict has all required keys.""" + styles = create_pdf_styles() + required_keys = ["title", "h1", "h2", "h3", "normal", "normal_center"] + for key in required_keys: + assert key in styles + + def test_create_pdf_styles_caches_result(self): + """Test that styles are cached.""" + styles1 = create_pdf_styles() + styles2 = create_pdf_styles() + assert styles1 is styles2 + + +# ============================================================================= +# Base Generator Tests +# ============================================================================= + + +class TestBaseComplianceReportGenerator: + """Tests for BaseComplianceReportGenerator.""" + + def test_cannot_instantiate_directly(self): + """Test that base class cannot be instantiated directly.""" + config = FrameworkConfig(name="test", display_name="Test") + with pytest.raises(TypeError): + BaseComplianceReportGenerator(config) + + def test_concrete_implementation(self): + """Test that a concrete implementation can be created.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test") + generator = ConcreteGenerator(config) + assert generator.config.name == "test" + assert generator.styles is not None + + def test_get_footer_text_english(self): + """Test footer text in English.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test", language="en") + generator = ConcreteGenerator(config) + left, right = generator.get_footer_text(1) + assert left == "Page 1" + assert right == "Powered by Prowler" + + def test_get_footer_text_spanish(self): + """Test footer text in Spanish.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test", language="es") + generator = ConcreteGenerator(config) + left, right = generator.get_footer_text(1) + assert left == "Página 1" + + +class TestBuildInfoRows: + """Tests for _build_info_rows helper method.""" + + def _create_generator(self, language="en"): + """Create a concrete generator for testing.""" + + class ConcreteGenerator(BaseComplianceReportGenerator): + def create_executive_summary(self, data): + return [] + + def create_charts_section(self, data): + return [] + + def create_requirements_index(self, data): + return [] + + config = FrameworkConfig(name="test", display_name="Test", language=language) + return ConcreteGenerator(config) + + def test_build_info_rows_english(self): + """Test info rows are built with English labels.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test Framework", + name="Test Name", + version="1.0", + description="Test description", + ) + + rows = generator._build_info_rows(data, language="en") + + assert ("Framework:", "Test Framework") in rows + assert ("Name:", "Test Name") in rows + assert ("Version:", "1.0") in rows + assert ("Scan ID:", "scan-123") in rows + assert ("Description:", "Test description") in rows + + def test_build_info_rows_spanish(self): + """Test info rows are built with Spanish labels.""" + generator = self._create_generator(language="es") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test Framework", + name="Test Name", + version="1.0", + description="Test description", + ) + + rows = generator._build_info_rows(data, language="es") + + assert ("Framework:", "Test Framework") in rows + assert ("Nombre:", "Test Name") in rows + assert ("Versión:", "1.0") in rows + assert ("Scan ID:", "scan-123") in rows + assert ("Descripción:", "Test description") in rows + + def test_build_info_rows_with_provider(self): + """Test info rows include provider info when available.""" + from unittest.mock import Mock + + generator = self._create_generator(language="en") + + mock_provider = Mock() + mock_provider.provider = "aws" + mock_provider.uid = "123456789012" + mock_provider.alias = "my-account" + + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=mock_provider, + ) + + rows = generator._build_info_rows(data, language="en") + + assert ("Provider:", "AWS") in rows + assert ("Account ID:", "123456789012") in rows + assert ("Alias:", "my-account") in rows + + def test_build_info_rows_with_provider_spanish(self): + """Test provider info uses Spanish labels.""" + from unittest.mock import Mock + + generator = self._create_generator(language="es") + + mock_provider = Mock() + mock_provider.provider = "azure" + mock_provider.uid = "subscription-id" + mock_provider.alias = "mi-suscripcion" + + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=mock_provider, + ) + + rows = generator._build_info_rows(data, language="es") + + assert ("Proveedor:", "AZURE") in rows + assert ("Account ID:", "subscription-id") in rows + assert ("Alias:", "mi-suscripcion") in rows + + def test_build_info_rows_without_provider(self): + """Test info rows work without provider info.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=None, + ) + + rows = generator._build_info_rows(data, language="en") + + # Provider info should not be present + labels = [label for label, _ in rows] + assert "Provider:" not in labels + assert "Account ID:" not in labels + assert "Alias:" not in labels + + def test_build_info_rows_provider_with_missing_fields(self): + """Test provider info handles None values gracefully.""" + from unittest.mock import Mock + + generator = self._create_generator(language="en") + + mock_provider = Mock() + mock_provider.provider = "gcp" + mock_provider.uid = None + mock_provider.alias = None + + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + provider_obj=mock_provider, + ) + + rows = generator._build_info_rows(data, language="en") + + assert ("Provider:", "GCP") in rows + assert ("Account ID:", "N/A") in rows + assert ("Alias:", "N/A") in rows + + def test_build_info_rows_without_description(self): + """Test info rows exclude description when empty.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="", + ) + + rows = generator._build_info_rows(data, language="en") + + labels = [label for label, _ in rows] + assert "Description:" not in labels + + def test_build_info_rows_defaults_to_english(self): + """Test unknown language defaults to English labels.""" + generator = self._create_generator(language="en") + data = ComplianceData( + tenant_id="t1", + scan_id="scan-123", + provider_id="p1", + compliance_id="test_compliance", + framework="Test", + name="Test", + version="1.0", + description="Desc", + ) + + rows = generator._build_info_rows(data, language="fr") # Unknown language + + # Should use English labels as fallback + assert ("Name:", "Test") in rows + assert ("Description:", "Desc") in rows + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestExampleReportGenerator: + """Integration tests using an example report generator.""" + + def setup_method(self): + """Set up test fixtures.""" + + class ExampleGenerator(BaseComplianceReportGenerator): + """Example concrete implementation for testing.""" + + def create_executive_summary(self, data): + return [ + Paragraph("Executive Summary", self.styles["h1"]), + Paragraph( + f"Total requirements: {len(data.requirements)}", + self.styles["normal"], + ), + ] + + def create_charts_section(self, data): + chart_buffer = create_vertical_bar_chart( + labels=["Pass", "Fail"], + values=[80, 20], + ) + return [Image(chart_buffer, width=6 * inch, height=4 * inch)] + + def create_requirements_index(self, data): + elements = [Paragraph("Requirements Index", self.styles["h1"])] + for req in data.requirements: + elements.append( + Paragraph( + f"- {req.id}: {req.description}", self.styles["normal"] + ) + ) + return elements + + self.generator_class = ExampleGenerator + + def test_example_generator_creation(self): + """Test creating example generator.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + assert generator is not None + + def test_example_generator_executive_summary(self): + """Test executive summary generation.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + requirements=[ + RequirementData(id="R1", description="Req 1", status="PASS"), + RequirementData(id="R2", description="Req 2", status="FAIL"), + ], + ) + + elements = generator.create_executive_summary(data) + assert len(elements) == 2 + + def test_example_generator_charts_section(self): + """Test charts section generation.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + ) + + elements = generator.create_charts_section(data) + assert len(elements) == 1 + + def test_example_generator_requirements_index(self): + """Test requirements index generation.""" + config = FrameworkConfig(name="example", display_name="Example Framework") + generator = self.generator_class(config) + + data = ComplianceData( + tenant_id="t1", + scan_id="s1", + provider_id="p1", + compliance_id="c1", + framework="Test", + name="Test", + version="1.0", + description="", + requirements=[ + RequirementData(id="R1", description="Requirement 1", status="PASS"), + ], + ) + + elements = generator.create_requirements_index(data) + assert len(elements) == 2 # Header + 1 requirement + + +# ============================================================================= +# Edge Case Tests +# ============================================================================= + + +class TestChartEdgeCases: + """Tests for chart edge cases.""" + + def test_vertical_bar_chart_empty_data(self): + """Test vertical bar chart with empty data.""" + buffer = create_vertical_bar_chart(labels=[], values=[]) + assert isinstance(buffer, io.BytesIO) + + def test_vertical_bar_chart_single_item(self): + """Test vertical bar chart with single item.""" + buffer = create_vertical_bar_chart(labels=["Single"], values=[75.0]) + assert isinstance(buffer, io.BytesIO) + + def test_horizontal_bar_chart_empty_data(self): + """Test horizontal bar chart with empty data.""" + buffer = create_horizontal_bar_chart(labels=[], values=[]) + assert isinstance(buffer, io.BytesIO) + + def test_horizontal_bar_chart_single_item(self): + """Test horizontal bar chart with single item.""" + buffer = create_horizontal_bar_chart(labels=["Single"], values=[50.0]) + assert isinstance(buffer, io.BytesIO) + + def test_radar_chart_minimum_points(self): + """Test radar chart with minimum number of points (3).""" + buffer = create_radar_chart( + labels=["A", "B", "C"], + values=[30.0, 60.0, 90.0], + ) + assert isinstance(buffer, io.BytesIO) + + def test_pie_chart_single_slice(self): + """Test pie chart with single slice.""" + buffer = create_pie_chart(labels=["Only"], values=[100.0]) + assert isinstance(buffer, io.BytesIO) + + def test_pie_chart_many_slices(self): + """Test pie chart with many slices.""" + labels = [f"Item {i}" for i in range(10)] + values = [10.0] * 10 + buffer = create_pie_chart(labels=labels, values=values) + assert isinstance(buffer, io.BytesIO) + + def test_stacked_bar_chart_single_series(self): + """Test stacked bar chart with single series.""" + buffer = create_stacked_bar_chart( + labels=["A", "B"], + data_series={"Only": [10.0, 20.0]}, + ) + assert isinstance(buffer, io.BytesIO) + + def test_stacked_bar_chart_empty_data(self): + """Test stacked bar chart with empty data.""" + buffer = create_stacked_bar_chart(labels=[], data_series={}) + assert isinstance(buffer, io.BytesIO) + + +class TestComponentEdgeCases: + """Tests for component edge cases.""" + + def test_create_badge_empty_text(self): + """Test badge with empty text.""" + badge = create_badge("", COLOR_BLUE) + assert badge is not None + + def test_create_badge_long_text(self): + """Test badge with very long text.""" + long_text = "A" * 100 + badge = create_badge(long_text, COLOR_BLUE, width=5 * inch) + assert badge is not None + + def test_create_status_badge_unknown_status(self): + """Test status badge with unknown status.""" + badge = create_status_badge("UNKNOWN") + assert badge is not None + + def test_create_multi_badge_row_single_badge(self): + """Test multi-badge row with single badge.""" + badges = [("A", COLOR_BLUE)] + table = create_multi_badge_row(badges) + assert table is not None + + def test_create_multi_badge_row_many_badges(self): + """Test multi-badge row with many badges.""" + badges = [(chr(65 + i), COLOR_BLUE) for i in range(10)] # A-J + table = create_multi_badge_row(badges) + assert table is not None + + def test_create_info_table_empty(self): + """Test info table with empty rows.""" + table = create_info_table([]) + assert isinstance(table, Table) + + def test_create_info_table_long_values(self): + """Test info table with very long values wraps properly.""" + rows = [ + ("Key:", "A" * 200), # Very long value + ] + styles = create_pdf_styles() + table = create_info_table(rows, normal_style=styles["normal"]) + assert table is not None + + def test_create_data_table_empty(self): + """Test data table with empty data.""" + columns = [ + ColumnConfig("Name", 2 * inch, "name"), + ] + table = create_data_table([], columns) + assert table is not None + + def test_create_data_table_large_dataset(self): + """Test data table with large dataset uses LongTable.""" + # Create more than 50 rows to trigger LongTable + data = [{"name": f"Item {i}"} for i in range(60)] + columns = [ColumnConfig("Name", 2 * inch, "name")] + table = create_data_table(data, columns) + # Should be a LongTable for large datasets + assert isinstance(table, LongTable) + + def test_create_risk_component_zero_values(self): + """Test risk component with zero values.""" + component = create_risk_component(risk_level=0, weight=0, score=0) + assert component is not None + + def test_create_risk_component_max_values(self): + """Test risk component with maximum values.""" + component = create_risk_component(risk_level=5, weight=200, score=1000) + assert component is not None + + +class TestColorEdgeCases: + """Tests for color function edge cases.""" + + def test_get_color_for_compliance_boundary_80(self): + """Test compliance color at exactly 80%.""" + assert get_color_for_compliance(80) == COLOR_SAFE + + def test_get_color_for_compliance_boundary_60(self): + """Test compliance color at exactly 60%.""" + assert get_color_for_compliance(60) == COLOR_LOW_RISK + + def test_get_color_for_compliance_over_100(self): + """Test compliance color for values over 100.""" + assert get_color_for_compliance(150) == COLOR_SAFE + + def test_get_color_for_weight_boundary_100(self): + """Test weight color at exactly 100.""" + assert get_color_for_weight(100) == COLOR_LOW_RISK + + def test_get_color_for_weight_boundary_50(self): + """Test weight color at exactly 50.""" + assert get_color_for_weight(50) == COLOR_SAFE + + def test_get_status_color_case_insensitive(self): + """Test that status color is case insensitive.""" + assert get_status_color("PASS") == get_status_color("pass") + assert get_status_color("FAIL") == get_status_color("Fail") + assert get_status_color("MANUAL") == get_status_color("manual") + + +class TestFrameworkConfigEdgeCases: + """Tests for FrameworkConfig edge cases.""" + + def test_framework_config_empty_sections(self): + """Test FrameworkConfig with empty sections list.""" + config = FrameworkConfig( + name="test", + display_name="Test", + sections=[], + ) + assert config.sections == [] + + def test_framework_config_empty_attribute_fields(self): + """Test FrameworkConfig with empty attribute fields.""" + config = FrameworkConfig( + name="test", + display_name="Test", + attribute_fields=[], + ) + assert config.attribute_fields == [] + + def test_get_framework_config_case_variations(self): + """Test get_framework_config with different case variations.""" + # Test case insensitivity + assert get_framework_config("PROWLER_THREATSCORE_AWS") is not None + assert get_framework_config("ENS_RD2022_AWS") is not None + assert get_framework_config("NIS2_AWS") is not None + + def test_get_framework_config_partial_match(self): + """Test that partial matches work correctly.""" + # Should match based on substring + assert get_framework_config("my_custom_threatscore_compliance") is not None + assert get_framework_config("ens_something_else") is not None + assert get_framework_config("nis2_gcp") is not None diff --git a/api/src/backend/tasks/tests/test_reports_ens.py b/api/src/backend/tasks/tests/test_reports_ens.py new file mode 100644 index 0000000000..91eb6d6f3a --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_ens.py @@ -0,0 +1,1227 @@ +import io +from unittest.mock import Mock, patch + +import pytest +from reportlab.platypus import PageBreak, Paragraph, Table +from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData +from tasks.jobs.reports.ens import ENSReportGenerator + + +# Use string status values directly to avoid Django DB initialization +# These match api.models.StatusChoices values +class StatusChoices: + """Mock StatusChoices to avoid Django DB initialization.""" + + PASS = "PASS" + FAIL = "FAIL" + MANUAL = "MANUAL" + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def ens_generator(): + """Create an ENSReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["ens"] + return ENSReportGenerator(config) + + +@pytest.fixture +def mock_ens_requirement_attribute(): + """Create a mock ENS requirement attribute with all fields.""" + mock = Mock() + mock.Marco = "Operacional" + mock.Categoria = "Gestión de incidentes" + mock.DescripcionControl = "Control de gestión de incidentes de seguridad" + mock.Tipo = "requisito" + mock.Nivel = "alto" + mock.Dimensiones = ["confidencialidad", "integridad"] + mock.ModoEjecucion = "automatico" + mock.IdGrupoControl = "op.ext.1" + return mock + + +@pytest.fixture +def mock_ens_requirement_attribute_medio(): + """Create a mock ENS requirement attribute with nivel medio.""" + mock = Mock() + mock.Marco = "Organizativo" + mock.Categoria = "Seguridad en los recursos humanos" + mock.DescripcionControl = "Control de seguridad del personal" + mock.Tipo = "refuerzo" + mock.Nivel = "medio" + mock.Dimensiones = "trazabilidad, autenticidad" # String format + mock.ModoEjecucion = "manual" + mock.IdGrupoControl = "org.rh.1" + return mock + + +@pytest.fixture +def mock_ens_requirement_attribute_bajo(): + """Create a mock ENS requirement attribute with nivel bajo.""" + mock = Mock() + mock.Marco = "Medidas de Protección" + mock.Categoria = "Protección de las instalaciones" + mock.DescripcionControl = "Control de acceso físico" + mock.Tipo = "recomendacion" + mock.Nivel = "bajo" + mock.Dimensiones = ["disponibilidad"] + mock.ModoEjecucion = "automatico" + mock.IdGrupoControl = "mp.if.1" + return mock + + +@pytest.fixture +def mock_ens_requirement_attribute_opcional(): + """Create a mock ENS requirement attribute with nivel opcional.""" + mock = Mock() + mock.Marco = "Marco de Organización" + mock.Categoria = "Política de seguridad" + mock.DescripcionControl = "Política de seguridad de la información" + mock.Tipo = "medida" + mock.Nivel = "opcional" + mock.Dimensiones = [] + mock.ModoEjecucion = "automatico" + mock.IdGrupoControl = "org.1" + return mock + + +@pytest.fixture +def basic_ens_compliance_data(): + """Create basic ComplianceData for ENS testing.""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="ens_rd2022_aws", + framework="ENS RD2022", + name="Esquema Nacional de Seguridad RD 311/2022", + version="2022", + description="Marco de seguridad para la administración electrónica española", + ) + + +# ============================================================================= +# Generator Initialization Tests +# ============================================================================= + + +class TestENSGeneratorInitialization: + """Test suite for ENS generator initialization.""" + + def test_generator_creation(self, ens_generator): + """Test that ENS generator is created correctly.""" + assert ens_generator is not None + assert ens_generator.config.name == "ens" + assert ens_generator.config.language == "es" + + def test_generator_has_niveles(self, ens_generator): + """Test that ENS config has niveles enabled.""" + assert ens_generator.config.has_niveles is True + + def test_generator_has_dimensions(self, ens_generator): + """Test that ENS config has dimensions enabled.""" + assert ens_generator.config.has_dimensions is True + + def test_generator_no_risk_levels(self, ens_generator): + """Test that ENS config does not use risk levels.""" + assert ens_generator.config.has_risk_levels is False + + def test_generator_no_weight(self, ens_generator): + """Test that ENS config does not use weight.""" + assert ens_generator.config.has_weight is False + + +# ============================================================================= +# Cover Page Tests +# ============================================================================= + + +class TestENSCoverPage: + """Test suite for ENS cover page generation.""" + + @patch("tasks.jobs.reports.ens.Image") + def test_cover_page_has_logos( + self, mock_image, ens_generator, basic_ens_compliance_data + ): + """Test that cover page contains logos.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + assert len(elements) > 0 + # Should have called Image at least twice (prowler + ens logos) + assert mock_image.call_count >= 2 + + def test_cover_page_has_title(self, ens_generator, basic_ens_compliance_data): + """Test that cover page contains the ENS title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "ENS" in content or "Informe" in content + + def test_cover_page_has_info_table(self, ens_generator, basic_ens_compliance_data): + """Test that cover page contains info table with metadata.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 # At least info table + + def test_cover_page_has_warning_about_manual( + self, ens_generator, basic_ens_compliance_data + ): + """Test that cover page has warning about manual requirements.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + # Find paragraphs (including those inside tables) that mention manual + all_paragraphs = [] + for e in elements: + if isinstance(e, Paragraph): + all_paragraphs.append(e) + elif isinstance(e, Table): + # Check table cells for Paragraph objects + cell_values = getattr(e, "_cellvalues", []) + for row in cell_values: + for cell in row: + if isinstance(cell, Paragraph): + all_paragraphs.append(cell) + content = " ".join(str(p.text) for p in all_paragraphs) + assert "manual" in content.lower() or "AVISO" in content + + def test_cover_page_has_legend(self, ens_generator, basic_ens_compliance_data): + """Test that cover page contains the ENS values legend.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_cover_page(basic_ens_compliance_data) + + # Legend should be a table with explanations + tables = [e for e in elements if isinstance(e, Table)] + # At least 3 tables: logos, info, warning, legend + assert len(tables) >= 3 + + +# ============================================================================= +# Executive Summary Tests +# ============================================================================= + + +class TestENSExecutiveSummary: + """Test suite for ENS executive summary generation.""" + + def test_executive_summary_has_title( + self, ens_generator, basic_ens_compliance_data + ): + """Test that executive summary has Spanish title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Resumen Ejecutivo" in content + + def test_executive_summary_calculates_compliance( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that executive summary calculates compliance percentage.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + # Should contain tables with metrics + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_executive_summary_excludes_manual_from_compliance( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from compliance calculation.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + # Should calculate 100% compliance (only 1 auto requirement that passed) + assert len(elements) > 0 + + def test_executive_summary_has_nivel_table( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that executive summary includes compliance by nivel table.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Alto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_executive_summary(basic_ens_compliance_data) + + # Should have nivel table + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Nivel" in content or "nivel" in content.lower() + + +# ============================================================================= +# Charts Section Tests +# ============================================================================= + + +class TestENSChartsSection: + """Test suite for ENS charts section generation.""" + + def test_charts_section_has_page_breaks( + self, ens_generator, basic_ens_compliance_data + ): + """Test that charts section has page breaks between charts.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + page_breaks = [e for e in elements if isinstance(e, PageBreak)] + assert len(page_breaks) >= 2 # At least 2 page breaks for different charts + + def test_charts_section_has_marco_category_chart( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that charts section contains Marco/Categoría chart.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Marco" in content or "Categoría" in content + + def test_charts_section_has_dimensions_radar( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that charts section contains dimensions radar chart.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Dimensiones" in content or "dimensiones" in content.lower() + + def test_charts_section_has_tipo_distribution( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that charts section contains tipo distribution.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_charts_section(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Tipo" in content or "tipo" in content.lower() + + +# ============================================================================= +# Critical Failed Requirements Tests +# ============================================================================= + + +class TestENSCriticalFailedRequirements: + """Test suite for ENS critical failed requirements (nivel alto).""" + + def test_no_critical_failures_shows_success_message( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that no critical failures shows success message.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed alto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_critical_failed_section( + basic_ens_compliance_data + ) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "No hay" in content or "✅" in content + + def test_critical_failures_shows_table( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that critical failures shows requirements table.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed alto requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_critical_failed_section( + basic_ens_compliance_data + ) + + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_critical_failures_only_includes_alto( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + ): + """Test that only nivel alto failures are included.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed alto requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed medio requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + } + + elements = ens_generator._create_critical_failed_section( + basic_ens_compliance_data + ) + + # Should have table but only with alto requirement + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should mention 1 critical requirement + assert "1" in content + + +# ============================================================================= +# Requirements Index Tests +# ============================================================================= + + +class TestENSRequirementsIndex: + """Test suite for ENS requirements index generation.""" + + def test_requirements_index_has_title( + self, ens_generator, basic_ens_compliance_data + ): + """Test that requirements index has Spanish title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Índice" in content or "Requisitos" in content + + def test_requirements_index_organized_by_marco( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + ): + """Test that requirements index is organized by Marco.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Operacional requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Organizativo requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + } + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert ( + "Operacional" in content or "Organizativo" in content or "Marco" in content + ) + + def test_requirements_index_excludes_manual( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from index.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # REQ-001 should be there, REQ-002 should not + assert "REQ-001" in content + assert "REQ-002" not in content + + def test_requirements_index_shows_status_indicators( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that requirements index shows pass/fail indicators.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_requirements_index(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should have status indicators + assert "✓" in content or "✗" in content + + +# ============================================================================= +# Detailed Findings Tests +# ============================================================================= + + +class TestENSDetailedFindings: + """Test suite for ENS detailed findings generation.""" + + def test_detailed_findings_has_title( + self, ens_generator, basic_ens_compliance_data + ): + """Test that detailed findings section has title.""" + basic_ens_compliance_data.requirements = [] + basic_ens_compliance_data.attributes_by_requirement_id = {} + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Detalle" in content or "Requisitos" in content + + def test_detailed_findings_no_failures_message( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test message when no failed requirements exist.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "No hay" in content or "requisitos fallidos" in content.lower() + + def test_detailed_findings_shows_failed_requirements( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that failed requirements are shown in detail.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + # Should have tables showing requirement details + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_detailed_findings_shows_nivel_badges( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that detailed findings show nivel badges.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + # Should generate without errors + assert len(elements) > 0 + + def test_detailed_findings_shows_dimensiones_badges( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that detailed findings show dimension badges.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator.create_detailed_findings(basic_ens_compliance_data) + + # Should generate without errors with dimension badges + assert len(elements) > 0 + + +# ============================================================================= +# Dimension Handling Tests +# ============================================================================= + + +class TestENSDimensionHandling: + """Test suite for ENS security dimension handling.""" + + def test_dimensions_as_list( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test handling dimensions as a list.""" + # mock_ens_requirement_attribute has Dimensiones as list + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_dimensions_radar_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + def test_dimensions_as_string( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute_medio, + ): + """Test handling dimensions as comma-separated string.""" + # mock_ens_requirement_attribute_medio has Dimensiones as string + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_dimensions_radar_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + def test_dimensions_empty( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute_opcional, + ): + """Test handling empty dimensions.""" + # mock_ens_requirement_attribute_opcional has empty Dimensiones + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_ens_requirement_attribute_opcional] + } + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_dimensions_radar_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + +# ============================================================================= +# Footer Tests +# ============================================================================= + + +class TestENSFooter: + """Test suite for ENS footer generation.""" + + def test_footer_is_spanish(self, ens_generator): + """Test that footer text is in Spanish.""" + left, right = ens_generator.get_footer_text(1) + + assert "Página" in left + assert "Prowler" in right + + def test_footer_includes_page_number(self, ens_generator): + """Test that footer includes page number.""" + left, right = ens_generator.get_footer_text(5) + + assert "5" in left + + +# ============================================================================= +# Nivel Table Tests +# ============================================================================= + + +class TestENSNivelTable: + """Test suite for ENS nivel compliance table.""" + + def test_nivel_table_all_niveles( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + mock_ens_requirement_attribute_bajo, + mock_ens_requirement_attribute_opcional, + ): + """Test nivel table with all niveles represented.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Alto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Medio requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + RequirementData( + id="REQ-003", + description="Bajo requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-004", + description="Opcional requirement", + status=StatusChoices.PASS, + passed_findings=3, + failed_findings=0, + total_findings=3, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + "REQ-003": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_bajo]} + }, + "REQ-004": { + "attributes": { + "req_attributes": [mock_ens_requirement_attribute_opcional] + } + }, + } + + elements = ens_generator._create_nivel_table(basic_ens_compliance_data) + + # Should have at least one table + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_nivel_table_excludes_manual( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from nivel table.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_nivel_table(basic_ens_compliance_data) + + # Should generate without errors + assert len(elements) > 0 + + +# ============================================================================= +# Marco Category Chart Tests +# ============================================================================= + + +class TestENSMarcoCategoryChart: + """Test suite for ENS Marco/Categoría chart.""" + + def test_marco_category_chart_creation( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that Marco/Categoría chart is created successfully.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + chart_buffer = ens_generator._create_marco_category_chart( + basic_ens_compliance_data + ) + + assert isinstance(chart_buffer, io.BytesIO) + assert chart_buffer.getvalue() # Not empty + + def test_marco_category_chart_excludes_manual( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that manual requirements are excluded from chart.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + # Should not raise any errors + chart_buffer = ens_generator._create_marco_category_chart( + basic_ens_compliance_data + ) + assert isinstance(chart_buffer, io.BytesIO) + + +# ============================================================================= +# Tipo Section Tests +# ============================================================================= + + +class TestENSTipoSection: + """Test suite for ENS tipo distribution section.""" + + def test_tipo_section_creation( + self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute + ): + """Test that tipo section is created successfully.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Requisito type", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + } + + elements = ens_generator._create_tipo_section(basic_ens_compliance_data) + + assert len(elements) > 0 + # Should have a table with tipo distribution + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_tipo_section_all_types( + self, + ens_generator, + basic_ens_compliance_data, + mock_ens_requirement_attribute, + mock_ens_requirement_attribute_medio, + mock_ens_requirement_attribute_bajo, + mock_ens_requirement_attribute_opcional, + ): + """Test tipo section with all requirement types.""" + basic_ens_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Requisito type", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Refuerzo type", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + RequirementData( + id="REQ-003", + description="Recomendacion type", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-004", + description="Medida type", + status=StatusChoices.PASS, + passed_findings=3, + failed_findings=0, + total_findings=3, + ), + ] + basic_ens_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute]} + }, + "REQ-002": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_medio]} + }, + "REQ-003": { + "attributes": {"req_attributes": [mock_ens_requirement_attribute_bajo]} + }, + "REQ-004": { + "attributes": { + "req_attributes": [mock_ens_requirement_attribute_opcional] + } + }, + } + + elements = ens_generator._create_tipo_section(basic_ens_compliance_data) + + # Should generate without errors + assert len(elements) > 0 diff --git a/api/src/backend/tasks/tests/test_reports_nis2.py b/api/src/backend/tasks/tests/test_reports_nis2.py new file mode 100644 index 0000000000..07e88ec7ca --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_nis2.py @@ -0,0 +1,1093 @@ +import io +from unittest.mock import Mock, patch + +import pytest +from reportlab.platypus import PageBreak, Paragraph, Table +from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData +from tasks.jobs.reports.nis2 import NIS2ReportGenerator, _extract_section_number + + +# Use string status values directly to avoid Django DB initialization +# These match api.models.StatusChoices values +class StatusChoices: + """Mock StatusChoices to avoid Django DB initialization.""" + + PASS = "PASS" + FAIL = "FAIL" + MANUAL = "MANUAL" + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def nis2_generator(): + """Create a NIS2ReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["nis2"] + return NIS2ReportGenerator(config) + + +@pytest.fixture +def mock_nis2_requirement_attribute_section1(): + """Create a mock NIS2 requirement attribute for Section 1.""" + mock = Mock() + mock.Section = "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS" + mock.SubSection = "1.1 Policy establishment" + mock.Description = "Establish security policies for network and information systems" + return mock + + +@pytest.fixture +def mock_nis2_requirement_attribute_section2(): + """Create a mock NIS2 requirement attribute for Section 2.""" + mock = Mock() + mock.Section = "2 RISK MANAGEMENT" + mock.SubSection = "2.1 Risk assessment" + mock.Description = "Conduct risk assessments for critical infrastructure" + return mock + + +@pytest.fixture +def mock_nis2_requirement_attribute_section11(): + """Create a mock NIS2 requirement attribute for Section 11.""" + mock = Mock() + mock.Section = "11 ACCESS CONTROL" + mock.SubSection = "11.2 User access management" + mock.Description = "Manage user access to systems and data" + return mock + + +@pytest.fixture +def mock_nis2_requirement_attribute_no_subsection(): + """Create a mock NIS2 requirement attribute without subsection.""" + mock = Mock() + mock.Section = "3 INCIDENT HANDLING" + mock.SubSection = "" + mock.Description = "Handle security incidents effectively" + return mock + + +@pytest.fixture +def basic_nis2_compliance_data(): + """Create basic ComplianceData for NIS2 testing.""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="nis2_aws", + framework="NIS2", + name="NIS2 Directive (EU) 2022/2555", + version="2022", + description="EU directive on security of network and information systems", + ) + + +# ============================================================================= +# Section Number Extraction Tests +# ============================================================================= + + +class TestSectionNumberExtraction: + """Test suite for section number extraction utility.""" + + def test_extract_simple_section_number(self): + """Test extracting single digit section number.""" + result = _extract_section_number("1 POLICY ON SECURITY") + assert result == "1" + + def test_extract_double_digit_section_number(self): + """Test extracting double digit section number.""" + result = _extract_section_number("11 ACCESS CONTROL") + assert result == "11" + + def test_extract_section_number_with_spaces(self): + """Test extracting section number with leading/trailing spaces.""" + result = _extract_section_number(" 2 RISK MANAGEMENT ") + assert result == "2" + + def test_extract_section_number_empty_string(self): + """Test extracting from empty string returns 'Other'.""" + result = _extract_section_number("") + assert result == "Other" + + def test_extract_section_number_none_like(self): + """Test extracting from empty/None-like returns 'Other'.""" + # Note: The function expects str, so we test empty string behavior + result = _extract_section_number("") + assert result == "Other" + + def test_extract_section_number_no_number(self): + """Test extracting from string without number returns 'Other'.""" + result = _extract_section_number("POLICY ON SECURITY") + assert result == "Other" + + def test_extract_section_number_letter_first(self): + """Test extracting from string starting with letter returns 'Other'.""" + result = _extract_section_number("A. Some Section") + assert result == "Other" + + +# ============================================================================= +# Generator Initialization Tests +# ============================================================================= + + +class TestNIS2GeneratorInitialization: + """Test suite for NIS2 generator initialization.""" + + def test_generator_creation(self, nis2_generator): + """Test that NIS2 generator is created correctly.""" + assert nis2_generator is not None + assert nis2_generator.config.name == "nis2" + assert nis2_generator.config.language == "en" + + def test_generator_no_niveles(self, nis2_generator): + """Test that NIS2 config does not use niveles.""" + assert nis2_generator.config.has_niveles is False + + def test_generator_no_dimensions(self, nis2_generator): + """Test that NIS2 config does not use dimensions.""" + assert nis2_generator.config.has_dimensions is False + + def test_generator_no_risk_levels(self, nis2_generator): + """Test that NIS2 config does not use risk levels.""" + assert nis2_generator.config.has_risk_levels is False + + def test_generator_no_weight(self, nis2_generator): + """Test that NIS2 config does not use weight.""" + assert nis2_generator.config.has_weight is False + + +# ============================================================================= +# Cover Page Tests +# ============================================================================= + + +class TestNIS2CoverPage: + """Test suite for NIS2 cover page generation.""" + + @patch("tasks.jobs.reports.nis2.Image") + def test_cover_page_has_logos( + self, mock_image, nis2_generator, basic_nis2_compliance_data + ): + """Test that cover page contains logos.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_cover_page(basic_nis2_compliance_data) + + assert len(elements) > 0 + # Should have called Image at least twice (prowler + nis2 logos) + assert mock_image.call_count >= 2 + + def test_cover_page_has_title(self, nis2_generator, basic_nis2_compliance_data): + """Test that cover page contains the NIS2 title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_cover_page(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "NIS2" in content or "Directive" in content + + def test_cover_page_has_metadata_table( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that cover page contains metadata table.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_cover_page(basic_nis2_compliance_data) + + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + +# ============================================================================= +# Executive Summary Tests +# ============================================================================= + + +class TestNIS2ExecutiveSummary: + """Test suite for NIS2 executive summary generation.""" + + def test_executive_summary_has_english_title( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that executive summary has English title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Executive Summary" in content + + def test_executive_summary_calculates_compliance( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that executive summary calculates compliance percentage.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + # Should contain tables with metrics + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) >= 1 + + def test_executive_summary_shows_all_statuses( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that executive summary shows passed, failed, and manual counts.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Manual", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + # Should have a summary table with all statuses + assert len(elements) > 0 + + def test_executive_summary_excludes_manual_from_percentage( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that manual requirements are excluded from compliance percentage.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + # Should calculate 100% (only 1 evaluated requirement that passed) + assert len(elements) > 0 + + +# ============================================================================= +# Charts Section Tests +# ============================================================================= + + +class TestNIS2ChartsSection: + """Test suite for NIS2 charts section generation.""" + + def test_charts_section_has_section_chart_title( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that charts section has section compliance title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Section" in content or "Compliance" in content + + def test_charts_section_has_page_break( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that charts section has page breaks.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + page_breaks = [e for e in elements if isinstance(e, PageBreak)] + assert len(page_breaks) >= 1 + + def test_charts_section_has_subsection_breakdown( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that charts section includes subsection breakdown table.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "SubSection" in content or "Breakdown" in content + + +# ============================================================================= +# Section Chart Tests +# ============================================================================= + + +class TestNIS2SectionChart: + """Test suite for NIS2 section compliance chart.""" + + def test_section_chart_creation( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that section chart is created successfully.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data) + + assert isinstance(chart_buffer, io.BytesIO) + assert chart_buffer.getvalue() # Not empty + + def test_section_chart_excludes_manual( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that manual requirements are excluded from section chart.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Auto requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + # Should not raise any errors + chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data) + assert isinstance(chart_buffer, io.BytesIO) + + def test_section_chart_multiple_sections( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + mock_nis2_requirement_attribute_section2, + mock_nis2_requirement_attribute_section11, + ): + """Test section chart with multiple sections.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Section 1 requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Section 2 requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Section 11 requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section2] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section11] + } + }, + } + + chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data) + assert isinstance(chart_buffer, io.BytesIO) + + +# ============================================================================= +# SubSection Table Tests +# ============================================================================= + + +class TestNIS2SubSectionTable: + """Test suite for NIS2 subsection breakdown table.""" + + def test_subsection_table_creation( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that subsection table is created successfully.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + table = nis2_generator._create_subsection_table(basic_nis2_compliance_data) + + assert isinstance(table, Table) + + def test_subsection_table_counts_statuses( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that subsection table counts passed, failed, and manual.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Manual", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + table = nis2_generator._create_subsection_table(basic_nis2_compliance_data) + assert isinstance(table, Table) + + def test_subsection_table_no_subsection( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_no_subsection, + ): + """Test subsection table when requirements have no subsection.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="No subsection requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_no_subsection] + } + }, + } + + table = nis2_generator._create_subsection_table(basic_nis2_compliance_data) + assert isinstance(table, Table) + + +# ============================================================================= +# Requirements Index Tests +# ============================================================================= + + +class TestNIS2RequirementsIndex: + """Test suite for NIS2 requirements index generation.""" + + def test_requirements_index_has_title( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that requirements index has English title.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "Requirements Index" in content + + def test_requirements_index_organized_by_section( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + mock_nis2_requirement_attribute_section2, + ): + """Test that requirements index is organized by section.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Section 1 requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Section 2 requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section2] + } + }, + } + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should have section headers + assert "Policy" in content or "Risk" in content or "1." in content + + def test_requirements_index_shows_status_indicators( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test that requirements index shows pass/fail/manual indicators.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passed requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Failed requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ), + RequirementData( + id="REQ-003", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-003": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + } + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + # Should have status indicators + assert "✓" in content or "✗" in content or "⊙" in content + + def test_requirements_index_truncates_long_descriptions( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test that long descriptions are truncated.""" + mock_attr = Mock() + mock_attr.Section = "1 POLICY" + mock_attr.SubSection = "1.1 Long subsection name" + mock_attr.Description = "A" * 100 # Very long description + + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="A" * 100, + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + # Should not raise errors + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + assert len(elements) > 0 + + +# ============================================================================= +# Section Key Sorting Tests +# ============================================================================= + + +class TestNIS2SectionKeySorting: + """Test suite for NIS2 section key sorting.""" + + def test_sort_simple_sections(self, nis2_generator): + """Test sorting simple section numbers.""" + result = nis2_generator._sort_section_key("1") + assert result == (1,) + + result = nis2_generator._sort_section_key("2") + assert result == (2,) + + def test_sort_subsections(self, nis2_generator): + """Test sorting subsection numbers.""" + result = nis2_generator._sort_section_key("1.1") + assert result == (1, 1) + + result = nis2_generator._sort_section_key("1.2") + assert result == (1, 2) + + def test_sort_double_digit_sections(self, nis2_generator): + """Test sorting double digit section numbers.""" + result = nis2_generator._sort_section_key("11") + assert result == (11,) + + result = nis2_generator._sort_section_key("11.2") + assert result == (11, 2) + + def test_sort_order_is_correct(self, nis2_generator): + """Test that sort order is numerically correct.""" + keys = ["11", "1", "2", "1.2", "1.1", "11.2", "2.1"] + sorted_keys = sorted(keys, key=nis2_generator._sort_section_key) + + assert sorted_keys == ["1", "1.1", "1.2", "2", "2.1", "11", "11.2"] + + def test_sort_invalid_key(self, nis2_generator): + """Test sorting invalid section key.""" + result = nis2_generator._sort_section_key("Other") + # Should contain infinity for non-numeric parts + assert result[0] == float("inf") + + +# ============================================================================= +# Empty Data Tests +# ============================================================================= + + +class TestNIS2EmptyData: + """Test suite for NIS2 with empty or minimal data.""" + + def test_executive_summary_empty_requirements( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test executive summary with no requirements.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + + assert len(elements) > 0 + + def test_charts_section_empty_requirements( + self, nis2_generator, basic_nis2_compliance_data + ): + """Test charts section with no requirements.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_charts_section(basic_nis2_compliance_data) + + assert len(elements) > 0 + + def test_requirements_index_empty(self, nis2_generator, basic_nis2_compliance_data): + """Test requirements index with no requirements.""" + basic_nis2_compliance_data.requirements = [] + basic_nis2_compliance_data.attributes_by_requirement_id = {} + + elements = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + # Should at least have the title + assert len(elements) >= 1 + + +# ============================================================================= +# All Pass / All Fail Tests +# ============================================================================= + + +class TestNIS2EdgeCases: + """Test suite for NIS2 edge cases.""" + + def test_all_requirements_pass( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test with all requirements passing.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id=f"REQ-{i:03d}", + description=f"Passing requirement {i}", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + for i in range(1, 6) + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + f"REQ-{i:03d}": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + } + for i in range(1, 6) + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + assert len(elements) > 0 + + def test_all_requirements_fail( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test with all requirements failing.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id=f"REQ-{i:03d}", + description=f"Failing requirement {i}", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + for i in range(1, 6) + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + f"REQ-{i:03d}": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + } + for i in range(1, 6) + } + + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + assert len(elements) > 0 + + def test_all_requirements_manual( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + ): + """Test with all requirements being manual.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id=f"REQ-{i:03d}", + description=f"Manual requirement {i}", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + for i in range(1, 6) + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + f"REQ-{i:03d}": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + } + for i in range(1, 6) + } + + # Should handle gracefully - compliance should be 100% when no evaluated + elements = nis2_generator.create_executive_summary(basic_nis2_compliance_data) + assert len(elements) > 0 + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestNIS2Integration: + """Integration tests for NIS2 report generation.""" + + def test_full_report_generation_flow( + self, + nis2_generator, + basic_nis2_compliance_data, + mock_nis2_requirement_attribute_section1, + mock_nis2_requirement_attribute_section2, + ): + """Test the complete report generation flow.""" + basic_nis2_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Section 1 passed", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Section 2 failed", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + ] + basic_nis2_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section1] + } + }, + "REQ-002": { + "attributes": { + "req_attributes": [mock_nis2_requirement_attribute_section2] + } + }, + } + + # Generate all sections + exec_summary = nis2_generator.create_executive_summary( + basic_nis2_compliance_data + ) + charts = nis2_generator.create_charts_section(basic_nis2_compliance_data) + index = nis2_generator.create_requirements_index(basic_nis2_compliance_data) + + # All sections should generate without errors + assert len(exec_summary) > 0 + assert len(charts) > 0 + assert len(index) > 0 diff --git a/api/src/backend/tasks/tests/test_reports_threatscore.py b/api/src/backend/tasks/tests/test_reports_threatscore.py new file mode 100644 index 0000000000..c79c0b16e9 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_threatscore.py @@ -0,0 +1,1093 @@ +import io +from unittest.mock import Mock + +import pytest +from reportlab.platypus import Image, PageBreak, Paragraph, Table +from tasks.jobs.reports import ( + FRAMEWORK_REGISTRY, + ComplianceData, + RequirementData, + ThreatScoreReportGenerator, +) + +from api.models import StatusChoices + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def threatscore_generator(): + """Create a ThreatScoreReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["prowler_threatscore"] + return ThreatScoreReportGenerator(config) + + +@pytest.fixture +def mock_requirement_attribute(): + """Create a mock requirement attribute with numeric values.""" + mock = Mock() + mock.LevelOfRisk = 4 + mock.Weight = 100 + mock.Section = "1. IAM" + mock.SubSection = "1.1 Access Control" + mock.Title = "Test Requirement" + mock.AttributeDescription = "Test Description" + return mock + + +@pytest.fixture +def mock_requirement_attribute_string_values(): + """Create a mock requirement attribute with string values (edge case).""" + mock = Mock() + mock.LevelOfRisk = "5" # String instead of int + mock.Weight = "150" # String instead of int + mock.Section = "2. Attack Surface" + mock.SubSection = "2.1 Exposure" + mock.Title = "String Values Requirement" + mock.AttributeDescription = "Test with string numeric values" + return mock + + +@pytest.fixture +def mock_requirement_attribute_invalid_values(): + """Create a mock requirement attribute with invalid values (edge case).""" + mock = Mock() + mock.LevelOfRisk = "High" # Invalid string + mock.Weight = "Critical" # Invalid string + mock.Section = "3. Logging" + mock.SubSection = "3.1 Audit" + mock.Title = "Invalid Values Requirement" + mock.AttributeDescription = "Test with invalid string values" + return mock + + +@pytest.fixture +def mock_requirement_attribute_empty_values(): + """Create a mock requirement attribute with empty values.""" + mock = Mock() + mock.LevelOfRisk = "" + mock.Weight = "" + mock.Section = "4. Encryption" + mock.SubSection = "4.1 Data at Rest" + mock.Title = "Empty Values Requirement" + mock.AttributeDescription = "Test with empty values" + return mock + + +@pytest.fixture +def mock_requirement_attribute_none_values(): + """Create a mock requirement attribute with None values.""" + mock = Mock() + mock.LevelOfRisk = None + mock.Weight = None + mock.Section = "1. IAM" + mock.SubSection = "1.2 Policies" + mock.Title = "None Values Requirement" + mock.AttributeDescription = "Test with None values" + return mock + + +@pytest.fixture +def basic_compliance_data(): + """Create basic ComplianceData for testing.""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="prowler_threatscore_aws", + framework="Prowler ThreatScore", + name="ThreatScore AWS", + version="1.0", + description="Security assessment framework", + ) + + +# ============================================================================= +# ThreatScore Calculation Tests +# ============================================================================= + + +class TestThreatScoreCalculation: + """Test suite for ThreatScore calculation logic.""" + + def test_calculate_threatscore_no_findings_returns_100( + self, threatscore_generator, basic_compliance_data + ): + """Test that 100% is returned when there are no findings.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_compliance_data.attributes_by_requirement_id = {} + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + assert result == 100.0 + + def test_calculate_threatscore_all_passed( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test ThreatScore calculation when all findings pass.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_requirement_attribute]}, + } + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + assert result == 100.0 + + def test_calculate_threatscore_all_failed( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test ThreatScore calculation when all findings fail.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_requirement_attribute]}, + } + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + assert result == 0.0 + + def test_calculate_threatscore_mixed_findings( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test ThreatScore calculation with mixed pass/fail findings.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.FAIL, + passed_findings=7, + failed_findings=3, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": {"req_attributes": [mock_requirement_attribute]}, + } + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # rate_i = 7/10 = 0.7 + # rfac_i = 1 + 0.25 * 4 = 2.0 + # numerator = 0.7 * 10 * 100 * 2.0 = 1400 + # denominator = 10 * 100 * 2.0 = 2000 + # score = (1400 / 2000) * 100 = 70.0 + assert result == 70.0 + + def test_calculate_threatscore_multiple_requirements( + self, threatscore_generator, basic_compliance_data + ): + """Test ThreatScore calculation with multiple requirements.""" + mock_attr_1 = Mock() + mock_attr_1.LevelOfRisk = 5 + mock_attr_1.Weight = 100 + + mock_attr_2 = Mock() + mock_attr_2.LevelOfRisk = 3 + mock_attr_2.Weight = 50 + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="High risk requirement", + status=StatusChoices.FAIL, + passed_findings=8, + failed_findings=2, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Low risk requirement", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + ), + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # REQ-001: rate=0.8, rfac=2.25, num=0.8*10*100*2.25=1800, den=10*100*2.25=2250 + # REQ-002: rate=1.0, rfac=1.75, num=1.0*5*50*1.75=437.5, den=5*50*1.75=437.5 + # total_num = 1800 + 437.5 = 2237.5 + # total_den = 2250 + 437.5 = 2687.5 + # score = (2237.5 / 2687.5) * 100 ≈ 83.26% + assert 83.0 < result < 84.0 + + def test_calculate_threatscore_zero_weight( + self, threatscore_generator, basic_compliance_data + ): + """Test ThreatScore calculation with zero weight.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 + mock_attr.Weight = 0 + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Zero weight requirement", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # With weight=0, denominator will be 0, should return 0.0 + assert result == 0.0 + + +# ============================================================================= +# Type Conversion Tests (Critical for bug fix validation) +# ============================================================================= + + +class TestTypeConversionSafety: + """Test suite for type conversion safety in ThreatScore calculations.""" + + def test_calculate_threatscore_with_string_risk_level( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_string_values, + ): + """Test that string LevelOfRisk is correctly converted to int.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="String values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_string_values] + } + }, + } + + # Should not raise TypeError: '<=' not supported between 'str' and 'int' + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # LevelOfRisk="5" -> 5, Weight="150" -> 150 + # rate_i = 0.5, rfac_i = 1 + 0.25*5 = 2.25 + # numerator = 0.5 * 10 * 150 * 2.25 = 1687.5 + # denominator = 10 * 150 * 2.25 = 3375 + # score = 50.0 + assert result == 50.0 + + def test_calculate_threatscore_with_invalid_string_values( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_invalid_values, + ): + """Test that invalid string values default to 0.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Invalid values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_invalid_values] + } + }, + } + + # Should not raise ValueError, should default to 0 + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # With weight=0 (from invalid string), denominator is 0 + assert result == 0.0 + + def test_calculate_threatscore_with_empty_values( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_empty_values, + ): + """Test that empty string values default to 0.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Empty values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_empty_values] + } + }, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # Empty strings should default to 0 + assert result == 0.0 + + def test_calculate_threatscore_with_none_values( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_none_values, + ): + """Test that None values default to 0.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="None values test", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_none_values] + } + }, + } + + result = threatscore_generator._calculate_threatscore(basic_compliance_data) + + # None values should default to 0 + assert result == 0.0 + + def test_critical_failed_requirements_with_string_risk_level( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_string_values, + ): + """Test that critical requirements filter works with string LevelOfRisk.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="High risk with string", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_string_values] + } + }, + } + + # Should not raise TypeError + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + # LevelOfRisk="5" should be converted to 5, which is >= 4 + assert len(result) == 1 + assert result[0]["id"] == "REQ-001" + assert result[0]["risk_level"] == 5 + assert result[0]["weight"] == 150 + + def test_critical_failed_requirements_with_invalid_risk_level( + self, + threatscore_generator, + basic_compliance_data, + mock_requirement_attribute_invalid_values, + ): + """Test that invalid LevelOfRisk is excluded from critical requirements.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Invalid risk level", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": { + "attributes": { + "req_attributes": [mock_requirement_attribute_invalid_values] + } + }, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + # Invalid string defaults to 0, which is < 4 + assert len(result) == 0 + + +# ============================================================================= +# Critical Failed Requirements Tests +# ============================================================================= + + +class TestCriticalFailedRequirements: + """Test suite for critical failed requirements identification.""" + + def test_get_critical_failed_no_failures( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that no critical requirements are returned when all pass.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passing requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 0 + + def test_get_critical_failed_below_threshold( + self, threatscore_generator, basic_compliance_data + ): + """Test that low risk failures are not included.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 2 # Below threshold of 4 + mock_attr.Weight = 100 + mock_attr.Title = "Low Risk" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Low risk failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 0 + + def test_get_critical_failed_at_threshold( + self, threatscore_generator, basic_compliance_data + ): + """Test that requirements at exactly the threshold are included.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 # Exactly at threshold + mock_attr.Weight = 100 + mock_attr.Title = "At Threshold" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="At threshold failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 1 + assert result[0]["risk_level"] == 4 + + def test_get_critical_failed_sorted_by_risk_and_weight( + self, threatscore_generator, basic_compliance_data + ): + """Test that critical requirements are sorted by risk level then weight.""" + mock_attr_1 = Mock() + mock_attr_1.LevelOfRisk = 4 + mock_attr_1.Weight = 150 + mock_attr_1.Title = "Mid risk, high weight" + mock_attr_1.Section = "1. IAM" + + mock_attr_2 = Mock() + mock_attr_2.LevelOfRisk = 5 + mock_attr_2.Weight = 50 + mock_attr_2.Title = "High risk, low weight" + mock_attr_2.Section = "2. Attack Surface" + + mock_attr_3 = Mock() + mock_attr_3.LevelOfRisk = 5 + mock_attr_3.Weight = 100 + mock_attr_3.Title = "High risk, mid weight" + mock_attr_3.Section = "3. Logging" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="First", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-002", + description="Second", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + RequirementData( + id="REQ-003", + description="Third", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=5, + total_findings=5, + ), + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + "REQ-003": {"attributes": {"req_attributes": [mock_attr_3]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 3 + # Sorted by (risk_level, weight) descending + # First: risk=5, weight=100 (REQ-003) + # Second: risk=5, weight=50 (REQ-002) + # Third: risk=4, weight=150 (REQ-001) + assert result[0]["id"] == "REQ-003" + assert result[1]["id"] == "REQ-002" + assert result[2]["id"] == "REQ-001" + + def test_get_critical_failed_manual_status_excluded( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that MANUAL status requirements are excluded.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Manual requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + result = threatscore_generator._get_critical_failed_requirements( + basic_compliance_data, min_risk_level=4 + ) + + assert len(result) == 0 + + +# ============================================================================= +# Section Score Chart Tests +# ============================================================================= + + +class TestSectionScoreChart: + """Test suite for section score chart generation.""" + + def test_create_section_chart_empty_data( + self, threatscore_generator, basic_compliance_data + ): + """Test chart creation with no requirements.""" + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = {} + + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + assert result.getvalue() # Should have content + + def test_create_section_chart_single_section( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test chart creation with a single section.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="IAM requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + + def test_create_section_chart_multiple_sections( + self, threatscore_generator, basic_compliance_data + ): + """Test chart creation with multiple sections.""" + mock_attr_1 = Mock() + mock_attr_1.LevelOfRisk = 4 + mock_attr_1.Weight = 100 + mock_attr_1.Section = "1. IAM" + + mock_attr_2 = Mock() + mock_attr_2.LevelOfRisk = 3 + mock_attr_2.Weight = 50 + mock_attr_2.Section = "2. Attack Surface" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="IAM requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ), + RequirementData( + id="REQ-002", + description="Attack Surface requirement", + status=StatusChoices.FAIL, + passed_findings=5, + failed_findings=5, + total_findings=10, + ), + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + } + + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + + def test_create_section_chart_no_findings_section_gets_100( + self, threatscore_generator, basic_compliance_data + ): + """Test that sections without findings get 100% score.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 + mock_attr.Weight = 100 + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="No findings requirement", + status=StatusChoices.MANUAL, + passed_findings=0, + failed_findings=0, + total_findings=0, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + # Chart should be created without errors + result = threatscore_generator._create_section_score_chart( + basic_compliance_data + ) + + assert isinstance(result, io.BytesIO) + + +# ============================================================================= +# Executive Summary Tests +# ============================================================================= + + +class TestExecutiveSummary: + """Test suite for executive summary generation.""" + + def test_executive_summary_contains_chart( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that executive summary contains a chart.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_executive_summary(basic_compliance_data) + + assert len(elements) > 0 + assert any(isinstance(e, Image) for e in elements) + + def test_executive_summary_contains_score_table( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test that executive summary contains a score table.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_executive_summary(basic_compliance_data) + + assert any(isinstance(e, Table) for e in elements) + + +# ============================================================================= +# Charts Section Tests +# ============================================================================= + + +class TestChartsSection: + """Test suite for charts section generation.""" + + def test_charts_section_no_critical_failures( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test charts section when no critical failures exist.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Passing requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + assert len(elements) > 0 + # Should contain success message + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "No critical failed requirements" in content or "Great job" in content + + def test_charts_section_with_critical_failures( + self, threatscore_generator, basic_compliance_data + ): + """Test charts section when critical failures exist.""" + mock_attr = Mock() + mock_attr.LevelOfRisk = 5 + mock_attr.Weight = 100 + mock_attr.Title = "Critical Failure" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Critical failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + assert len(elements) > 0 + # Should contain a table with critical requirements + assert any(isinstance(e, Table) for e in elements) + + def test_charts_section_starts_with_page_break( + self, threatscore_generator, basic_compliance_data + ): + """Test that charts section starts with a page break.""" + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = {} + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + assert len(elements) > 0 + assert isinstance(elements[0], PageBreak) + + def test_charts_section_respects_min_risk_level( + self, threatscore_generator, basic_compliance_data + ): + """Test that charts section respects the min_risk_level setting.""" + threatscore_generator._min_risk_level = 5 # Higher threshold + + mock_attr = Mock() + mock_attr.LevelOfRisk = 4 # Below the new threshold + mock_attr.Weight = 100 + mock_attr.Title = "Medium Risk" + mock_attr.Section = "1. IAM" + + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Medium risk failure", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=10, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr]}}, + } + + elements = threatscore_generator.create_charts_section(basic_compliance_data) + + # Should not contain a table since risk=4 < min=5 + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) == 0 + + +# ============================================================================= +# Requirements Index Tests +# ============================================================================= + + +class TestRequirementsIndex: + """Test suite for requirements index generation.""" + + def test_requirements_index_empty( + self, threatscore_generator, basic_compliance_data + ): + """Test requirements index with no requirements.""" + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = {} + + elements = threatscore_generator.create_requirements_index( + basic_compliance_data + ) + + assert len(elements) >= 1 # At least the header + assert isinstance(elements[0], Paragraph) + + def test_requirements_index_single_requirement( + self, threatscore_generator, basic_compliance_data, mock_requirement_attribute + ): + """Test requirements index with a single requirement.""" + basic_compliance_data.requirements = [ + RequirementData( + id="REQ-001", + description="Test requirement", + status=StatusChoices.PASS, + passed_findings=10, + failed_findings=0, + total_findings=10, + ) + ] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_requirement_attribute]}}, + } + + elements = threatscore_generator.create_requirements_index( + basic_compliance_data + ) + + assert len(elements) >= 2 # Header + at least section header + + def test_requirements_index_organized_by_section( + self, threatscore_generator, basic_compliance_data + ): + """Test that requirements index is organized by section.""" + mock_attr_1 = Mock() + mock_attr_1.Section = "1. IAM" + mock_attr_1.SubSection = "1.1 Access" + mock_attr_1.Title = "IAM Requirement" + + mock_attr_2 = Mock() + mock_attr_2.Section = "2. Attack Surface" + mock_attr_2.SubSection = "2.1 Exposure" + mock_attr_2.Title = "Attack Surface Requirement" + + basic_compliance_data.requirements = [] + basic_compliance_data.attributes_by_requirement_id = { + "REQ-001": {"attributes": {"req_attributes": [mock_attr_1]}}, + "REQ-002": {"attributes": {"req_attributes": [mock_attr_2]}}, + } + + elements = threatscore_generator.create_requirements_index( + basic_compliance_data + ) + + # Check that section headers are present + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + content = " ".join(str(p.text) for p in paragraphs) + assert "IAM" in content or "1." in content + + +# ============================================================================= +# Critical Requirements Table Tests +# ============================================================================= + + +class TestCriticalRequirementsTable: + """Test suite for critical requirements table generation.""" + + def test_create_table_single_requirement(self, threatscore_generator): + """Test table creation with a single requirement.""" + critical = [ + { + "id": "REQ-001", + "risk_level": 5, + "weight": 100, + "title": "Test Requirement", + "section": "1. IAM", + } + ] + + table = threatscore_generator._create_critical_requirements_table(critical) + + assert isinstance(table, Table) + + def test_create_table_truncates_long_titles(self, threatscore_generator): + """Test that long titles are truncated.""" + critical = [ + { + "id": "REQ-001", + "risk_level": 5, + "weight": 100, + "title": "A" * 100, # Very long title + "section": "1. IAM", + } + ] + + table = threatscore_generator._create_critical_requirements_table(critical) + + # Table should be created without errors + assert isinstance(table, Table) + + def test_create_table_multiple_requirements(self, threatscore_generator): + """Test table creation with multiple requirements.""" + critical = [ + { + "id": "REQ-001", + "risk_level": 5, + "weight": 150, + "title": "First", + "section": "1. IAM", + }, + { + "id": "REQ-002", + "risk_level": 4, + "weight": 100, + "title": "Second", + "section": "2. Attack Surface", + }, + ] + + table = threatscore_generator._create_critical_requirements_table(critical) + + assert isinstance(table, Table) diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 62c9a2b7d1..5f244e0103 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1,25 +1,69 @@ import csv import json +import re import uuid +from contextlib import contextmanager from datetime import datetime, timezone from io import StringIO from unittest.mock import MagicMock, patch import pytest from tasks.jobs.scan import ( + _ATTACK_SURFACE_MAPPING_CACHE, + _aggregate_findings_by_region, _copy_compliance_requirement_rows, + _create_compliance_summaries, _create_finding_delta, + _get_attack_surface_mapping_from_provider, + _normalized_compliance_key, _persist_compliance_requirement_rows, + _process_finding_micro_batch, _store_resources, + aggregate_attack_surface, + aggregate_category_counts, + aggregate_findings, create_compliance_requirements, perform_prowler_scan, + update_provider_compliance_scores, ) from tasks.utils import CustomEncoder from api.db_router import MainRouter from api.exceptions import ProviderConnectionError -from api.models import Finding, Provider, Resource, Scan, StateChoices, StatusChoices +from api.models import ( + Finding, + MuteRule, + Provider, + Resource, + Scan, + StateChoices, + StatusChoices, +) from prowler.lib.check.models import Severity +from prowler.lib.outputs.finding import Status + + +@contextmanager +def noop_rls_transaction(*args, **kwargs): + yield + + +class FakeFinding: + def __init__(self, **attrs): + self.metadata = attrs.pop("metadata", {}) + for key, value in attrs.items(): + setattr(self, key, value) + + self.resource_tags = getattr(self, "resource_tags", {}) + self.resource_metadata = getattr(self, "resource_metadata", {}) + self.resource_details = getattr(self, "resource_details", {}) + self.compliance = getattr(self, "compliance", {}) + self.raw = getattr(self, "raw", {}) + self.partition = getattr(self, "partition", "") + self.muted = getattr(self, "muted", False) + + def get_metadata(self): + return self.metadata @pytest.mark.django_db @@ -739,10 +783,1008 @@ class TestPerformScan: # Assert that failed_findings_count was reset to 0 during the scan assert resource.failed_findings_count == 0 + def test_perform_prowler_scan_with_active_mute_rules( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test active MuteRule mutes findings with correct reason""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Create active MuteRule with specific finding UIDs + mute_rule_reason = "Accepted risk - production exception" + finding_uid_1 = "finding_to_mute_1" + finding_uid_2 = "finding_to_mute_2" + + MuteRule.objects.create( + tenant_id=tenant_id, + name="Production Exception Rule", + reason=mute_rule_reason, + enabled=True, + finding_uids=[finding_uid_1, finding_uid_2], + ) + + # Mock findings: one FAIL and one PASS, both should be muted + muted_fail_finding = MagicMock() + muted_fail_finding.uid = finding_uid_1 + muted_fail_finding.status = StatusChoices.FAIL + muted_fail_finding.status_extended = "muted fail" + muted_fail_finding.severity = Severity.high + muted_fail_finding.check_id = "muted_fail_check" + muted_fail_finding.get_metadata.return_value = {"key": "value"} + muted_fail_finding.resource_uid = "resource_uid_1" + muted_fail_finding.resource_name = "resource_1" + muted_fail_finding.region = "us-east-1" + muted_fail_finding.service_name = "ec2" + muted_fail_finding.resource_type = "instance" + muted_fail_finding.resource_tags = {} + muted_fail_finding.muted = False + muted_fail_finding.raw = {} + muted_fail_finding.resource_metadata = {} + muted_fail_finding.resource_details = {} + muted_fail_finding.partition = "aws" + muted_fail_finding.compliance = {} + + muted_pass_finding = MagicMock() + muted_pass_finding.uid = finding_uid_2 + muted_pass_finding.status = StatusChoices.PASS + muted_pass_finding.status_extended = "muted pass" + muted_pass_finding.severity = Severity.medium + muted_pass_finding.check_id = "muted_pass_check" + muted_pass_finding.get_metadata.return_value = {"key": "value"} + muted_pass_finding.resource_uid = "resource_uid_2" + muted_pass_finding.resource_name = "resource_2" + muted_pass_finding.region = "us-east-1" + muted_pass_finding.service_name = "s3" + muted_pass_finding.resource_type = "bucket" + muted_pass_finding.resource_tags = {} + muted_pass_finding.muted = False + muted_pass_finding.raw = {} + muted_pass_finding.resource_metadata = {} + muted_pass_finding.resource_details = {} + muted_pass_finding.partition = "aws" + muted_pass_finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [ + (100, [muted_fail_finding, muted_pass_finding]) + ] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Verify findings are muted with correct reason + fail_finding_db = Finding.objects.get(uid=finding_uid_1) + pass_finding_db = Finding.objects.get(uid=finding_uid_2) + + assert fail_finding_db.muted + assert fail_finding_db.muted_reason == mute_rule_reason + assert fail_finding_db.muted_at is not None + + assert pass_finding_db.muted + assert pass_finding_db.muted_reason == mute_rule_reason + assert pass_finding_db.muted_at is not None + + # Verify failed_findings_count is 0 for muted FAIL finding + resource_1 = Resource.objects.get(uid="resource_uid_1") + assert resource_1.failed_findings_count == 0 + + def test_perform_prowler_scan_with_inactive_mute_rules( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test inactive MuteRule does not mute findings""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Create inactive MuteRule + finding_uid = "finding_inactive_rule" + MuteRule.objects.create( + tenant_id=tenant_id, + name="Inactive Rule", + reason="Should not apply", + enabled=False, + finding_uids=[finding_uid], + ) + + # Mock FAIL finding + fail_finding = MagicMock() + fail_finding.uid = finding_uid + fail_finding.status = StatusChoices.FAIL + fail_finding.status_extended = "test fail" + fail_finding.severity = Severity.high + fail_finding.check_id = "fail_check" + fail_finding.get_metadata.return_value = {"key": "value"} + fail_finding.resource_uid = "resource_uid_inactive" + fail_finding.resource_name = "resource_inactive" + fail_finding.region = "us-east-1" + fail_finding.service_name = "ec2" + fail_finding.resource_type = "instance" + fail_finding.resource_tags = {} + fail_finding.muted = False + fail_finding.raw = {} + fail_finding.resource_metadata = {} + fail_finding.resource_details = {} + fail_finding.partition = "aws" + fail_finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, [fail_finding])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Verify finding is NOT muted + finding_db = Finding.objects.get(uid=finding_uid) + assert not finding_db.muted + assert finding_db.muted_reason is None + assert finding_db.muted_at is None + + # Verify failed_findings_count increments for FAIL finding + resource = Resource.objects.get(uid="resource_uid_inactive") + assert resource.failed_findings_count == 1 + + def test_perform_prowler_scan_mutelist_overrides_mute_rules( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test mutelist processor takes precedence over MuteRule""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Create active MuteRule + finding_uid = "finding_both_rules" + MuteRule.objects.create( + tenant_id=tenant_id, + name="Manual Mute Rule", + reason="Muted by manual rule", + enabled=True, + finding_uids=[finding_uid], + ) + + # Mock finding with mutelist processor muted=True + muted_finding = MagicMock() + muted_finding.uid = finding_uid + muted_finding.status = StatusChoices.FAIL + muted_finding.status_extended = "test" + muted_finding.severity = Severity.high + muted_finding.check_id = "test_check" + muted_finding.get_metadata.return_value = {"key": "value"} + muted_finding.resource_uid = "resource_both" + muted_finding.resource_name = "resource_both" + muted_finding.region = "us-east-1" + muted_finding.service_name = "ec2" + muted_finding.resource_type = "instance" + muted_finding.resource_tags = {} + muted_finding.muted = True + muted_finding.raw = {} + muted_finding.resource_metadata = {} + muted_finding.resource_details = {} + muted_finding.partition = "aws" + muted_finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, [muted_finding])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Verify mutelist reason takes precedence + finding_db = Finding.objects.get(uid=finding_uid) + assert finding_db.muted + assert finding_db.muted_reason == "Muted by mutelist" + assert finding_db.muted_at is not None + + # Verify failed_findings_count is 0 + resource = Resource.objects.get(uid="resource_both") + assert resource.failed_findings_count == 0 + + def test_perform_prowler_scan_mute_rules_multiple_findings( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test MuteRule with multiple finding UIDs mutes all findings""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Create MuteRule with multiple finding UIDs + mute_rule_reason = "Bulk exception for dev environment" + finding_uids = [ + "bulk_finding_1", + "bulk_finding_2", + "bulk_finding_3", + "bulk_finding_4", + ] + MuteRule.objects.create( + tenant_id=tenant_id, + name="Bulk Mute Rule", + reason=mute_rule_reason, + enabled=True, + finding_uids=finding_uids, + ) + + # Mock multiple findings with mixed statuses + findings = [] + for i, uid in enumerate(finding_uids): + finding = MagicMock() + finding.uid = uid + finding.status = ( + StatusChoices.FAIL if i % 2 == 0 else StatusChoices.PASS + ) + finding.status_extended = f"test {i}" + finding.severity = Severity.medium + finding.check_id = f"check_{i}" + finding.get_metadata.return_value = {"key": f"value_{i}"} + finding.resource_uid = f"resource_bulk_{i}" + finding.resource_name = f"resource_{i}" + finding.region = "us-west-2" + finding.service_name = "lambda" + finding.resource_type = "function" + finding.resource_tags = {} + finding.muted = False + finding.raw = {} + finding.resource_metadata = {} + finding.resource_details = {} + finding.partition = "aws" + finding.compliance = {} + findings.append(finding) + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, findings)] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-west-2"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Verify all findings are muted with same reason + for uid in finding_uids: + finding_db = Finding.objects.get(uid=uid) + assert finding_db.muted + assert finding_db.muted_reason == mute_rule_reason + assert finding_db.muted_at is not None + + # Verify all resources have failed_findings_count = 0 + for i in range(len(finding_uids)): + resource = Resource.objects.get(uid=f"resource_bulk_{i}") + assert resource.failed_findings_count == 0 + + def test_perform_prowler_scan_mute_rules_error_handling( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test scan continues when MuteRule loading fails""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + patch("api.models.MuteRule.objects.filter") as mock_mute_rule_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Mock MuteRule.objects.filter to raise exception + mock_mute_rule_filter.side_effect = Exception("Database error") + + # Mock finding + finding = MagicMock() + finding.uid = "finding_error_handling" + finding.status = StatusChoices.FAIL + finding.status_extended = "test" + finding.severity = Severity.high + finding.check_id = "test_check" + finding.get_metadata.return_value = {"key": "value"} + finding.resource_uid = "resource_error" + finding.resource_name = "resource_error" + finding.region = "us-east-1" + finding.service_name = "ec2" + finding.resource_type = "instance" + finding.resource_tags = {} + finding.muted = False + finding.raw = {} + finding.resource_metadata = {} + finding.resource_details = {} + finding.partition = "aws" + finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, [finding])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test - should not raise + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Verify scan completed successfully + scan.refresh_from_db() + assert scan.state == StateChoices.COMPLETED + + # Verify finding is not muted (mute_rules_cache was empty dict) + finding_db = Finding.objects.get(uid="finding_error_handling") + assert not finding_db.muted + assert finding_db.muted_reason is None + + # Verify failed_findings_count increments + resource = Resource.objects.get(uid="resource_error") + assert resource.failed_findings_count == 1 + + def test_perform_prowler_scan_muted_at_timestamp( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test muted_at timestamp is set correctly for muted findings""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Create active MuteRule + finding_uid = "finding_timestamp_test" + MuteRule.objects.create( + tenant_id=tenant_id, + name="Timestamp Test Rule", + reason="Testing timestamp", + enabled=True, + finding_uids=[finding_uid], + ) + + # Mock finding + finding = MagicMock() + finding.uid = finding_uid + finding.status = StatusChoices.FAIL + finding.status_extended = "test" + finding.severity = Severity.high + finding.check_id = "test_check" + finding.get_metadata.return_value = {"key": "value"} + finding.resource_uid = "resource_timestamp" + finding.resource_name = "resource_timestamp" + finding.region = "us-east-1" + finding.service_name = "ec2" + finding.resource_type = "instance" + finding.resource_tags = {} + finding.muted = False + finding.raw = {} + finding.resource_metadata = {} + finding.resource_details = {} + finding.partition = "aws" + finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, [finding])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Capture time before and after scan + before_scan = datetime.now(timezone.utc) + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + after_scan = datetime.now(timezone.utc) + + # Verify muted_at is within the scan time window + finding_db = Finding.objects.get(uid=finding_uid) + assert finding_db.muted + assert finding_db.muted_at is not None + assert before_scan <= finding_db.muted_at <= after_scan + # TODO Add tests for aggregations +@pytest.mark.django_db +class TestProcessFindingMicroBatch: + def test_process_finding_micro_batch_creates_records_and_updates_caches( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = scan.provider + + finding = FakeFinding( + uid="finding-new", + status=StatusChoices.PASS, + status_extended="all good", + severity=Severity.low, + check_id="s3_public_buckets", + resource_uid="arn:aws:s3:::bucket-1", + resource_name="bucket-1", + region="us-east-1", + service_name="s3", + resource_type="bucket", + resource_tags={"env": "dev", "team": "security"}, + resource_metadata={"owner": "secops"}, + resource_details={"arn": "arn:aws:s3:::bucket-1"}, + partition="aws", + raw={"status": "PASS"}, + compliance={"cis": {"1.1": "PASS"}}, + metadata={"source": "prowler"}, + muted=False, + ) + + resource_cache = {} + tag_cache = {} + last_status_cache = {} + resource_failed_findings_cache = {} + unique_resources: set[tuple[str, str]] = set() + scan_resource_cache: set[tuple[str, str, str, str]] = set() + mute_rules_cache = {} + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + + with ( + patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), + patch("api.db_utils.rls_transaction", new=noop_rls_transaction), + ): + _process_finding_micro_batch( + str(tenant.id), + [finding], + scan, + provider, + resource_cache, + tag_cache, + last_status_cache, + resource_failed_findings_cache, + unique_resources, + scan_resource_cache, + mute_rules_cache, + scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, + ) + + created_finding = Finding.objects.get(uid=finding.uid) + resource = Resource.objects.get(uid=finding.resource_uid) + + assert created_finding.scan_id == scan.id + assert created_finding.status == StatusChoices.PASS + assert created_finding.delta == Finding.DeltaChoices.NEW + assert created_finding.muted is False + assert created_finding.check_metadata == finding.metadata + assert created_finding.resource_regions == [finding.region] + assert created_finding.resource_services == [finding.service_name] + assert created_finding.resource_types == [finding.resource_type] + assert created_finding.first_seen_at is not None + assert created_finding.compliance == finding.compliance + + assert resource.provider_id == provider.id + assert resource.region == finding.region + assert resource.service == finding.service_name + assert resource.type == finding.resource_type + assert resource.name == finding.resource_name + assert resource.metadata == json.dumps( + finding.resource_metadata, cls=CustomEncoder + ) + assert resource.details == f"{finding.resource_details}" + assert resource.partition == finding.partition + assert set(resource.tags.values_list("key", "value")) == set( + finding.resource_tags.items() + ) + assert resource.findings.filter(uid=finding.uid).exists() + + assert resource_cache[finding.resource_uid].id == resource.id + assert resource_failed_findings_cache[finding.resource_uid] == 0 + assert (resource.uid, resource.region) in unique_resources + assert ( + str(resource.id), + resource.service, + resource.region, + resource.type, + ) in scan_resource_cache + assert set(tag_cache.keys()) == set(finding.resource_tags.items()) + + def test_process_finding_micro_batch_manual_mute_and_dirty_resources( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = scan.provider + + existing_resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="arn:aws:ec2:us-east-1:123456789012:instance/i-001", + name="i-001", + region="us-east-1", + service="ec2", + type="instance", + metadata=json.dumps({"old": "meta"}), + details="old-details", + partition="aws-old", + ) + + previous_first_seen = datetime(2024, 1, 1, tzinfo=timezone.utc) + + finding = FakeFinding( + uid="finding-muted", + status=StatusChoices.FAIL, + status_extended="failing", + severity=Severity.high, + check_id="ec2_public_instance", + resource_uid=existing_resource.uid, + resource_name=existing_resource.name, + region="eu-west-1", + service_name="eks", + resource_type="cluster", + resource_tags={"team": "devsec"}, + resource_metadata={"owner": "platform"}, + resource_details={"id": existing_resource.name}, + partition="aws", + raw={"status": "FAIL"}, + compliance={"cis": {"1.2": "FAIL"}}, + metadata={"source": "prowler"}, + muted=False, + ) + + resource_cache = {existing_resource.uid: existing_resource} + tag_cache = {} + last_status_cache = {finding.uid: (StatusChoices.PASS, previous_first_seen)} + resource_failed_findings_cache = {existing_resource.uid: 2} + unique_resources: set[tuple[str, str]] = set() + scan_resource_cache: set[tuple[str, str, str, str]] = set() + mute_rules_cache = {finding.uid: "Muted via rule"} + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + + with ( + patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), + patch("api.db_utils.rls_transaction", new=noop_rls_transaction), + ): + _process_finding_micro_batch( + str(tenant.id), + [finding], + scan, + provider, + resource_cache, + tag_cache, + last_status_cache, + resource_failed_findings_cache, + unique_resources, + scan_resource_cache, + mute_rules_cache, + scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, + ) + + existing_resource.refresh_from_db() + created_finding = Finding.objects.get(uid=finding.uid) + + assert created_finding.delta == Finding.DeltaChoices.CHANGED + assert created_finding.status == StatusChoices.FAIL + assert created_finding.muted is True + assert created_finding.muted_reason == "Muted via rule" + assert created_finding.muted_at is not None + assert created_finding.first_seen_at == previous_first_seen + assert created_finding.compliance == finding.compliance + assert created_finding.resource_regions == [finding.region] + assert created_finding.resource_services == [finding.service_name] + assert created_finding.resource_types == [finding.resource_type] + assert created_finding.scan_id == scan.id + + assert resource_failed_findings_cache[finding.resource_uid] == 2 + assert (finding.resource_uid, finding.region) in unique_resources + assert ( + str(existing_resource.id), + finding.service_name, + finding.region, + finding.resource_type, + ) in scan_resource_cache + + assert existing_resource.region == finding.region + assert existing_resource.service == finding.service_name + assert existing_resource.type == finding.resource_type + assert existing_resource.metadata == json.dumps( + finding.resource_metadata, cls=CustomEncoder + ) + assert existing_resource.details == f"{finding.resource_details}" + assert existing_resource.partition == finding.partition + assert set(existing_resource.tags.values_list("key", "value")) == { + ("team", "devsec") + } + assert existing_resource.findings.filter(uid=finding.uid).exists() + + assert resource_cache[finding.resource_uid].region == finding.region + assert resource_cache[finding.resource_uid].service == finding.service_name + assert tag_cache.keys() == {("team", "devsec")} + + def test_process_finding_micro_batch_skips_long_uid( + self, tenants_fixture, scans_fixture + ): + """Test that findings with UID > 300 chars are skipped (temporary workaround).""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = scan.provider + + # Create a finding with UID > 300 chars + long_uid = ( + "prowler-aws-ec2_instance_public_ip-123456789012-us-east-1-" + "x" * 250 + ) + assert len(long_uid) > 300 + + finding_with_long_uid = FakeFinding( + uid=long_uid, + status=StatusChoices.FAIL, + status_extended="public instance", + severity=Severity.high, + check_id="ec2_instance_public_ip", + resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-long", + resource_name="i-long-uid-instance", + region="us-east-1", + service_name="ec2", + resource_type="instance", + resource_tags={}, + resource_metadata={}, + resource_details={}, + partition="aws", + raw={}, + compliance={}, + metadata={}, + muted=False, + ) + + # Create a normal finding that should be processed + normal_finding = FakeFinding( + uid="finding-normal", + status=StatusChoices.PASS, + status_extended="all good", + severity=Severity.low, + check_id="s3_bucket_encryption", + resource_uid="arn:aws:s3:::bucket-normal", + resource_name="bucket-normal", + region="us-east-1", + service_name="s3", + resource_type="bucket", + resource_tags={}, + resource_metadata={}, + resource_details={}, + partition="aws", + raw={}, + compliance={}, + metadata={}, + muted=False, + ) + + resource_cache = {} + tag_cache = {} + last_status_cache = {} + resource_failed_findings_cache = {} + unique_resources: set[tuple[str, str]] = set() + scan_resource_cache: set[tuple[str, str, str, str]] = set() + mute_rules_cache = {} + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + + with ( + patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), + patch("api.db_utils.rls_transaction", new=noop_rls_transaction), + patch("tasks.jobs.scan.logger") as mock_logger, + ): + _process_finding_micro_batch( + str(tenant.id), + [finding_with_long_uid, normal_finding], + scan, + provider, + resource_cache, + tag_cache, + last_status_cache, + resource_failed_findings_cache, + unique_resources, + scan_resource_cache, + mute_rules_cache, + scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, + ) + + # Verify the long UID finding was NOT created + assert not Finding.objects.filter(uid=long_uid).exists() + + # Verify the normal finding WAS created + assert Finding.objects.filter(uid=normal_finding.uid).exists() + + # Verify logging was called for skipped finding + assert mock_logger.warning.called + warning_calls = [str(call) for call in mock_logger.warning.call_args_list] + assert any( + "Skipping finding with UID exceeding 300 characters" in str(call) + for call in warning_calls + ) + assert any( + f"Scan {scan.id}: Skipped 1 finding(s)" in str(call) + for call in warning_calls + ) + + def test_process_finding_micro_batch_tracks_categories( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = scan.provider + + finding1 = FakeFinding( + uid="finding-cat-1", + status=StatusChoices.PASS, + status_extended="all good", + severity=Severity.low, + check_id="genai_check", + resource_uid="arn:aws:bedrock:::model/test", + resource_name="test-model", + region="us-east-1", + service_name="bedrock", + resource_type="model", + resource_tags={}, + resource_metadata={}, + resource_details={}, + partition="aws", + raw={}, + compliance={}, + metadata={"categories": ["gen-ai", "security"]}, + muted=False, + ) + + finding2 = FakeFinding( + uid="finding-cat-2", + status=StatusChoices.FAIL, + status_extended="bad", + severity=Severity.high, + check_id="iam_check", + resource_uid="arn:aws:iam:::user/test", + resource_name="test-user", + region="us-east-1", + service_name="iam", + resource_type="user", + resource_tags={}, + resource_metadata={}, + resource_details={}, + partition="aws", + raw={}, + compliance={}, + metadata={"categories": ["security", "iam"]}, + muted=False, + ) + + resource_cache = {} + tag_cache = {} + last_status_cache = {} + resource_failed_findings_cache = {} + unique_resources: set[tuple[str, str]] = set() + scan_resource_cache: set[tuple[str, str, str, str]] = set() + mute_rules_cache = {} + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + + with ( + patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), + patch("api.db_utils.rls_transaction", new=noop_rls_transaction), + ): + _process_finding_micro_batch( + str(tenant.id), + [finding1, finding2], + scan, + provider, + resource_cache, + tag_cache, + last_status_cache, + resource_failed_findings_cache, + unique_resources, + scan_resource_cache, + mute_rules_cache, + scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, + ) + + # finding1: PASS, severity=low, categories=["gen-ai", "security"] + # finding2: FAIL, severity=high, categories=["security", "iam"] + # Keys are (category, severity) tuples + assert set(scan_categories_cache.keys()) == { + ("gen-ai", "low"), + ("security", "low"), + ("security", "high"), + ("iam", "high"), + } + assert scan_categories_cache[("gen-ai", "low")] == { + "total": 1, + "failed": 0, + "new_failed": 0, + } + assert scan_categories_cache[("security", "low")] == { + "total": 1, + "failed": 0, + "new_failed": 0, + } + assert scan_categories_cache[("security", "high")] == { + "total": 1, + "failed": 1, + "new_failed": 1, + } + assert scan_categories_cache[("iam", "high")] == { + "total": 1, + "failed": 1, + "new_failed": 1, + } + + created_finding1 = Finding.objects.get(uid="finding-cat-1") + created_finding2 = Finding.objects.get(uid="finding-cat-2") + assert set(created_finding1.categories) == {"gen-ai", "security"} + assert set(created_finding2.categories) == {"security", "iam"} + + @pytest.mark.django_db class TestCreateComplianceRequirements: def test_create_compliance_requirements_success( @@ -753,12 +1795,9 @@ class TestCreateComplianceRequirements: findings_fixture, resources_fixture, ): - with ( - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch("tasks.jobs.scan.generate_scan_compliance"), - ): + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: tenant_id = str(tenants_fixture[0].id) scan_id = str(scans_fixture[0].id) @@ -769,6 +1808,7 @@ class TestCreateComplianceRequirements: "requirements": { "1.1": { "description": "Ensure root access key does not exist", + "checks": {"test_check_id": None}, "checks_status": { "pass": 0, "fail": 0, @@ -779,6 +1819,7 @@ class TestCreateComplianceRequirements: }, "1.2": { "description": "Ensure MFA is enabled for root account", + "checks": {"test_check_id": None}, "checks_status": { "pass": 0, "fail": 1, @@ -804,12 +1845,9 @@ class TestCreateComplianceRequirements: providers_fixture, findings_fixture, ): - with ( - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch("tasks.jobs.scan.generate_scan_compliance"), - ): + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: tenant_id = str(tenants_fixture[0].id) scan_id = str(scans_fixture[0].id) @@ -820,6 +1858,7 @@ class TestCreateComplianceRequirements: "requirements": { "req_1": { "description": "Test Requirement 1", + "checks": {"test_check_id": None}, "checks_status": { "pass": 2, "fail": 1, @@ -843,12 +1882,9 @@ class TestCreateComplianceRequirements: providers_fixture, findings_fixture, ): - with ( - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch("tasks.jobs.scan.generate_scan_compliance"), - ): + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: tenant = tenants_fixture[0] scan = scans_fixture[0] provider = providers_fixture[0] @@ -868,6 +1904,7 @@ class TestCreateComplianceRequirements: "requirements": { "1.1": { "description": "Test requirement", + "checks": {"test_check_id": None}, "checks_status": { "pass": 0, "fail": 0, @@ -891,12 +1928,9 @@ class TestCreateComplianceRequirements: providers_fixture, findings_fixture, ): - with ( - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch("tasks.jobs.scan.generate_scan_compliance"), - ): + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: tenant_id = str(tenants_fixture[0].id) scan_id = str(scans_fixture[0].id) @@ -925,18 +1959,41 @@ class TestCreateComplianceRequirements: create_compliance_requirements(tenant_id, scan_id) def test_create_compliance_requirements_check_status_priority( - self, tenants_fixture, scans_fixture, providers_fixture, findings_fixture + self, tenants_fixture, scans_fixture, findings_fixture ): with ( patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, patch( - "tasks.jobs.scan.generate_scan_compliance" - ) as mock_generate_compliance, + "tasks.jobs.scan._persist_compliance_requirement_rows" + ) as mock_persist, + patch("tasks.jobs.scan._create_compliance_summaries"), ): tenant_id = str(tenants_fixture[0].id) - scan_id = str(scans_fixture[0].id) + scan = scans_fixture[0] + scan_id = str(scan.id) + existing_finding = findings_fixture[0] + + pass_finding = Finding.objects.create( + tenant_id=scan.tenant_id, + uid="pass-finding", + scan=scan, + delta=None, + status=Status.PASS, + status_extended="pass status", + impact=Severity.low, + impact_extended="", + severity=Severity.low, + raw_result={"status": Status.PASS}, + tags={}, + check_id=existing_finding.check_id, + check_metadata={"CheckId": existing_finding.check_id}, + first_seen_at=datetime.now(timezone.utc), + muted=False, + ) + resource = existing_finding.resources.first() + pass_finding.add_resources([resource]) mock_compliance_template.__getitem__.return_value = { "cis_1.4_aws": { @@ -945,6 +2002,7 @@ class TestCreateComplianceRequirements: "requirements": { "1.1": { "description": "Test requirement", + "checks": {existing_finding.check_id: None}, "checks_status": { "pass": 0, "fail": 0, @@ -959,7 +2017,12 @@ class TestCreateComplianceRequirements: create_compliance_requirements(tenant_id, scan_id) - assert mock_generate_compliance.call_count == 1 + mock_persist.assert_called_once() + persisted_rows = mock_persist.call_args[0][1] + requirement_row = next( + row for row in persisted_rows if row["requirement_id"] == "1.1" + ) + assert requirement_row["requirement_status"] == "FAIL" def test_create_compliance_requirements_multiple_regions( self, @@ -968,12 +2031,9 @@ class TestCreateComplianceRequirements: providers_fixture, findings_fixture, ): - with ( - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch("tasks.jobs.scan.generate_scan_compliance"), - ): + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: tenant_id = str(tenants_fixture[0].id) scan_id = str(scans_fixture[0].id) @@ -984,6 +2044,7 @@ class TestCreateComplianceRequirements: "requirements": { "req_1": { "description": "Test Requirement 1", + "checks": {"test_check_id": None}, "checks_status": { "pass": 2, "fail": 0, @@ -1008,12 +2069,9 @@ class TestCreateComplianceRequirements: providers_fixture, findings_fixture, ): - with ( - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch("tasks.jobs.scan.generate_scan_compliance"), - ): + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: tenant_id = str(tenants_fixture[0].id) scan_id = str(scans_fixture[0].id) @@ -1024,6 +2082,7 @@ class TestCreateComplianceRequirements: "requirements": { "req_1": { "description": "Test Requirement 1", + "checks": {"test_check_id": None}, "checks_status": { "pass": 2, "fail": 0, @@ -1034,6 +2093,7 @@ class TestCreateComplianceRequirements: }, "req_2": { "description": "Test Requirement 2", + "checks": {"test_check_id": None}, "checks_status": { "pass": 1, "fail": 1, @@ -1820,3 +2880,1282 @@ class TestComplianceRequirementCopy: assert obj.failed_checks == 5 assert obj.total_checks == 15 assert obj.scan_id == scan_id + + +@pytest.mark.django_db +class TestCreateComplianceSummaries: + """Test _create_compliance_summaries function.""" + + @patch("tasks.jobs.scan.ComplianceOverviewSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_create_compliance_summaries_mixed_statuses( + self, mock_rls_transaction, mock_bulk_create + ): + """Test creating summaries with mixed requirement statuses (PASS/FAIL/MANUAL).""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + # Simulate pre-computed requirement statuses + requirement_statuses = { + ("compliance1", "req1"): { + "fail_count": 0, + "pass_count": 5, + "total_count": 5, + }, + ("compliance1", "req2"): { + "fail_count": 2, + "pass_count": 3, + "total_count": 5, + }, + ("compliance1", "req3"): { + "fail_count": 0, + "pass_count": 3, + "total_count": 5, + }, + ("compliance2", "req1"): { + "fail_count": 1, + "pass_count": 0, + "total_count": 5, + }, + } + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) + + mock_rls_transaction.assert_called_once_with(tenant_id) + mock_bulk_create.assert_called_once() + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert len(objects) == 2 + assert kwargs["batch_size"] == 500 + + # Find compliance1 and compliance2 summaries + comp1 = next(obj for obj in objects if obj.compliance_id == "compliance1") + comp2 = next(obj for obj in objects if obj.compliance_id == "compliance2") + + # compliance1: req1=PASS, req2=FAIL (has fail_count), req3=MANUAL (pass < total) + assert comp1.total_requirements == 3 + assert comp1.requirements_passed == 1 + assert comp1.requirements_failed == 1 + assert comp1.requirements_manual == 1 + + # compliance2: req1=FAIL (has fail_count) + assert comp2.total_requirements == 1 + assert comp2.requirements_passed == 0 + assert comp2.requirements_failed == 1 + assert comp2.requirements_manual == 0 + + @patch("tasks.jobs.scan.ComplianceOverviewSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_create_compliance_summaries_empty_input( + self, mock_rls_transaction, mock_bulk_create + ): + """Test with empty requirement_statuses dict - should not create any summaries.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + requirement_statuses = {} + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) + + # Should not call bulk_create with empty list + mock_bulk_create.assert_not_called() + + @patch("tasks.jobs.scan.ComplianceOverviewSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_create_compliance_summaries_all_pass( + self, mock_rls_transaction, mock_bulk_create + ): + """Test creating summaries when all requirements pass.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + requirement_statuses = { + ("comp1", "req1"): {"fail_count": 0, "pass_count": 10, "total_count": 10}, + ("comp1", "req2"): {"fail_count": 0, "pass_count": 5, "total_count": 5}, + } + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert len(objects) == 1 + + obj = objects[0] + assert obj.compliance_id == "comp1" + assert obj.total_requirements == 2 + assert obj.requirements_passed == 2 + assert obj.requirements_failed == 0 + assert obj.requirements_manual == 0 + + @patch("tasks.jobs.scan.ComplianceOverviewSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_create_compliance_summaries_all_fail( + self, mock_rls_transaction, mock_bulk_create + ): + """Test creating summaries when all requirements fail.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + requirement_statuses = { + ("comp1", "req1"): {"fail_count": 3, "pass_count": 7, "total_count": 10}, + ("comp1", "req2"): {"fail_count": 1, "pass_count": 4, "total_count": 5}, + } + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert len(objects) == 1 + + obj = objects[0] + assert obj.compliance_id == "comp1" + assert obj.total_requirements == 2 + assert obj.requirements_passed == 0 + assert obj.requirements_failed == 2 + assert obj.requirements_manual == 0 + + @patch("tasks.jobs.scan.ComplianceOverviewSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_create_compliance_summaries_correct_aggregation( + self, mock_rls_transaction, mock_bulk_create + ): + """Test that requirements are correctly aggregated to compliance level.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + requirement_statuses = { + ("compliance_a", "req1"): { + "fail_count": 0, + "pass_count": 10, + "total_count": 10, + }, + ("compliance_a", "req2"): { + "fail_count": 1, + "pass_count": 9, + "total_count": 10, + }, + ("compliance_a", "req3"): { + "fail_count": 0, + "pass_count": 5, + "total_count": 10, + }, + ("compliance_b", "req1"): { + "fail_count": 0, + "pass_count": 8, + "total_count": 8, + }, + } + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert len(objects) == 2 + + comp_a = next(obj for obj in objects if obj.compliance_id == "compliance_a") + comp_b = next(obj for obj in objects if obj.compliance_id == "compliance_b") + + # compliance_a: req1=PASS, req2=FAIL, req3=MANUAL + assert comp_a.total_requirements == 3 + assert comp_a.requirements_passed == 1 + assert comp_a.requirements_failed == 1 + assert comp_a.requirements_manual == 1 + + # compliance_b: req1=PASS + assert comp_b.total_requirements == 1 + assert comp_b.requirements_passed == 1 + assert comp_b.requirements_failed == 0 + assert comp_b.requirements_manual == 0 + + +@pytest.mark.django_db +class TestNormalizedComplianceKey: + """Test _normalized_compliance_key function.""" + + def test_normalized_compliance_key_normal_strings(self): + """Test normalization with normal framework and version strings.""" + result = _normalized_compliance_key("AWS-Foundational-Security", "2.0") + assert result == "awsfoundationalsecurity20" + + def test_normalized_compliance_key_with_underscores(self): + """Test normalization removes underscores.""" + result = _normalized_compliance_key("CIS_AWS_Foundations", "1_5_0") + assert result == "cisawsfoundations150" + + def test_normalized_compliance_key_none_framework(self): + """Test normalization with None framework.""" + result = _normalized_compliance_key(None, "1.0") + assert result == "10" + + def test_normalized_compliance_key_none_version(self): + """Test normalization with None version.""" + result = _normalized_compliance_key("AWS-Security", None) + assert result == "awssecurity" + + def test_normalized_compliance_key_both_none(self): + """Test normalization with both framework and version as None.""" + result = _normalized_compliance_key(None, None) + assert result == "" + + def test_normalized_compliance_key_empty_strings(self): + """Test normalization with empty strings.""" + result = _normalized_compliance_key("", "") + assert result == "" + + def test_normalized_compliance_key_mixed_case(self): + """Test normalization lowercases strings.""" + result = _normalized_compliance_key("AWS-FOUNDATIONAL", "V2.0") + assert result == "awsfoundationalv20" + + def test_normalized_compliance_key_complex_pattern(self): + """Test normalization with complex patterns.""" + result = _normalized_compliance_key("PCI-DSS_v3-2-1", "2023-Update") + assert result == "pcidssv3212023update" + + +@pytest.mark.django_db +class TestAggregateFindings: + """Test aggregate_findings function.""" + + @patch("tasks.jobs.scan.ScanSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_creates_scan_summaries( + self, + mock_rls_transaction, + mock_bulk_create, + tenants_fixture, + scans_fixture, + findings_fixture, + ): + """Test that aggregate_findings creates ScanSummary records.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + aggregate_findings(str(tenant.id), str(scan.id)) + + mock_rls_transaction.assert_called() + mock_bulk_create.assert_called_once() + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert kwargs["batch_size"] == 3000 + # Should have created at least one summary + assert len(objects) > 0 + + @patch("tasks.jobs.scan.Finding.objects.filter") + @patch("tasks.jobs.scan.ScanSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_excludes_muted_from_counts( + self, mock_rls_transaction, mock_bulk_create, mock_findings_filter + ): + """Test that muted findings are excluded from fail/pass counts but counted separately.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + # Mock findings queryset + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [ + { + "check_id": "check1", + "resources__service": "s3", + "severity": "high", + "resources__region": "us-east-1", + "fail": 5, + "_pass": 10, + "muted_count": 3, + "total": 18, + "new": 2, + "changed": 1, + "unchanged": 12, + "fail_new": 1, + "fail_changed": 0, + "pass_new": 1, + "pass_changed": 0, + "muted_new": 0, + "muted_changed": 1, + } + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_findings(tenant_id, scan_id) + + mock_bulk_create.assert_called_once() + args, kwargs = mock_bulk_create.call_args + objects = args[0] + + summary = list(objects)[0] + assert summary.fail == 5 + assert summary._pass == 10 + assert summary.muted == 3 + assert summary.total == 18 + + @patch("tasks.jobs.scan.Finding.objects.filter") + @patch("tasks.jobs.scan.ScanSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_computes_deltas_correctly( + self, mock_rls_transaction, mock_bulk_create, mock_findings_filter + ): + """Test that delta counts (new, changed, unchanged) are computed correctly.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [ + { + "check_id": "check1", + "resources__service": "ec2", + "severity": "critical", + "resources__region": "us-west-2", + "fail": 8, + "_pass": 12, + "muted_count": 2, + "total": 22, + "new": 5, + "changed": 3, + "unchanged": 12, + "fail_new": 3, + "fail_changed": 2, + "pass_new": 2, + "pass_changed": 1, + "muted_new": 1, + "muted_changed": 0, + } + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_findings(tenant_id, scan_id) + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + + summary = list(objects)[0] + assert summary.new == 5 + assert summary.changed == 3 + assert summary.unchanged == 12 + assert summary.fail_new == 3 + assert summary.fail_changed == 2 + assert summary.pass_new == 2 + assert summary.pass_changed == 1 + assert summary.muted_new == 1 + assert summary.muted_changed == 0 + + @patch("tasks.jobs.scan.Finding.objects.filter") + @patch("tasks.jobs.scan.ScanSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_groups_by_dimensions( + self, mock_rls_transaction, mock_bulk_create, mock_findings_filter + ): + """Test that findings are grouped by check_id, service, severity, and region.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [ + { + "check_id": "check1", + "resources__service": "s3", + "severity": "high", + "resources__region": "us-east-1", + "fail": 5, + "_pass": 10, + "muted_count": 0, + "total": 15, + "new": 2, + "changed": 1, + "unchanged": 12, + "fail_new": 1, + "fail_changed": 0, + "pass_new": 1, + "pass_changed": 1, + "muted_new": 0, + "muted_changed": 0, + }, + { + "check_id": "check1", + "resources__service": "s3", + "severity": "high", + "resources__region": "us-west-2", + "fail": 3, + "_pass": 7, + "muted_count": 1, + "total": 11, + "new": 1, + "changed": 0, + "unchanged": 9, + "fail_new": 1, + "fail_changed": 0, + "pass_new": 0, + "pass_changed": 0, + "muted_new": 0, + "muted_changed": 1, + }, + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_findings(tenant_id, scan_id) + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + + # Should create 2 summaries (different regions) + assert len(list(objects)) == 2 + + summaries = list(objects) + assert all(s.check_id == "check1" for s in summaries) + assert all(s.service == "s3" for s in summaries) + assert all(s.severity == "high" for s in summaries) + + regions = {s.region for s in summaries} + assert regions == {"us-east-1", "us-west-2"} + + +@pytest.mark.django_db +class TestAggregateFindingsByRegion: + """Test _aggregate_findings_by_region function.""" + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_returns_correct_structure( + self, mock_rls_transaction, mock_findings_filter + ): + """Test function returns correct data structure.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + # Mock findings with resources + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "FAIL" + mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1", "req2"]} + + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.small_resources = [mock_resource1] + + mock_queryset = MagicMock() + mock_queryset.only.return_value = mock_queryset + mock_queryset.prefetch_related.return_value = [mock_finding1] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, findings_count_by_compliance = ( + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + ) + + # Verify structure of check_status_by_region + assert isinstance(check_status_by_region, dict) + assert "us-east-1" in check_status_by_region + assert "check1" in check_status_by_region["us-east-1"] + assert check_status_by_region["us-east-1"]["check1"] == "FAIL" + + # Verify structure of findings_count_by_compliance + assert isinstance(findings_count_by_compliance, dict) + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_fail_status_priority( + self, mock_rls_transaction, mock_findings_filter + ): + """Test that FAIL status takes priority over other statuses.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + # First finding with PASS status + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "PASS" + mock_finding1.compliance = {} + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.small_resources = [mock_resource1] + + # Second finding with FAIL status for same check/region + mock_finding2 = MagicMock() + mock_finding2.check_id = "check1" + mock_finding2.status = "FAIL" + mock_finding2.compliance = {} + mock_resource2 = MagicMock() + mock_resource2.region = "us-east-1" + mock_finding2.small_resources = [mock_resource2] + + mock_queryset = MagicMock() + mock_queryset.only.return_value = mock_queryset + mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, _ = _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + + # FAIL should override PASS + assert check_status_by_region["us-east-1"]["check1"] == "FAIL" + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_filters_muted( + self, mock_rls_transaction, mock_findings_filter + ): + """Test that muted findings are filtered out (muted=False in query).""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + mock_queryset = MagicMock() + mock_queryset.only.return_value = mock_queryset + mock_queryset.prefetch_related.return_value = [] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + + # Verify filter was called with muted=False + mock_findings_filter.assert_called_once_with( + tenant_id=tenant_id, + scan_id=scan_id, + muted=False, + status__in=["PASS", "FAIL"], + ) + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_processes_compliance_counts( + self, mock_rls_transaction, mock_findings_filter + ): + """Test that ThreatScore compliance counts are processed correctly.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + # Finding with PASS status + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "PASS" + mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1"]} + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.small_resources = [mock_resource1] + + # Finding with FAIL status + mock_finding2 = MagicMock() + mock_finding2.check_id = "check2" + mock_finding2.status = "FAIL" + mock_finding2.compliance = {modeled_threatscore_compliance_id: ["req1"]} + mock_resource2 = MagicMock() + mock_resource2.region = "us-east-1" + mock_finding2.small_resources = [mock_resource2] + + mock_queryset = MagicMock() + mock_queryset.only.return_value = mock_queryset + mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + _, findings_count_by_compliance = _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + + # Verify compliance counts + normalized_id = re.sub( + r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() + ) + assert "us-east-1" in findings_count_by_compliance + assert normalized_id in findings_count_by_compliance["us-east-1"] + assert "req1" in findings_count_by_compliance["us-east-1"][normalized_id] + + req_stats = findings_count_by_compliance["us-east-1"][normalized_id]["req1"] + assert req_stats["total"] == 2 + assert req_stats["pass"] == 1 + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_multiple_regions( + self, mock_rls_transaction, mock_findings_filter + ): + """Test aggregation across multiple regions.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + # Finding in us-east-1 + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "FAIL" + mock_finding1.compliance = {} + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.small_resources = [mock_resource1] + + # Finding in us-west-2 + mock_finding2 = MagicMock() + mock_finding2.check_id = "check1" + mock_finding2.status = "PASS" + mock_finding2.compliance = {} + mock_resource2 = MagicMock() + mock_resource2.region = "us-west-2" + mock_finding2.small_resources = [mock_resource2] + + mock_queryset = MagicMock() + mock_queryset.only.return_value = mock_queryset + mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, _ = _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + + # Verify both regions are present with correct statuses + assert "us-east-1" in check_status_by_region + assert "us-west-2" in check_status_by_region + assert check_status_by_region["us-east-1"]["check1"] == "FAIL" + assert check_status_by_region["us-west-2"]["check1"] == "PASS" + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_empty_findings( + self, mock_rls_transaction, mock_findings_filter + ): + """Test with no findings - should return empty dicts.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + mock_queryset = MagicMock() + mock_queryset.only.return_value = mock_queryset + mock_queryset.prefetch_related.return_value = [] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, findings_count_by_compliance = ( + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + ) + + assert check_status_by_region == {} + assert findings_count_by_compliance == {} + + +@pytest.mark.django_db +class TestAggregateAttackSurface: + """Test aggregate_attack_surface function and related caching.""" + + def setup_method(self): + """Clear cache before each test.""" + _ATTACK_SURFACE_MAPPING_CACHE.clear() + + def teardown_method(self): + """Clear cache after each test.""" + _ATTACK_SURFACE_MAPPING_CACHE.clear() + + @patch("tasks.jobs.scan.CheckMetadata.list") + def test_get_attack_surface_mapping_caches_result(self, mock_check_metadata_list): + """Test that _get_attack_surface_mapping_from_provider caches results.""" + mock_check_metadata_list.return_value = {"check_internet_exposed_1"} + + # First call should hit CheckMetadata.list + result1 = _get_attack_surface_mapping_from_provider("aws") + assert mock_check_metadata_list.call_count == 2 # internet-exposed, secrets + + # Second call should use cache + result2 = _get_attack_surface_mapping_from_provider("aws") + assert mock_check_metadata_list.call_count == 2 # No additional calls + + assert result1 is result2 + assert "aws" in _ATTACK_SURFACE_MAPPING_CACHE + + @patch("tasks.jobs.scan.CheckMetadata.list") + def test_get_attack_surface_mapping_different_providers( + self, mock_check_metadata_list + ): + """Test caching works independently for different providers.""" + mock_check_metadata_list.return_value = {"check_1"} + + _get_attack_surface_mapping_from_provider("aws") + aws_call_count = mock_check_metadata_list.call_count + + _get_attack_surface_mapping_from_provider("gcp") + gcp_call_count = mock_check_metadata_list.call_count + + # Both providers should have made calls + assert gcp_call_count > aws_call_count + assert "aws" in _ATTACK_SURFACE_MAPPING_CACHE + assert "gcp" in _ATTACK_SURFACE_MAPPING_CACHE + + @patch("tasks.jobs.scan.CheckMetadata.list") + def test_get_attack_surface_mapping_returns_hardcoded_checks( + self, mock_check_metadata_list + ): + """Test that hardcoded check IDs are returned for privilege-escalation and ec2-imdsv1.""" + mock_check_metadata_list.return_value = set() + + result = _get_attack_surface_mapping_from_provider("aws") + + # Hardcoded checks should be present + assert ( + "iam_policy_allows_privilege_escalation" in result["privilege-escalation"] + ) + assert ( + "iam_inline_policy_allows_privilege_escalation" + in result["privilege-escalation"] + ) + assert "ec2_instance_imdsv2_enabled" in result["ec2-imdsv1"] + + @patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create") + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_attack_surface_creates_overview_records( + self, + mock_rls_transaction, + mock_get_mapping, + mock_findings_filter, + mock_bulk_create, + tenants_fixture, + scans_fixture, + ): + """Test that aggregate_attack_surface creates AttackSurfaceOverview records.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + scan.provider.provider = "aws" + scan.provider.save() + + mock_get_mapping.return_value = { + "internet-exposed": {"check_internet_1", "check_internet_2"}, + "secrets": {"check_secrets_1"}, + "privilege-escalation": {"check_privesc_1"}, + "ec2-imdsv1": {"check_imdsv1_1"}, + } + + # Mock findings aggregation + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [ + {"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1}, + {"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0}, + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_attack_surface(str(tenant.id), str(scan.id)) + + mock_bulk_create.assert_called_once() + args, kwargs = mock_bulk_create.call_args + objects = args[0] + + # Should create records for internet-exposed and secrets (the ones with findings) + assert len(objects) == 2 + assert kwargs["batch_size"] == 500 + + @patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create") + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_attack_surface_skips_unsupported_provider( + self, + mock_rls_transaction, + mock_get_mapping, + mock_findings_filter, + mock_bulk_create, + tenants_fixture, + scans_fixture, + ): + """Test that ec2-imdsv1 is skipped for non-AWS providers.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + scan.provider.provider = "gcp" + scan.provider.uid = "gcp-test-project-id" + scan.provider.save() + + mock_get_mapping.return_value = { + "internet-exposed": {"check_internet_1"}, + "secrets": {"check_secrets_1"}, + "privilege-escalation": set(), # Not supported for GCP + "ec2-imdsv1": {"check_imdsv1_1"}, # Should be skipped for GCP + } + + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [ + {"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0}, + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_attack_surface(str(tenant.id), str(scan.id)) + + # ec2-imdsv1 check_ids should not be in the filter + filter_call = mock_findings_filter.call_args + check_ids_in_filter = filter_call[1]["check_id__in"] + assert "check_imdsv1_1" not in check_ids_in_filter + + @patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create") + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_attack_surface_no_findings( + self, + mock_rls_transaction, + mock_get_mapping, + mock_findings_filter, + mock_bulk_create, + tenants_fixture, + scans_fixture, + ): + """Test that no records are created when there are no findings.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + mock_get_mapping.return_value = { + "internet-exposed": {"check_1"}, + "secrets": {"check_2"}, + "privilege-escalation": set(), + "ec2-imdsv1": set(), + } + + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [] # No findings + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_attack_surface(str(tenant.id), str(scan.id)) + + mock_bulk_create.assert_not_called() + + @patch("tasks.jobs.scan.AttackSurfaceOverview.objects.bulk_create") + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_attack_surface_aggregates_counts_correctly( + self, + mock_rls_transaction, + mock_get_mapping, + mock_findings_filter, + mock_bulk_create, + tenants_fixture, + scans_fixture, + ): + """Test that counts from multiple check_ids are aggregated per attack surface type.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + scan.provider.provider = "aws" + scan.provider.save() + + mock_get_mapping.return_value = { + "internet-exposed": {"check_internet_1", "check_internet_2"}, + "secrets": set(), + "privilege-escalation": set(), + "ec2-imdsv1": set(), + } + + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [ + {"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1}, + {"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0}, + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_attack_surface(str(tenant.id), str(scan.id)) + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + + assert len(objects) == 1 + overview = objects[0] + assert overview.attack_surface_type == "internet-exposed" + assert overview.total_findings == 15 # 10 + 5 + assert overview.failed_findings == 5 # 3 + 2 + assert overview.muted_failed_findings == 1 # 1 + 0 + + @patch("tasks.jobs.scan.Scan.all_objects.select_related") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_attack_surface_uses_select_related( + self, mock_rls_transaction, mock_select_related, tenants_fixture, scans_fixture + ): + """Test that select_related is used to avoid N+1 query.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + mock_scan = MagicMock() + mock_scan.provider.provider = "aws" + + mock_select_related.return_value.get.return_value = mock_scan + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + with patch( + "tasks.jobs.scan._get_attack_surface_mapping_from_provider" + ) as mock_map: + mock_map.return_value = {} + + aggregate_attack_surface(str(tenant.id), str(scan.id)) + + mock_select_related.assert_called_once_with("provider") + + +class TestAggregateCategoryCounts: + """Test aggregate_category_counts helper function.""" + + def test_aggregate_category_counts_basic(self): + """Test basic category counting for a non-muted PASS finding.""" + cache: dict[tuple[str, str], dict[str, int]] = {} + aggregate_category_counts( + categories=["security", "iam"], + severity="high", + status="PASS", + delta=None, + muted=False, + cache=cache, + ) + + assert ("security", "high") in cache + assert ("iam", "high") in cache + assert cache[("security", "high")] == {"total": 1, "failed": 0, "new_failed": 0} + assert cache[("iam", "high")] == {"total": 1, "failed": 0, "new_failed": 0} + + def test_aggregate_category_counts_fail_not_muted(self): + """Test category counting for a non-muted FAIL finding.""" + cache: dict[tuple[str, str], dict[str, int]] = {} + aggregate_category_counts( + categories=["security"], + severity="critical", + status="FAIL", + delta=None, + muted=False, + cache=cache, + ) + + assert cache[("security", "critical")] == { + "total": 1, + "failed": 1, + "new_failed": 0, + } + + def test_aggregate_category_counts_new_fail(self): + """Test category counting for a new FAIL finding (delta='new').""" + cache: dict[tuple[str, str], dict[str, int]] = {} + aggregate_category_counts( + categories=["gen-ai"], + severity="high", + status="FAIL", + delta="new", + muted=False, + cache=cache, + ) + + assert cache[("gen-ai", "high")] == {"total": 1, "failed": 1, "new_failed": 1} + + def test_aggregate_category_counts_muted_finding(self): + """Test that muted findings are excluded from all counts.""" + cache: dict[tuple[str, str], dict[str, int]] = {} + aggregate_category_counts( + categories=["security"], + severity="high", + status="FAIL", + delta="new", + muted=True, + cache=cache, + ) + + assert cache[("security", "high")] == {"total": 0, "failed": 0, "new_failed": 0} + + def test_aggregate_category_counts_accumulates(self): + """Test that multiple calls accumulate counts.""" + cache: dict[tuple[str, str], dict[str, int]] = {} + + # First finding: PASS + aggregate_category_counts( + categories=["security"], + severity="high", + status="PASS", + delta=None, + muted=False, + cache=cache, + ) + + # Second finding: FAIL (new) + aggregate_category_counts( + categories=["security"], + severity="high", + status="FAIL", + delta="new", + muted=False, + cache=cache, + ) + + # Third finding: FAIL (changed) + aggregate_category_counts( + categories=["security"], + severity="high", + status="FAIL", + delta="changed", + muted=False, + cache=cache, + ) + + assert cache[("security", "high")] == {"total": 3, "failed": 2, "new_failed": 1} + + def test_aggregate_category_counts_empty_categories(self): + """Test with empty categories list.""" + cache: dict[tuple[str, str], dict[str, int]] = {} + aggregate_category_counts( + categories=[], + severity="high", + status="FAIL", + delta="new", + muted=False, + cache=cache, + ) + + assert cache == {} + + def test_aggregate_category_counts_changed_delta(self): + """Test that changed delta increments failed but not new_failed.""" + cache: dict[tuple[str, str], dict[str, int]] = {} + aggregate_category_counts( + categories=["iam"], + severity="medium", + status="FAIL", + delta="changed", + muted=False, + cache=cache, + ) + + assert cache[("iam", "medium")] == {"total": 1, "failed": 1, "new_failed": 0} + + def test_aggregate_category_counts_multiple_categories_single_finding(self): + """Test single finding with multiple categories.""" + cache: dict[tuple[str, str], dict[str, int]] = {} + aggregate_category_counts( + categories=["security", "compliance", "data-protection"], + severity="low", + status="FAIL", + delta="new", + muted=False, + cache=cache, + ) + + assert len(cache) == 3 + for cat in ["security", "compliance", "data-protection"]: + assert cache[(cat, "low")] == {"total": 1, "failed": 1, "new_failed": 1} + + +@pytest.mark.django_db +class TestUpdateProviderComplianceScores: + @patch("tasks.jobs.scan.psycopg_connection") + def test_update_provider_compliance_scores_basic( + self, + mock_psycopg_connection, + tenants_fixture, + scans_fixture, + settings, + ): + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + tenant = tenants_fixture[0] + scan = scans_fixture[0] + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + scan.state = StateChoices.COMPLETED + scan.completed_at = datetime.now(timezone.utc) + scan.save() + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + connection.autocommit = True + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + cursor.rowcount = 2 + + result = update_provider_compliance_scores(tenant_id, scan_id) + + assert result["status"] == "completed" + assert result["upserted"] == 2 + assert cursor.execute.call_count >= 3 + connection.commit.assert_called_once() + + def test_update_provider_compliance_scores_skips_incomplete_scan( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[1] + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + result = update_provider_compliance_scores(tenant_id, scan_id) + + assert result["status"] == "skipped" + assert result["reason"] == "scan not completed" + + def test_update_provider_compliance_scores_skips_no_completed_at( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + scan.state = StateChoices.COMPLETED + scan.completed_at = None + scan.save() + + result = update_provider_compliance_scores(tenant_id, scan_id) + + assert result["status"] == "skipped" + assert result["reason"] == "no completed_at" + + @patch("tasks.jobs.scan.psycopg_connection") + def test_update_provider_compliance_scores_executes_sql_queries( + self, + mock_psycopg_connection, + tenants_fixture, + providers_fixture, + scans_fixture, + settings, + ): + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + tenant = tenants_fixture[0] + scan = scans_fixture[0] + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + scan.state = StateChoices.COMPLETED + scan.completed_at = datetime.now(timezone.utc) + scan.save() + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + cursor.rowcount = 1 + cursor.fetchall.side_effect = [[("aws_cis_2.0",)], []] + + result = update_provider_compliance_scores(tenant_id, scan_id) + + assert result["status"] == "completed" + + calls = [str(c) for c in cursor.execute.call_args_list] + assert any("provider_compliance_scores" in c for c in calls) + assert any("tenant_compliance_summaries" in c for c in calls) + assert any("pg_advisory_xact_lock" in c for c in calls) diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index a98341ef35..3a58118c62 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -1,16 +1,230 @@ import uuid +from contextlib import contextmanager +from datetime import datetime, timezone from unittest.mock import MagicMock, patch +import openai import pytest +from botocore.exceptions import ClientError +from django_celery_beat.models import IntervalSchedule, PeriodicTask +from django_celery_results.models import TaskResult +from tasks.jobs.lighthouse_providers import ( + _create_bedrock_client, + _extract_bedrock_credentials, +) from tasks.tasks import ( + _cleanup_orphan_scheduled_scans, _perform_scan_complete_tasks, check_integrations_task, + check_lighthouse_provider_connection_task, generate_outputs_task, + perform_attack_paths_scan_task, + perform_scheduled_scan_task, + refresh_lighthouse_provider_models_task, s3_integration_task, security_hub_integration_task, ) -from api.models import Integration +from api.models import ( + Integration, + LighthouseProviderConfiguration, + LighthouseProviderModels, + Scan, + StateChoices, + Task, +) + + +@pytest.mark.django_db +class TestExtractBedrockCredentials: + """Unit tests for _extract_bedrock_credentials helper function.""" + + def test_extract_access_key_credentials(self, tenants_fixture): + """Test extraction of access key + secret key credentials.""" + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + is_active=True, + ) + provider_cfg.credentials_decoded = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-east-1", + } + provider_cfg.save() + + result = _extract_bedrock_credentials(provider_cfg) + + assert result is not None + assert result["access_key_id"] == "AKIAIOSFODNN7EXAMPLE" + assert result["secret_access_key"] == "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + assert result["region"] == "us-east-1" + assert "api_key" not in result + + def test_extract_api_key_credentials(self, tenants_fixture): + """Test extraction of API key (bearer token) credentials.""" + valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110) + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + is_active=True, + ) + provider_cfg.credentials_decoded = { + "api_key": valid_api_key, + "region": "us-west-2", + } + provider_cfg.save() + + result = _extract_bedrock_credentials(provider_cfg) + + assert result is not None + assert result["api_key"] == valid_api_key + assert result["region"] == "us-west-2" + assert "access_key_id" not in result + assert "secret_access_key" not in result + + def test_api_key_takes_precedence_over_access_keys(self, tenants_fixture): + """Test that API key is preferred when both auth methods are present.""" + valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("B" * 110) + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + is_active=True, + ) + provider_cfg.credentials_decoded = { + "api_key": valid_api_key, + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "eu-west-1", + } + provider_cfg.save() + + result = _extract_bedrock_credentials(provider_cfg) + + assert result is not None + assert result["api_key"] == valid_api_key + assert result["region"] == "eu-west-1" + assert "access_key_id" not in result + + def test_missing_region_returns_none(self, tenants_fixture): + """Test that missing region returns None.""" + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + is_active=True, + ) + provider_cfg.credentials_decoded = { + "api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110), + } + provider_cfg.save() + + result = _extract_bedrock_credentials(provider_cfg) + + assert result is None + + def test_empty_credentials_returns_none(self, tenants_fixture): + """Test that empty credentials dict returns None (region only is not enough).""" + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + is_active=True, + ) + # Only region, no auth credentials - should return None + provider_cfg.credentials_decoded = { + "region": "us-east-1", + } + provider_cfg.save() + + result = _extract_bedrock_credentials(provider_cfg) + + assert result is None + + def test_non_dict_credentials_returns_none(self, tenants_fixture): + """Test that non-dict credentials returns None.""" + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + is_active=True, + ) + # Store valid credentials first to pass model validation + provider_cfg.credentials_decoded = { + "api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110), + "region": "us-east-1", + } + provider_cfg.save() + + # Mock the credentials_decoded property to return a non-dict value + # This simulates corrupted/invalid stored data + with patch.object( + type(provider_cfg), + "credentials_decoded", + new_callable=lambda: property(lambda self: "invalid"), + ): + result = _extract_bedrock_credentials(provider_cfg) + + assert result is None + + +class TestCreateBedrockClient: + """Unit tests for _create_bedrock_client helper function.""" + + @patch("tasks.jobs.lighthouse_providers.boto3.client") + def test_create_client_with_access_keys(self, mock_boto_client): + """Test creating client with access key authentication.""" + mock_client = MagicMock() + mock_boto_client.return_value = mock_client + + creds = { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "region": "us-east-1", + } + + result = _create_bedrock_client(creds) + + assert result == mock_client + mock_boto_client.assert_called_once_with( + service_name="bedrock", + region_name="us-east-1", + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ) + + @patch("tasks.jobs.lighthouse_providers.Config") + @patch("tasks.jobs.lighthouse_providers.boto3.client") + def test_create_client_with_api_key(self, mock_boto_client, mock_config): + """Test creating client with API key authentication.""" + mock_client = MagicMock() + mock_events = MagicMock() + mock_client.meta.events = mock_events + mock_boto_client.return_value = mock_client + mock_config_instance = MagicMock() + mock_config.return_value = mock_config_instance + valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110) + + creds = { + "api_key": valid_api_key, + "region": "us-west-2", + } + + result = _create_bedrock_client(creds) + + assert result == mock_client + mock_boto_client.assert_called_once_with( + service_name="bedrock", + region_name="us-west-2", + config=mock_config_instance, + ) + mock_events.register.assert_called_once() + call_args = mock_events.register.call_args + assert call_args[0][0] == "before-send.*.*" + + # Verify handler injects bearer token + handler_fn = call_args[0][1] + mock_request = MagicMock() + mock_request.headers = {} + handler_fn(mock_request) + assert mock_request.headers["Authorization"] == f"Bearer {valid_api_key}" # TODO Move this to outputs/reports jobs @@ -101,7 +315,6 @@ class TestGenerateOutputs: return_value=( "/tmp/test/out-dir", "/tmp/test/comp-dir", - "/tmp/test/threat-dir", ), ), patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update, @@ -131,7 +344,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), patch("tasks.tasks.FindingOutput.transform_api_finding"), @@ -201,7 +414,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch( "tasks.tasks.FindingOutput._transform_findings_stats", @@ -281,7 +494,6 @@ class TestGenerateOutputs: return_value=( "/tmp/test/outdir", "/tmp/test/compdir", - "/tmp/test/threatdir", ), ), patch("tasks.tasks._compress_output_files", return_value="outdir.zip"), @@ -360,7 +572,6 @@ class TestGenerateOutputs: return_value=( "/tmp/test/outdir", "/tmp/test/compdir", - "/tmp/test/threatdir", ), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), @@ -428,7 +639,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch( "tasks.tasks.FindingOutput._transform_findings_stats", @@ -486,7 +697,7 @@ class TestGenerateOutputs: patch("tasks.tasks.Finding.all_objects.filter") as mock_findings, patch( "tasks.tasks._generate_output_directory", - return_value=("/tmp/test/out", "/tmp/test/comp", "/tmp/test/threat"), + return_value=("/tmp/test/out", "/tmp/test/comp"), ), patch("tasks.tasks.FindingOutput._transform_findings_stats"), patch("tasks.tasks.FindingOutput.transform_api_finding"), @@ -524,43 +735,135 @@ class TestGenerateOutputs: class TestScanCompleteTasks: - @patch("tasks.tasks.create_compliance_requirements_task.apply_async") + @patch("tasks.tasks.aggregate_attack_surface_task.apply_async") + @patch("tasks.tasks.chain") + @patch("tasks.tasks.create_compliance_requirements_task.si") + @patch("tasks.tasks.update_provider_compliance_scores_task.si") @patch("tasks.tasks.perform_scan_summary_task.si") @patch("tasks.tasks.generate_outputs_task.si") - @patch("tasks.tasks.generate_threatscore_report_task.si") + @patch("tasks.tasks.generate_compliance_reports_task.si") @patch("tasks.tasks.check_integrations_task.si") + @patch("tasks.tasks.perform_attack_paths_scan_task.apply_async") + @patch("tasks.tasks.can_provider_run_attack_paths_scan", return_value=False) def test_scan_complete_tasks( self, + mock_can_run_attack_paths, + mock_attack_paths_task, mock_check_integrations_task, - mock_threatscore_task, + mock_compliance_reports_task, mock_outputs_task, mock_scan_summary_task, - mock_compliance_tasks, + mock_update_compliance_scores_task, + mock_compliance_requirements_task, + mock_chain, + mock_attack_surface_task, ): + """Test that scan complete tasks are properly orchestrated with optimized reports.""" _perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id") - mock_compliance_tasks.assert_called_once_with( + + # Verify compliance requirements task is called via chain + mock_compliance_requirements_task.assert_called_once_with( + tenant_id="tenant-id", scan_id="scan-id" + ) + + # Verify update provider compliance scores task is called via chain + mock_update_compliance_scores_task.assert_called_once_with( + tenant_id="tenant-id", scan_id="scan-id" + ) + + # Verify attack surface task is called + mock_attack_surface_task.assert_called_once_with( kwargs={"tenant_id": "tenant-id", "scan_id": "scan-id"}, ) + + # Verify scan summary task is called mock_scan_summary_task.assert_called_once_with( scan_id="scan-id", tenant_id="tenant-id", ) + + # Verify outputs task is called mock_outputs_task.assert_called_once_with( scan_id="scan-id", provider_id="provider-id", tenant_id="tenant-id", ) - mock_threatscore_task.assert_called_once_with( + + # Verify optimized compliance reports task is called (replaces individual tasks) + mock_compliance_reports_task.assert_called_once_with( tenant_id="tenant-id", scan_id="scan-id", provider_id="provider-id", ) + + # Verify integrations task is called mock_check_integrations_task.assert_called_once_with( tenant_id="tenant-id", provider_id="provider-id", scan_id="scan-id", ) + # Attack Paths task should be skipped when provider cannot run it + mock_attack_paths_task.assert_not_called() + + +class TestAttackPathsTasks: + @staticmethod + @contextmanager + def _override_task_request(task, **attrs): + request = task.request + sentinel = object() + previous = {key: getattr(request, key, sentinel) for key in attrs} + for key, value in attrs.items(): + setattr(request, key, value) + + try: + yield + finally: + for key, prev in previous.items(): + if prev is sentinel: + if hasattr(request, key): + delattr(request, key) + else: + setattr(request, key, prev) + + def test_perform_attack_paths_scan_task_calls_runner(self): + with ( + patch("tasks.tasks.attack_paths_scan") as mock_attack_paths_scan, + self._override_task_request( + perform_attack_paths_scan_task, id="celery-task-id" + ), + ): + mock_attack_paths_scan.return_value = {"status": "ok"} + + result = perform_attack_paths_scan_task.run( + tenant_id="tenant-id", scan_id="scan-id" + ) + + mock_attack_paths_scan.assert_called_once_with( + tenant_id="tenant-id", scan_id="scan-id", task_id="celery-task-id" + ) + assert result == {"status": "ok"} + + def test_perform_attack_paths_scan_task_propagates_exception(self): + with ( + patch( + "tasks.tasks.attack_paths_scan", + side_effect=RuntimeError("Exception to propagate"), + ) as mock_attack_paths_scan, + self._override_task_request( + perform_attack_paths_scan_task, id="celery-task-error" + ), + ): + with pytest.raises(RuntimeError, match="Exception to propagate"): + perform_attack_paths_scan_task.run( + tenant_id="tenant-id", scan_id="scan-id" + ) + + mock_attack_paths_scan.assert_called_once_with( + tenant_id="tenant-id", scan_id="scan-id", task_id="celery-task-error" + ) + @pytest.mark.django_db class TestCheckIntegrationsTask: @@ -730,7 +1033,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings @@ -855,7 +1158,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings @@ -971,7 +1274,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider.return_value = MagicMock() mock_compliance_bulk.return_value = {} mock_get_frameworks.return_value = [] - mock_generate_dir.return_value = ("out-dir", "comp-dir", "threat-dir") + mock_generate_dir.return_value = ("out-dir", "comp-dir") mock_transform_stats.return_value = {"stats": "data"} # Mock findings @@ -1097,3 +1400,954 @@ class TestCheckIntegrationsTask: assert result is False mock_upload.assert_called_once_with(self.tenant_id, self.provider_id, scan_id) + + +@pytest.mark.django_db +class TestCheckLighthouseProviderConnectionTask: + def setup_method(self): + self.tenant_id = str(uuid.uuid4()) + + @pytest.mark.parametrize( + "provider_type,credentials,base_url,expected_result", + [ + ( + LighthouseProviderConfiguration.LLMProviderChoices.OPENAI, + {"api_key": "sk-test123"}, + None, + {"connected": True, "error": None}, + ), + ( + LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + {"api_key": "sk-test123"}, + "https://openrouter.ai/api/v1", + {"connected": True, "error": None}, + ), + ( + LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + { + "access_key_id": "AKIA123", + "secret_access_key": "secret", + "region": "us-east-1", + }, + None, + {"connected": True, "error": None}, + ), + # Bedrock API key authentication + ( + LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + { + "api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110), + "region": "us-east-1", + }, + None, + {"connected": True, "error": None}, + ), + ], + ) + def test_check_connection_success_all_providers( + self, tenants_fixture, provider_type, credentials, base_url, expected_result + ): + """Test successful connection check for all provider types.""" + # Create provider configuration + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=provider_type, + base_url=base_url, + is_active=False, + ) + provider_cfg.credentials_decoded = credentials + provider_cfg.save() + + # Mock the appropriate API calls + with ( + patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai, + patch("tasks.jobs.lighthouse_providers.boto3.client") as mock_boto3, + ): + mock_client = MagicMock() + mock_client.models.list.return_value = MagicMock() + mock_client.list_foundation_models.return_value = {} + mock_openai.return_value = mock_client + mock_boto3.return_value = mock_client + + # Execute + result = check_lighthouse_provider_connection_task( + provider_config_id=str(provider_cfg.id), + tenant_id=str(tenants_fixture[0].id), + ) + + # Assert + assert result == expected_result + provider_cfg.refresh_from_db() + assert provider_cfg.is_active is True + + @pytest.mark.parametrize( + "provider_type,credentials,base_url,exception_to_raise", + [ + ( + LighthouseProviderConfiguration.LLMProviderChoices.OPENAI, + {"api_key": "sk-invalid"}, + None, + openai.AuthenticationError( + "Invalid API key", response=MagicMock(), body=None + ), + ), + ( + LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + {"api_key": "sk-invalid"}, + "https://openrouter.ai/api/v1", + openai.APIConnectionError(request=MagicMock()), + ), + ( + LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + { + "access_key_id": "AKIA123", + "secret_access_key": "secret", + "region": "us-east-1", + }, + None, + ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}, + "list_foundation_models", + ), + ), + # Bedrock API key authentication failure + ( + LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + { + "api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("X" * 110), + "region": "us-east-1", + }, + None, + ClientError( + { + "Error": { + "Code": "UnrecognizedClientException", + "Message": "Invalid API key", + } + }, + "list_foundation_models", + ), + ), + ], + ) + def test_check_connection_api_failure( + self, + tenants_fixture, + provider_type, + credentials, + base_url, + exception_to_raise, + ): + """Test connection check when API calls fail.""" + # Create provider configuration + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=provider_type, + base_url=base_url, + is_active=True, + ) + provider_cfg.credentials_decoded = credentials + provider_cfg.save() + + # Mock the API to raise exception + with ( + patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai, + patch("tasks.jobs.lighthouse_providers.boto3.client") as mock_boto3, + ): + mock_client = MagicMock() + if ( + provider_type + == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK + ): + mock_client.list_foundation_models.side_effect = exception_to_raise + mock_boto3.return_value = mock_client + else: + mock_client.models.list.side_effect = exception_to_raise + mock_openai.return_value = mock_client + + # Execute + result = check_lighthouse_provider_connection_task( + provider_config_id=str(provider_cfg.id), + tenant_id=str(tenants_fixture[0].id), + ) + + # Assert + assert result["connected"] is False + assert result["error"] is not None + provider_cfg.refresh_from_db() + assert provider_cfg.is_active is False + + def test_check_connection_updates_active_status(self, tenants_fixture): + """Test that connection check toggles is_active from True to False on failure.""" + # Create provider with is_active=True + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI, + base_url=None, + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "sk-test123"} + provider_cfg.save() + + # Mock API to fail + with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai: + mock_client = MagicMock() + mock_client.models.list.side_effect = openai.AuthenticationError( + "Invalid", response=MagicMock(), body=None + ) + mock_openai.return_value = mock_client + + # Execute + result = check_lighthouse_provider_connection_task( + provider_config_id=str(provider_cfg.id), + tenant_id=str(tenants_fixture[0].id), + ) + + # Assert status changed + assert result["connected"] is False + provider_cfg.refresh_from_db() + assert provider_cfg.is_active is False + + def test_check_connection_provider_does_not_exist(self, tenants_fixture): + """Test that checking non-existent provider raises DoesNotExist.""" + non_existent_id = str(uuid.uuid4()) + + with pytest.raises(LighthouseProviderConfiguration.DoesNotExist): + check_lighthouse_provider_connection_task( + provider_config_id=non_existent_id, + tenant_id=str(tenants_fixture[0].id), + ) + + +@pytest.mark.django_db +class TestRefreshLighthouseProviderModelsTask: + def setup_method(self): + self.tenant_id = str(uuid.uuid4()) + + @pytest.mark.parametrize( + "provider_type,credentials,base_url,mock_models,expected_count", + [ + ( + LighthouseProviderConfiguration.LLMProviderChoices.OPENAI, + {"api_key": "sk-test123"}, + None, + {"gpt-5": "gpt-5", "gpt-4o": "gpt-4o"}, + 2, + ), + ( + LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE, + {"api_key": "sk-test123"}, + "https://openrouter.ai/api/v1", + {"model-1": "Model One", "model-2": "Model Two"}, + 2, + ), + ( + LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + { + "access_key_id": "AKIA123", + "secret_access_key": "secret", + "region": "us-east-1", + }, + None, + {"openai.gpt-oss-120b-1:0": "gpt-oss-120b"}, + 1, + ), + # Bedrock API key authentication + ( + LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK, + { + "api_key": "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110), + "region": "us-east-1", + }, + None, + {"anthropic.claude-v3": "Claude 3"}, + 1, + ), + ], + ) + def test_refresh_models_create_new( + self, + tenants_fixture, + provider_type, + credentials, + base_url, + mock_models, + expected_count, + ): + """Test creating new models for all provider types.""" + # Create provider configuration + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=provider_type, + base_url=base_url, + is_active=True, + ) + provider_cfg.credentials_decoded = credentials + provider_cfg.save() + + # Mock the fetch functions + with ( + patch( + "tasks.jobs.lighthouse_providers._fetch_openai_models", + return_value=mock_models, + ), + patch( + "tasks.jobs.lighthouse_providers._fetch_openai_compatible_models", + return_value=mock_models, + ), + patch( + "tasks.jobs.lighthouse_providers._fetch_bedrock_models", + return_value=mock_models, + ), + ): + # Execute + result = refresh_lighthouse_provider_models_task( + provider_config_id=str(provider_cfg.id), + tenant_id=str(tenants_fixture[0].id), + ) + + # Assert + assert result["created"] == expected_count + assert result["updated"] == 0 + assert result["deleted"] == 0 + assert ( + LighthouseProviderModels.objects.filter( + provider_configuration=provider_cfg + ).count() + == expected_count + ) + + def test_refresh_models_mixed_operations(self, tenants_fixture): + """Test mixed create, update, and delete operations.""" + # Create provider configuration + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI, + base_url=None, + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "sk-test123"} + provider_cfg.save() + + # Create 2 existing models (A, B) + LighthouseProviderModels.objects.create( + tenant_id=tenants_fixture[0].id, + provider_configuration=provider_cfg, + model_id="model-a", + model_name="Model A", + ) + LighthouseProviderModels.objects.create( + tenant_id=tenants_fixture[0].id, + provider_configuration=provider_cfg, + model_id="model-b", + model_name="Model B", + ) + + # Mock API to return models B (existing), C (new) - A will be deleted + mock_models = {"model-b": "Model B", "model-c": "Model C"} + with patch( + "tasks.jobs.lighthouse_providers._fetch_openai_models", + return_value=mock_models, + ): + # Execute + result = refresh_lighthouse_provider_models_task( + provider_config_id=str(provider_cfg.id), + tenant_id=str(tenants_fixture[0].id), + ) + + # Assert + assert result["created"] == 1 # model-c created + assert result["updated"] == 1 # model-b updated + assert result["deleted"] == 1 # model-a deleted + + # Verify only B and C exist + remaining_models = LighthouseProviderModels.objects.filter( + provider_configuration=provider_cfg + ) + assert remaining_models.count() == 2 + assert set(remaining_models.values_list("model_id", flat=True)) == { + "model-b", + "model-c", + } + + def test_refresh_models_api_exception(self, tenants_fixture): + """Test refresh when API raises an exception.""" + # Create provider configuration + provider_cfg = LighthouseProviderConfiguration( + tenant_id=tenants_fixture[0].id, + provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI, + base_url=None, + is_active=True, + ) + provider_cfg.credentials_decoded = {"api_key": "sk-test123"} + provider_cfg.save() + + # Mock fetch to raise exception + with patch( + "tasks.jobs.lighthouse_providers._fetch_openai_models", + side_effect=openai.APIError("API Error", request=MagicMock(), body=None), + ): + # Execute + result = refresh_lighthouse_provider_models_task( + provider_config_id=str(provider_cfg.id), + tenant_id=str(tenants_fixture[0].id), + ) + + # Assert + assert result["created"] == 0 + assert result["updated"] == 0 + assert result["deleted"] == 0 + assert "error" in result + assert result["error"] is not None + + +@pytest.mark.django_db +class TestCleanupOrphanScheduledScans: + """Unit tests for _cleanup_orphan_scheduled_scans helper function.""" + + def _create_periodic_task(self, provider_id, tenant_id): + """Helper to create a PeriodicTask for testing.""" + interval, _ = IntervalSchedule.objects.get_or_create(every=24, 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 test_cleanup_deletes_orphan_when_both_available_and_scheduled_exist( + self, tenants_fixture, providers_fixture + ): + """Test that AVAILABLE scan is deleted when SCHEDULED also exists.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + + # Create orphan AVAILABLE scan + orphan_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task.id, + ) + + # Create SCHEDULED scan (next execution) + scheduled_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduler_task_id=periodic_task.id, + ) + + # Execute cleanup + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + scheduler_task_id=periodic_task.id, + ) + + # Verify orphan was deleted + assert deleted_count == 1 + assert not Scan.objects.filter(id=orphan_scan.id).exists() + assert Scan.objects.filter(id=scheduled_scan.id).exists() + + def test_cleanup_does_not_delete_when_only_available_exists( + self, tenants_fixture, providers_fixture + ): + """Test that AVAILABLE scan is NOT deleted when no SCHEDULED exists.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + + # Create only AVAILABLE scan (normal first scan scenario) + available_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task.id, + ) + + # Execute cleanup + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + scheduler_task_id=periodic_task.id, + ) + + # Verify nothing was deleted + assert deleted_count == 0 + assert Scan.objects.filter(id=available_scan.id).exists() + + def test_cleanup_does_not_delete_when_only_scheduled_exists( + self, tenants_fixture, providers_fixture + ): + """Test that nothing is deleted when only SCHEDULED exists.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + + # Create only SCHEDULED scan (normal subsequent scan scenario) + scheduled_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduler_task_id=periodic_task.id, + ) + + # Execute cleanup + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + scheduler_task_id=periodic_task.id, + ) + + # Verify nothing was deleted + assert deleted_count == 0 + assert Scan.objects.filter(id=scheduled_scan.id).exists() + + def test_cleanup_returns_zero_when_no_scans_exist( + self, tenants_fixture, providers_fixture + ): + """Test that cleanup returns 0 when no scans exist.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + + # Execute cleanup with no scans + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + scheduler_task_id=periodic_task.id, + ) + + assert deleted_count == 0 + + def test_cleanup_deletes_multiple_orphan_available_scans( + self, tenants_fixture, providers_fixture + ): + """Test that multiple AVAILABLE orphan scans are all deleted.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + + # Create multiple orphan AVAILABLE scans + orphan_scan_1 = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task.id, + ) + orphan_scan_2 = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task.id, + ) + + # Create SCHEDULED scan + scheduled_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduler_task_id=periodic_task.id, + ) + + # Execute cleanup + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + scheduler_task_id=periodic_task.id, + ) + + # Verify all orphans were deleted + assert deleted_count == 2 + assert not Scan.objects.filter(id=orphan_scan_1.id).exists() + assert not Scan.objects.filter(id=orphan_scan_2.id).exists() + assert Scan.objects.filter(id=scheduled_scan.id).exists() + + def test_cleanup_does_not_affect_different_provider( + self, tenants_fixture, providers_fixture + ): + """Test that cleanup only affects scans for the specified provider.""" + tenant = tenants_fixture[0] + provider1 = providers_fixture[0] + provider2 = providers_fixture[1] + periodic_task1 = self._create_periodic_task(provider1.id, tenant.id) + periodic_task2 = self._create_periodic_task(provider2.id, tenant.id) + + # Create orphan scenario for provider1 + orphan_scan_p1 = Scan.objects.create( + tenant_id=tenant.id, + provider=provider1, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task1.id, + ) + scheduled_scan_p1 = Scan.objects.create( + tenant_id=tenant.id, + provider=provider1, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduler_task_id=periodic_task1.id, + ) + + # Create AVAILABLE scan for provider2 (should not be affected) + available_scan_p2 = Scan.objects.create( + tenant_id=tenant.id, + provider=provider2, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task2.id, + ) + + # Execute cleanup for provider1 only + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider1.id), + scheduler_task_id=periodic_task1.id, + ) + + # Verify only provider1's orphan was deleted + assert deleted_count == 1 + assert not Scan.objects.filter(id=orphan_scan_p1.id).exists() + assert Scan.objects.filter(id=scheduled_scan_p1.id).exists() + assert Scan.objects.filter(id=available_scan_p2.id).exists() + + def test_cleanup_does_not_affect_manual_scans( + self, tenants_fixture, providers_fixture + ): + """Test that cleanup only affects SCHEDULED trigger scans, not MANUAL.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + + # Create orphan AVAILABLE scheduled scan + orphan_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task.id, + ) + + # Create SCHEDULED scan + scheduled_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduler_task_id=periodic_task.id, + ) + + # Create AVAILABLE manual scan (should not be affected) + manual_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Manual scan", + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + ) + + # Execute cleanup + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + scheduler_task_id=periodic_task.id, + ) + + # Verify only scheduled orphan was deleted + assert deleted_count == 1 + assert not Scan.objects.filter(id=orphan_scan.id).exists() + assert Scan.objects.filter(id=scheduled_scan.id).exists() + assert Scan.objects.filter(id=manual_scan.id).exists() + + def test_cleanup_does_not_affect_different_scheduler_task( + self, tenants_fixture, providers_fixture + ): + """Test that cleanup only affects scans with the specified scheduler_task_id.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task1 = self._create_periodic_task(provider.id, tenant.id) + + # Create another periodic task + interval, _ = IntervalSchedule.objects.get_or_create(every=24, period="hours") + periodic_task2 = PeriodicTask.objects.create( + name=f"scan-perform-scheduled-other-{provider.id}", + task="scan-perform-scheduled", + interval=interval, + kwargs=f'{{"tenant_id": "{tenant.id}", "provider_id": "{provider.id}"}}', + enabled=True, + ) + + # Create orphan scenario for periodic_task1 + orphan_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task1.id, + ) + scheduled_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduler_task_id=periodic_task1.id, + ) + + # Create AVAILABLE scan for periodic_task2 (should not be affected) + available_scan_other_task = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduler_task_id=periodic_task2.id, + ) + + # Execute cleanup for periodic_task1 only + deleted_count = _cleanup_orphan_scheduled_scans( + tenant_id=str(tenant.id), + provider_id=str(provider.id), + scheduler_task_id=periodic_task1.id, + ) + + # Verify only periodic_task1's orphan was deleted + assert deleted_count == 1 + assert not Scan.objects.filter(id=orphan_scan.id).exists() + assert Scan.objects.filter(id=scheduled_scan.id).exists() + assert Scan.objects.filter(id=available_scan_other_task.id).exists() + + +@pytest.mark.django_db +class TestPerformScheduledScanTask: + """Unit tests for perform_scheduled_scan_task.""" + + @staticmethod + @contextmanager + def _override_task_request(task, **attrs): + request = task.request + sentinel = object() + previous = {key: getattr(request, key, sentinel) for key in attrs} + for key, value in attrs.items(): + setattr(request, key, value) + + try: + yield + finally: + for key, prev in previous.items(): + if prev is sentinel: + if hasattr(request, key): + delattr(request, key) + else: + setattr(request, key, prev) + + def _create_periodic_task(self, provider_id, tenant_id, interval_hours=24): + interval, _ = IntervalSchedule.objects.get_or_create( + every=interval_hours, period="hours" + ) + return PeriodicTask.objects.create( + name=f"scan-perform-scheduled-{provider_id}", + task="scan-perform-scheduled", + interval=interval, + kwargs=f'{{"tenant_id": "{tenant_id}", "provider_id": "{provider_id}"}}', + enabled=True, + ) + + def _create_task_result(self, tenant_id, task_id): + task_result = TaskResult.objects.create( + task_id=task_id, + task_name="scan-perform-scheduled", + status="STARTED", + date_created=datetime.now(timezone.utc), + ) + Task.objects.create( + id=task_id, task_runner_task=task_result, tenant_id=tenant_id + ) + return task_result + + def test_skip_when_scheduled_scan_executing( + self, tenants_fixture, providers_fixture + ): + """Skip a scheduled run when another scheduled scan is already executing.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + task_id = str(uuid.uuid4()) + self._create_task_result(tenant.id, task_id) + + executing_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.EXECUTING, + scheduler_task_id=periodic_task.id, + ) + + with ( + patch("tasks.tasks.perform_prowler_scan") as mock_scan, + patch("tasks.tasks._perform_scan_complete_tasks") as mock_complete_tasks, + self._override_task_request(perform_scheduled_scan_task, id=task_id), + ): + result = perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=str(provider.id) + ) + + mock_scan.assert_not_called() + mock_complete_tasks.assert_not_called() + assert result["id"] == str(executing_scan.id) + assert result["state"] == StateChoices.EXECUTING + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + ).count() + == 0 + ) + + def test_creates_next_scheduled_scan_after_completion( + self, tenants_fixture, providers_fixture + ): + """Create a next scheduled scan after a successful run completes.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + self._create_periodic_task(provider.id, tenant.id) + task_id = str(uuid.uuid4()) + self._create_task_result(tenant.id, task_id) + + def _complete_scan(tenant_id, scan_id, provider_id): + other_scheduled = Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + ).exclude(id=scan_id) + assert not other_scheduled.exists() + scan_instance = Scan.objects.get(id=scan_id) + scan_instance.state = StateChoices.COMPLETED + scan_instance.save() + return {"status": "ok"} + + with ( + patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan), + patch("tasks.tasks._perform_scan_complete_tasks"), + self._override_task_request(perform_scheduled_scan_task, id=task_id), + ): + perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=str(provider.id) + ) + + scheduled_scans = Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + ) + assert scheduled_scans.count() == 1 + assert scheduled_scans.first().scheduled_at > datetime.now(timezone.utc) + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + ).count() + == 1 + ) + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.COMPLETED, + ).count() + == 1 + ) + + def test_dedupes_multiple_scheduled_scans_before_run( + self, tenants_fixture, providers_fixture + ): + """Ensure duplicated scheduled scans are removed before executing.""" + tenant = tenants_fixture[0] + provider = providers_fixture[0] + periodic_task = self._create_periodic_task(provider.id, tenant.id) + task_id = str(uuid.uuid4()) + self._create_task_result(tenant.id, task_id) + + scheduled_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduled_at=datetime.now(timezone.utc), + scheduler_task_id=periodic_task.id, + ) + duplicate_scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Daily scheduled scan", + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + scheduled_at=scheduled_scan.scheduled_at, + scheduler_task_id=periodic_task.id, + ) + + def _complete_scan(tenant_id, scan_id, provider_id): + other_scheduled = Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + ).exclude(id=scan_id) + assert not other_scheduled.exists() + scan_instance = Scan.objects.get(id=scan_id) + scan_instance.state = StateChoices.COMPLETED + scan_instance.save() + return {"status": "ok"} + + with ( + patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan), + patch("tasks.tasks._perform_scan_complete_tasks"), + self._override_task_request(perform_scheduled_scan_task, id=task_id), + ): + perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=str(provider.id) + ) + + assert not Scan.objects.filter(id=duplicate_scan.id).exists() + assert Scan.objects.filter(id=scheduled_scan.id).exists() + assert ( + Scan.objects.filter( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + ).count() + == 1 + ) diff --git a/api/src/backend/tasks/utils.py b/api/src/backend/tasks/utils.py index 21e30c9e29..eded5bfb9a 100644 --- a/api/src/backend/tasks/utils.py +++ b/api/src/backend/tasks/utils.py @@ -5,6 +5,10 @@ from enum import Enum from django_celery_beat.models import PeriodicTask from django_celery_results.models import TaskResult +from api.models import Scan, StateChoices + +SCHEDULED_SCAN_NAME = "Daily scheduled scan" + class CustomEncoder(json.JSONEncoder): def default(self, o): @@ -71,3 +75,58 @@ def batched(iterable, batch_size): batch = [] yield batch, True + + +def _get_or_create_scheduled_scan( + tenant_id: str, + provider_id: str, + scheduler_task_id: int, + scheduled_at: datetime, + update_state: bool = False, +) -> Scan: + """ + Get or create a scheduled scan, cleaning up duplicates if found. + + Args: + tenant_id: The tenant ID. + provider_id: The provider ID. + scheduler_task_id: The PeriodicTask ID. + scheduled_at: The scheduled datetime for the scan. + update_state: If True, also reset state to SCHEDULED when updating. + + Returns: + The scan instance to use. + """ + scheduled_scans = list( + Scan.objects.filter( + tenant_id=tenant_id, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE), + scheduler_task_id=scheduler_task_id, + ).order_by("scheduled_at", "inserted_at") + ) + + if scheduled_scans: + scan_instance = scheduled_scans[0] + if len(scheduled_scans) > 1: + Scan.objects.filter(id__in=[s.id for s in scheduled_scans[1:]]).delete() + needs_update = scan_instance.scheduled_at != scheduled_at + if update_state and scan_instance.state != StateChoices.SCHEDULED: + scan_instance.state = StateChoices.SCHEDULED + scan_instance.name = SCHEDULED_SCAN_NAME + needs_update = True + if needs_update: + scan_instance.scheduled_at = scheduled_at + scan_instance.save() + return scan_instance + + return Scan.objects.create( + tenant_id=tenant_id, + name=SCHEDULED_SCAN_NAME, + provider_id=provider_id, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.SCHEDULED, + scheduled_at=scheduled_at, + scheduler_task_id=scheduler_task_id, + ) diff --git a/prowler/compliance/oci/__init__.py b/contrib/aws/simulate_policy/__init__.py similarity index 100% rename from prowler/compliance/oci/__init__.py rename to contrib/aws/simulate_policy/__init__.py diff --git a/contrib/aws/simulate_policy/simulate_policy_client.py b/contrib/aws/simulate_policy/simulate_policy_client.py new file mode 100644 index 0000000000..eebc9d5174 --- /dev/null +++ b/contrib/aws/simulate_policy/simulate_policy_client.py @@ -0,0 +1,20 @@ +# prowler/contrib/aws/simulate_policy_client.py +from typing import Optional + +from prowler.contrib.aws.simulate_policy.simulate_policy_service import IamSimulator +from prowler.providers.common.provider import Provider + +_iam_simulator_client: Optional[IamSimulator] = None + + +def get_iam_simulator_client() -> IamSimulator: + global _iam_simulator_client + if _iam_simulator_client is None: + provider = Provider.get_global_provider() + if provider is None: + # Fail fast with a clear message if somehow called too early + raise RuntimeError( + "Global Provider is not initialized yet for IAM simulator." + ) + _iam_simulator_client = IamSimulator(provider) + return _iam_simulator_client diff --git a/contrib/aws/simulate_policy/simulate_policy_service.py b/contrib/aws/simulate_policy/simulate_policy_service.py new file mode 100644 index 0000000000..b111515bb1 --- /dev/null +++ b/contrib/aws/simulate_policy/simulate_policy_service.py @@ -0,0 +1,200 @@ +# prowler/contrib/aws/simulate_policy_service.py + +import json +import logging +from typing import Dict, List, Optional, Tuple + +from botocore.exceptions import ClientError + +from prowler.providers.common.provider import Provider + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +# ====================================================================== +# PURPOSE +# ---------------------------------------------------------------------- +# This module provides a precise way to test IAM actions programmatically. +# It replicates the behaviour of the AWS CLI command: +# aws iam simulate-principal-policy --policy-source-arn arn:aws:iam:::role/ --action-names +# +# Use this when you need to validate whether a specific IAM role allows or denies +# certain actions against given resources. +# +# ====================================================================== +# CLI ANALOGUE +# ---------------------------------------------------------------------- +# Example equivalent CLI command: +# aws iam simulate-principal-policy \ +# --policy-source-arn arn:aws:iam::278419598935:role/your-role \ +# --action-names datazone:AcceptPredictions +# +# ====================================================================== +# DOCUMENTATION +# ---------------------------------------------------------------------- +# AWS IAM Policy Simulator: +# https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_testing-policies.html +# +# IAM Condition Keys: +# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html +# +# Related AWS SDK discussion: +# https://github.com/aws/aws-sdk/issues/102 +# +# ====================================================================== +# LIMITATIONS +# ---------------------------------------------------------------------- +# - The IAM Policy Simulator does NOT evaluate Service Control Policies (SCPs) +# that include conditions. This is a limitation of the API. +# - In environments where SCPs contain conditions, use +# `is_action_allowed_simulate_custom_policy` instead. +# - In environments without SCP conditions, `is_action_allowed_simulate_principal_policy` +# works as expected. +# +# ====================================================================== +# USAGE +# ---------------------------------------------------------------------- +# In your custom check: +# +# from prowler.contrib.aws.simulate_policy.simulate_policy_client import get_iam_simulator_client +# +# iam_sim = get_iam_simulator_client() +# policy_data = iam_sim.get_role_policy_data(role_name=role_name) +# iam_sim.is_action_allowed_simulate_custom_policy( +# policy_data=policy_data, +# action_names=[action], +# resource_arns=["*"] +# ) +# +# +# ====================================================================== + + +class IamSimulator: + """ + Helper for IAM Policy Simulator: + - simulate_principal_policy + - simulate_custom_policy + - collect role inline/managed policies + """ + + def __init__(self, provider: Provider) -> None: + + boto3_session = provider.session.current_session + + # IAM is a global service. Region is optional; we can use the provider's global region + # to stay consistent across partitions. + try: + region_name = provider.get_global_region() + except AttributeError: + # Fallback if provider lacks the helper (older trees) + region_name = boto3_session.region_name or "us-east-1" + + self.iam = boto3_session.client("iam", region_name=region_name) + + def is_action_allowed_simulate_principal_policy( + self, + principal_arn: str, + action_names: List[str], + resource_arns: Optional[List[str]] = None, + ) -> Tuple[bool, Dict]: + if resource_arns is None: + resource_arns = ["*"] + try: + resp = self.iam.simulate_principal_policy( + PolicySourceArn=principal_arn, + ActionNames=action_names, + ResourceArns=resource_arns, + ) + allowed = any( + r.get("EvalDecision") == "allowed" + for r in resp.get("EvaluationResults", []) + ) + return allowed, resp + except ClientError as e: + logger.error("simulate_principal_policy failed: %s", e, exc_info=True) + return False, {"error": str(e)} + + def get_role_policy_data(self, role_name: str) -> Dict[str, List]: + inline_names: List[str] = [] + inline_docs: List[Dict] = [] + managed_names: List[str] = [] + managed_docs: List[Dict] = [] + + # Inline policies + inline_resp = self.iam.list_role_policies(RoleName=role_name) + inline_names = inline_resp.get("PolicyNames", []) + for pname in inline_names: + pol_resp = self.iam.get_role_policy(RoleName=role_name, PolicyName=pname) + inline_docs.append(pol_resp["PolicyDocument"]) # dict + + # Managed policies + managed_resp = self.iam.list_attached_role_policies(RoleName=role_name) + for attached in managed_resp.get("AttachedPolicies", []): + managed_names.append(attached["PolicyName"]) + pol_meta = self.iam.get_policy(PolicyArn=attached["PolicyArn"])["Policy"] + pol_ver = self.iam.get_policy_version( + PolicyArn=attached["PolicyArn"], VersionId=pol_meta["DefaultVersionId"] + ) + managed_docs.append(pol_ver["PolicyVersion"]["Document"]) # dict + + return { + "inline_policy_names": inline_names, + "inline_policy_data": inline_docs, + "managed_policy_names": managed_names, + "managed_policy_data": managed_docs, + } + + def is_action_allowed_simulate_custom_policy( + self, + policy_data: Dict[str, List], + action_names: List[str], + resource_arns: Optional[List[str]] = None, + ) -> Tuple[bool, Dict]: + names = policy_data.get("inline_policy_names", []) + policy_data.get( + "managed_policy_names", [] + ) + docs = policy_data.get("inline_policy_data", []) + policy_data.get( + "managed_policy_data", [] + ) + + results: Dict[str, List] = {"policies": []} + any_allowed = False + if resource_arns is None: + resource_arns = ["*"] + + for idx, doc in enumerate(docs): + name = names[idx] if idx < len(names) else f"policy_{idx}" + try: + sim_resp = self.iam.simulate_custom_policy( + PolicyInputList=[json.dumps(doc)], + ActionNames=action_names, + ResourceArns=resource_arns, + ) + except ClientError as e: + logger.error( + "simulate_custom_policy failed for %s: %s", name, e, exc_info=True + ) + results["policies"].append({"policy_name": name, "error": str(e)}) + continue + + per_action = [] + for ev in sim_resp.get("EvaluationResults", []): + decision = ev.get( + "EvalDecision" + ) # allowed | explicitDeny | implicitDeny + per_action.append( + { + "action": ev.get("EvalActionName"), + "decision": decision, + "matching_statements": ev.get("MatchedStatements", []), + "missing_context_values": ev.get("MissingContextValues", []), + } + ) + if decision == "allowed": + any_allowed = True + + results["policies"].append({"policy_name": name, "evaluations": per_action}) + + return any_allowed, results diff --git a/contrib/k8s/helm/prowler-app/.helmignore b/contrib/k8s/helm/prowler-app/.helmignore new file mode 100644 index 0000000000..7d250c5aa5 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/.helmignore @@ -0,0 +1,24 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +examples +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/contrib/k8s/helm/prowler-app/Chart.lock b/contrib/k8s/helm/prowler-app/Chart.lock new file mode 100644 index 0000000000..fe4af2f9e2 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/Chart.lock @@ -0,0 +1,12 @@ +dependencies: +- name: postgresql + repository: oci://registry-1.docker.io/bitnamicharts + version: 18.2.0 +- name: valkey + repository: https://valkey.io/valkey-helm/ + version: 0.9.3 +- name: neo4j + repository: https://helm.neo4j.com/neo4j + version: 2025.12.1 +digest: sha256:da19233c6832727345fcdb314d683d30aa347d349f270023f3a67149bffb009b +generated: "2026-01-26T12:00:06.798702+02:00" diff --git a/contrib/k8s/helm/prowler-app/Chart.yaml b/contrib/k8s/helm/prowler-app/Chart.yaml new file mode 100644 index 0000000000..5397d43569 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/Chart.yaml @@ -0,0 +1,33 @@ +apiVersion: v2 +name: prowler +description: Prowler is an Open Cloud Security tool for AWS, Azure, GCP and Kubernetes. It helps for continuous monitoring, security assessments and audits, incident response, compliance, hardening and forensics readiness. +type: application +version: 0.0.1 +appVersion: "5.17.0" +home: https://prowler.com +icon: https://cdn.prod.website-files.com/68c4ec3f9fb7b154fbcb6e36/68c5e0fea5d0059b9e05834b_Link.png +keywords: + - security + - aws + - azure + - gcp + - kubernetes +maintainers: + - name: Mihai + email: mihai.legat@gmail.com +dependencies: + # https://artifacthub.io/packages/helm/bitnami/postgresql + - name: postgresql + version: 18.2.0 + repository: oci://registry-1.docker.io/bitnamicharts + condition: postgresql.enabled + # https://valkey.io/valkey-helm/ + - name: valkey + version: 0.9.3 + repository: https://valkey.io/valkey-helm/ + condition: valkey.enabled + # https://helm.neo4j.com/neo4j + - name: neo4j + version: 2025.12.1 + repository: https://helm.neo4j.com/neo4j + condition: neo4j.enabled diff --git a/contrib/k8s/helm/prowler-app/README.md b/contrib/k8s/helm/prowler-app/README.md new file mode 100644 index 0000000000..544b5f39a7 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/README.md @@ -0,0 +1,143 @@ + + +# Prowler App Helm Chart + +![Version: 0.0.1](https://img.shields.io/badge/Version-0.0.1-informational?style=flat-square) +![AppVersion: 5.17.0](https://img.shields.io/badge/AppVersion-5.17.0-informational?style=flat-square) + +Prowler is an Open Cloud Security tool for AWS, Azure, GCP and Kubernetes. It helps for continuous monitoring, security assessments and audits, incident response, compliance, hardening and forensics readiness. Includes CIS, NIST 800, NIST CSF, CISA, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, Well-Architected Security, ENS and more. + +## Architecture + +The Prowler App consists of three main components: + +- **Prowler UI**: A user-friendly web interface for running Prowler and viewing results, powered by Next.js. +- **Prowler API**: The backend API that executes Prowler scans and stores the results, built with Django REST Framework. +- **Prowler SDK**: A Python SDK that integrates with the Prowler CLI for advanced functionality. + +The app leverages the following supporting infrastructure: + +- **PostgreSQL**: Used for persistent storage of scan results. +- **Celery Workers**: Facilitate asynchronous execution of Prowler scans. +- **Valkey**: An in-memory database serving as a message broker for the Celery workers. +- **Neo4j**: Graph Database +- **Keda**: Kubernetes Event-driven Autoscaling (Keda) automatically scales the number of Celery worker pods based on the workload, ensuring efficient resource utilization and responsiveness. + +## Setup + +This guide walks you through installing Prowler App using Helm. For a minimal installation example, see the [minimal installation example](./examples/minimal-installation/). + +### Prerequisites + +- Kubernetes cluster (1.24+) +- Helm 3.x installed +- `kubectl` configured to access your cluster +- Access to the Prowler Helm chart repository (or local chart) + +### Step 1: Create Required Secrets + +Before installing the Helm chart, you must create a Kubernetes Secret containing the required authentication keys and secrets. + +1. **Generate the required keys and secrets:** + + ```bash + # Generate Django token signing key (private key) + openssl genrsa -out private.pem 2048 + + # Generate Django token verifying key (public key) + openssl rsa -in private.pem -pubout -out public.pem + + # Generate Django secrets encryption key + openssl rand -base64 32 + + # Generate Auth secret + openssl rand -base64 32 + ``` + +2. **Create the secret file:** + + Create a file named `secrets.yaml` with the following structure: + + ```yaml + apiVersion: v1 + kind: Secret + type: Opaque + metadata: + name: prowler-secret + stringData: + DJANGO_TOKEN_SIGNING_KEY: | + -----BEGIN PRIVATE KEY----- + [paste your private key here] + -----END PRIVATE KEY----- + + DJANGO_TOKEN_VERIFYING_KEY: | + -----BEGIN PUBLIC KEY----- + [paste your public key here] + -----END PUBLIC KEY----- + + DJANGO_SECRETS_ENCRYPTION_KEY: "[paste your encryption key here]" + + AUTH_SECRET: "[paste your auth secret here]" + + NEO4J_PASSWORD: "[prowler-password]" + NEO4J_AUTH: "neo4j/[prowler-password]" + ``` + + > **Note:** You can use the [example secrets file](./examples/minimal-installation/secrets.yaml) as a template, but **always replace the placeholder values with your own secure keys** before applying. + +3. **Apply the secret to your cluster:** + + ```bash + kubectl apply -f secrets.yaml + ``` + +### Step 2: Configure Values + +Create a `values.yaml` file to customize your installation. At minimum, you need to configure the UI access method. + +**Option A: Using Ingress (Recommended for production)** + +```yaml +ui: + ingress: + enabled: true + hosts: + - host: prowler.example.com + paths: + - path: / + pathType: ImplementationSpecific +``` + +**Option B: Using authUrl (For proxy setups)** + +```yaml +ui: + authUrl: prowler.example.com +``` + +> **Note:** See the [minimal installation example](./examples/minimal-installation/values.yaml) for a complete reference. + +### Step 3: Install the Chart + +Install Prowler App using Helm: + +```bash +helm dependency update +helm install prowler prowler/prowler-app -f values.yaml +``` + +### Using Existing PostgreSQL and Valkey Instances + +By default, this Chart uses Bitnami's Charts to deploy [PostgreSQL](https://artifacthub.io/packages/helm/bitnami/postgresql), [Neo4j](https://helm.neo4j.com/neo4j) and [Valkey official helm chart](https://valkey.io/valkey-helm/). **Note:** This default setup is not production-ready. + +To connect to existing PostgreSQL, Neo4j and Valkey instances: + +1. Create a `Secret` containing the correct database and message broker credentials +2. Reference the secret in the [values.yaml](values.yaml) file api->secrets list + +## Contributing + +Feel free to contact the maintainer of this repository for any questions or concerns. Contributions are encouraged and appreciated. diff --git a/contrib/k8s/helm/prowler-app/examples/minimal-installation/README.md b/contrib/k8s/helm/prowler-app/examples/minimal-installation/README.md new file mode 100644 index 0000000000..e22f429b44 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/examples/minimal-installation/README.md @@ -0,0 +1,46 @@ +# Minimal Installation Example + +This example demonstrates a minimal installation of Prowler in a Kubernetes cluster. + +## Installation + +To install Prowler using this example: + +1. First, create the required secret: +```bash +# Edit secret.yaml and set secure values before applying +kubectl apply -f secret.yaml +``` + +1. Install the chart using the base values file: +```bash +# Basic installation +helm install prowler prowler/prowler-app -f values.yaml +``` + +## Configuration + +The example contains the following configuration files: + +### `secret.yaml` +Contains all required secrets for the Prowler installation. **Must be applied before installing the Helm chart**. Make sure to replace all placeholder values with secure values before applying. + +### `values.yaml` +```yaml +ui: + # Note: You should set either `authUrl` if you use prowler behind a proxy or enable `ingress`. + + # Example with authUrl: + # authUrl: example.prowler.com + + # Example with ingress: + ingress: + enabled: true + hosts: + - host: example.prowler.com + paths: + - path: / + pathType: ImplementationSpecific +``` + +Make sure to adjust the hostname in the values file to match your environment before installing. diff --git a/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml b/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml new file mode 100644 index 0000000000..2e379ef5c8 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/examples/minimal-installation/secrets.yaml @@ -0,0 +1,58 @@ +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: prowler-secret +stringData: + # openssl genrsa -out private.pem 2048 + DJANGO_TOKEN_SIGNING_KEY: | + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCIro0QiLAxw7rF + GO0NgAWJfkpYE5ysMGDCbId07HUrv+/SCoRjqKVzGJVIvmNP5oByzSehPgswW9v3 + 3dqe2r9sCS1JyMa+XO3qfZCR0uRDcPCwZjIyr0QQLpWAymdBa8baeHsU1/3Orjcb + Vrr+lNx4HQJOiSn094iXPReW/25hYeq/SXs79V2CR87PGdoZAhb8IllAxJgdfkeB + /iWohY/1vfRTmIuMweWGXk0aKzPsBdvE/DqG4HjiNVEPh18G3vid0YTZNmm7u8vO + Cue3x9NQWGHA4QtxNtLtxlHcOEryqZ9ChO2nC+ew0Xl/v706XFNyLFicjisIKNQo + qdkaMS33AgMBAAECggEAGdJIChCYoL4mYafk2MEPyrrWFq+V0J3PGcvhB0DInfxD + tT2RZzZsE0NYqIZ3Qpf8OjPxwa9z863W74u1Cn+u3B0bti29BieONteD4VijEO6c + OecEorijth7m1Y7nVN+kkI9kSTrI0yvsczi+WOwMfpCUZ/vXtlSxNEkxVLBqzPCo + 9VxAFIjgWOj2rpw8nxPedves36PUrC5ghLqrOTe1jmw/Di0++47AXG+DsTXc00sc + 5+oybopm3Kimsxrqbf9s8SZf2A8NiwqcbLj8OtP2j2g4TCEgZYLD5Zmt+JN/wN4B + WsQG/Hwp4KPPm9QTHEpuuoPFP1CZWZeq8gPcV4apYQKBgQC+TuXjJCYhZqNIttTZ + z/i3hkKUEKQLkzTZnXaDzL5wHyEMVqM2E/WkilO0C9ZZwh0ENPzkp+JsHf7LEhHy + wSHOti81VzUCjN/YpCBKlOlClqSiDlOonImrobLei8xgvmA0VmGtirCXZyyzZUoV + OyPr17WpK6G/M5piX59MvKQg0QKBgQC33NBoQFD8A6FjrTopYmWfK099k9uQh9NE + bvUYsNAPunSDslmc/0PPHQC7fRX5Ime2BinXAN1PYtB/Fsu3jv/+FCUM5hVil0Dd + KBvt13+RYSCJKlhcGP1EkWoIg1F2XXBOZKJrC8VQ+Vyl2t06UcWQqy5M9J4VZaqI + fruOLU/URwKBgE55GjJfZZnASPRi78IhD94dbra/ZeWf/dr+IzCV7LEvJOGBmCtk + b5Y5s+o6N1krwetKLj3bPHJ4q+fwu5XuLZKfbTgBjcpPbL5YbzhRzx22IIzye2y7 + n8k2FBvQaaY62lC6jeyRk9/am4Qd8D5w9I77k9z+MOQ20yJda8KoxsUBAoGBAIQ9 + 5QPmppjsf4ry0C9t30uhWhYnX7fPiYviBpVQrwVxBVan076Q9xOjd6BicohzT4bj + XfqPW546o12VZsbKqqLzmEZzwpPb2EJ5E8V4xv8ojb86Xr03GArWUB55XQE2aY1o + 4kz99VitUg7UoWPN5ryL8sxU8NLRAdwU0w+K1a0HAoGAZaU7O94u9IIPZ6Ohobs2 + Vjf/eV0brCKgX61b4z/YhuJdZsyTujhBZUihZwqR696kiFKuzmHx1ghE2ITvnPVN + q0iHxRZzBCnRQ+mQlS0trzphaCP0NVy3osFeAD9mJfnOnSmkU0ua4F81mkvke1eN + 6nnaoAdy2lmMr96/Tye2ty4= + -----END PRIVATE KEY----- + + # openssl rsa -in private.pem -pubout -out public.pem + DJANGO_TOKEN_VERIFYING_KEY: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiK6NEIiwMcO6xRjtDYAF + iX5KWBOcrDBgwmyHdOx1K7/v0gqEY6ilcxiVSL5jT+aAcs0noT4LMFvb993antq/ + bAktScjGvlzt6n2QkdLkQ3DwsGYyMq9EEC6VgMpnQWvG2nh7FNf9zq43G1a6/pTc + eB0CTokp9PeIlz0Xlv9uYWHqv0l7O/VdgkfOzxnaGQIW/CJZQMSYHX5Hgf4lqIWP + 9b30U5iLjMHlhl5NGisz7AXbxPw6huB44jVRD4dfBt74ndGE2TZpu7vLzgrnt8fT + UFhhwOELcTbS7cZR3DhK8qmfQoTtpwvnsNF5f7+9OlxTcixYnI4rCCjUKKnZGjEt + 9wIDAQAB + -----END PUBLIC KEY----- + + # openssl rand -base64 32 + DJANGO_SECRETS_ENCRYPTION_KEY: "qYAIWnRK52aBT5YQkBoMEw08j7j3+QIPZXS6+A8Su44=" + + # openssl rand -base64 32 + AUTH_SECRET: "CM9w3Nco2P1RdHaYmD+fmy2nJmSofusdHd4g7Z4KDG4=" + + # Unfortunatelly, we need to duplicate the password in two different keys because the Neo4j Helm Chart expects the password in the NEO4J_AUTH key and the application expects it in the NEO4J_PASSWORD key. + NEO4J_PASSWORD: "prowler-password-fake" + NEO4J_AUTH: "neo4j/prowler-password-fake" diff --git a/contrib/k8s/helm/prowler-app/examples/minimal-installation/values.yaml b/contrib/k8s/helm/prowler-app/examples/minimal-installation/values.yaml new file mode 100644 index 0000000000..9ac8dda9e9 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/examples/minimal-installation/values.yaml @@ -0,0 +1,11 @@ +ui: + ingress: + enabled: true + hosts: + - host: 127.0.0.1.nip.io + paths: + - path: / + pathType: ImplementationSpecific + +# or use authUrl if you use prowler behind a proxy +# authUrl: 127.0.0.1.nip.io diff --git a/contrib/k8s/helm/prowler-app/templates/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/_helpers.tpl new file mode 100644 index 0000000000..7698fbfa18 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/_helpers.tpl @@ -0,0 +1,134 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "prowler.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "prowler.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "prowler.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "prowler.labels" -}} +helm.sh/chart: {{ include "prowler.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Django environment variables for api, worker, and worker_beat. +*/}} +{{- define "prowler.django.env" -}} +- name: DJANGO_TOKEN_SIGNING_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.djangoTokenSigningKey.secretKeyRef.name }} + key: {{ .Values.djangoTokenSigningKey.secretKeyRef.key }} +- name: DJANGO_TOKEN_VERIFYING_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.djangoTokenVerifyingKey.secretKeyRef.name }} + key: {{ .Values.djangoTokenVerifyingKey.secretKeyRef.key }} +- name: DJANGO_SECRETS_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.djangoSecretsEncryptionKey.secretKeyRef.name }} + key: {{ .Values.djangoSecretsEncryptionKey.secretKeyRef.key }} +{{- end }} + + +{{/* +PostgreSQL environment variables for api, worker, and worker_beat. +Outputs nothing when postgresql.enabled is false. +*/}} +{{- define "prowler.postgresql.env" -}} +{{- if .Values.postgresql.enabled }} +{{- if .Values.postgresql.auth.username }} +- name: POSTGRES_USER + value: {{ .Values.postgresql.auth.username | quote }} +{{- end }} +- name: POSTGRES_PASSWORD +{{- if .Values.postgresql.auth.existingSecret }} + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.auth.existingSecret }} + key: {{ required "postgresql.auth.secretKeys.userPasswordKey is required when using an existing secret" .Values.postgresql.auth.secretKeys.userPasswordKey }} +{{- else if .Values.postgresql.auth.password }} + value: {{ .Values.postgresql.auth.password | quote }} +{{- else }} + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgresql + key: password +{{- end }} +- name: POSTGRES_DB + value: {{ .Values.postgresql.auth.database | quote }} +- name: POSTGRES_HOST + value: {{ .Release.Name }}-postgresql +- name: POSTGRES_PORT + value: "5432" +- name: POSTGRES_ADMIN_USER + value: postgres +- name: POSTGRES_ADMIN_PASSWORD +{{- if .Values.postgresql.auth.existingSecret }} + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.auth.existingSecret }} + key: {{ required "postgresql.auth.secretKeys.adminPasswordKey is required when using an existing secret" .Values.postgresql.auth.secretKeys.adminPasswordKey }} +{{- else if .Values.postgresql.auth.postgresPassword }} + value: {{ .Values.postgresql.auth.postgresPassword | quote }} +{{- else }} + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgresql + key: postgres-password +{{- end }} +{{- end }} +{{- end }} + +{{/* +Neo4j environment variables for api, worker, and worker_beat. +Outputs nothing when neo4j.enabled is false. +*/}} +{{- define "prowler.neo4j.env" -}} +{{- if .Values.neo4j.enabled }} +- name: NEO4J_HOST + value: {{ .Release.Name }} +- name: NEO4J_PORT + value: "7687" +- name: NEO4J_USER + value: "neo4j" +- name: NEO4J_PASSWORD + valueFrom: + secretKeyRef: + name: {{ required "neo4j.neo4j.passwordFromSecret is required" .Values.neo4j.neo4j.passwordFromSecret }} + key: NEO4J_PASSWORD +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/api/_helpers.tpl new file mode 100644 index 0000000000..55ac97f0d8 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.api.serviceAccountName" -}} +{{- if .Values.api.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "api") .Values.api.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.api.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml new file mode 100644 index 0000000000..8e219a9271 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml @@ -0,0 +1,10 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.api.djangoConfig }} + {{ $key }}: {{ $value | quote }} + {{- end }} \ No newline at end of file diff --git a/contrib/k8s/helm/prowler-app/templates/api/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/api/deployment.yaml new file mode 100644 index 0000000000..f7a16b66ae --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/deployment.yaml @@ -0,0 +1,105 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + {{- if not .Values.api.autoscaling.enabled }} + replicas: {{ .Values.api.replicaCount }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-api + template: + metadata: + annotations: + secret-hash: "{{ printf "%s%s%s" (.Files.Get "templates/api/configmap.yaml" | sha256sum) (.Files.Get "templates/api/secret-valkey.yaml" | sha256sum) | sha256sum }}" + {{- with .Values.api.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-api + {{- with .Values.api.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.api.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.api.serviceAccountName" . }} + {{- with .Values.api.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: api + {{- with .Values.api.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy }} + {{- with .Values.api.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.api.service.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-api + {{- if .Values.valkey.enabled }} + - secretRef: + name: {{ include "prowler.fullname" . }}-api-valkey + {{- end }} + {{- with .Values.api.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + env: + {{- include "prowler.django.env" . | nindent 12 }} + {{- include "prowler.postgresql.env" . | nindent 12 }} + {{- include "prowler.neo4j.env" . | nindent 12 }} + {{- with .Values.api.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.api.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/hpa.yaml b/contrib/k8s/helm/prowler-app/templates/api/hpa.yaml new file mode 100644 index 0000000000..c3d77d7e44 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.api.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "prowler.fullname" . }}-api + minReplicas: {{ .Values.api.autoscaling.minReplicas }} + maxReplicas: {{ .Values.api.autoscaling.maxReplicas }} + metrics: + {{- if .Values.api.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.api.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.api.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.api.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/ingress.yaml b/contrib/k8s/helm/prowler-app/templates/api/ingress.yaml new file mode 100644 index 0000000000..4118d9cd7a --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.api.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.api.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.api.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.api.ingress.tls }} + tls: + {{- range .Values.api.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.api.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "prowler.fullname" $ }}-api + port: + number: {{ $.Values.api.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/api/role.yaml b/contrib/k8s/helm/prowler-app/templates/api/role.yaml new file mode 100644 index 0000000000..172b035076 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/role.yaml @@ -0,0 +1,29 @@ +# https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/#step-44-kubernetes-credentials +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +rules: +- apiGroups: [""] + resources: ["pods", "configmaps", "nodes", "namespaces"] + verbs: ["get", "list", "watch"] +- apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterrolebindings", "rolebindings", "clusterroles", "roles"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "prowler.fullname" . }}-api +subjects: +- kind: ServiceAccount + name: {{ include "prowler.api.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} \ No newline at end of file diff --git a/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml b/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml new file mode 100644 index 0000000000..7778d06731 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml @@ -0,0 +1,13 @@ +{{- if .Values.valkey.enabled -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "prowler.fullname" . }}-api-valkey + labels: + {{- include "prowler.labels" . | nindent 4 }} +type: Opaque +stringData: + VALKEY_HOST: "{{ include "prowler.fullname" . }}-valkey" + VALKEY_PORT: "6379" + VALKEY_DB: "0" +{{- end -}} diff --git a/contrib/k8s/helm/prowler-app/templates/api/service.yaml b/contrib/k8s/helm/prowler-app/templates/api/service.yaml new file mode 100644 index 0000000000..9a42979306 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "prowler.fullname" . }}-api + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + type: {{ .Values.api.service.type }} + ports: + - port: {{ .Values.api.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-api diff --git a/contrib/k8s/helm/prowler-app/templates/api/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/api/serviceaccount.yaml new file mode 100644 index 0000000000..4d76d7f54e --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/api/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.api.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.api.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.api.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.api.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/ui/_helpers.tpl new file mode 100644 index 0000000000..8bdf93ba5f --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.ui.serviceAccountName" -}} +{{- if .Values.ui.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "ui") .Values.ui.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.ui.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml new file mode 100644 index 0000000000..38d6e65ee3 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml @@ -0,0 +1,18 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: {{ include "prowler.fullname" . }}-ui +data: + PROWLER_UI_VERSION: "stable" + {{- if .Values.ui.ingress.enabled }} + {{- with (first .Values.ui.ingress.hosts) }} + AUTH_URL: "https://{{ .host }}" + {{- end }} + {{- else }} + AUTH_URL: {{ .Values.ui.authUrl | quote }} + {{- end }} + API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" + AUTH_TRUST_HOST: "true" + UI_PORT: {{ .Values.ui.service.port | quote }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/ui/deployment.yaml new file mode 100644 index 0000000000..f7bf2c17fe --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/deployment.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + {{- if not .Values.ui.autoscaling.enabled }} + replicas: {{ .Values.ui.replicaCount }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-ui + template: + metadata: + annotations: + secret-hash: {{ .Files.Get "templates/ui/configmap.yaml" | sha256sum }} + {{- with .Values.ui.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-ui + {{- with .Values.ui.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.ui.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.ui.serviceAccountName" . }} + {{- with .Values.ui.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: ui + {{- with .Values.ui.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.ui.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.ui.service.port }} + protocol: TCP + env: + - name: AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.ui.authSecret.secretKeyRef.name }} + key: {{ .Values.ui.authSecret.secretKeyRef.key }} + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-ui + {{- with .Values.ui.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + {{- with .Values.ui.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.ui.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/hpa.yaml b/contrib/k8s/helm/prowler-app/templates/ui/hpa.yaml new file mode 100644 index 0000000000..7c6716ef1f --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.ui.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "prowler.fullname" . }}-ui + minReplicas: {{ .Values.ui.autoscaling.minReplicas }} + maxReplicas: {{ .Values.ui.autoscaling.maxReplicas }} + metrics: + {{- if .Values.ui.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.ui.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.ui.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.ui.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/ingress.yaml b/contrib/k8s/helm/prowler-app/templates/ui/ingress.yaml new file mode 100644 index 0000000000..74dcecabe1 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ui.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.ui.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ui.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ui.ingress.tls }} + tls: + {{- range .Values.ui.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ui.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "prowler.fullname" $ }}-ui + port: + number: {{ $.Values.ui.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/ui/service.yaml b/contrib/k8s/helm/prowler-app/templates/ui/service.yaml new file mode 100644 index 0000000000..9b845e5b5f --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "prowler.fullname" . }}-ui + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + type: {{ .Values.ui.service.type }} + ports: + - port: {{ .Values.ui.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-ui diff --git a/contrib/k8s/helm/prowler-app/templates/ui/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/ui/serviceaccount.yaml new file mode 100644 index 0000000000..91b176a64e --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/ui/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.ui.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.ui.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.ui.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.ui.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/worker/_helpers.tpl new file mode 100644 index 0000000000..3a99d42cb6 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.worker.serviceAccountName" -}} +{{- if .Values.worker.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "worker") .Values.worker.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.worker.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml new file mode 100644 index 0000000000..6c11a28f9b --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/deployment.yaml @@ -0,0 +1,101 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-worker + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + {{- if not .Values.worker.autoscaling.enabled }} + replicas: {{ .Values.worker.replicaCount }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker + template: + metadata: + annotations: + secret-hash: "{{ printf "%s%s%s" (.Files.Get "templates/api/configmap.yaml" | sha256sum) (.Files.Get "templates/api/secret-valkey.yaml" | sha256sum) | sha256sum }}" + {{- with .Values.worker.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker + {{- with .Values.worker.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.worker.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.worker.serviceAccountName" . }} + {{- with .Values.worker.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: worker + {{- with .Values.worker.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.worker.image.pullPolicy }} + {{- with .Values.worker.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-api + {{- if .Values.valkey.enabled }} + - secretRef: + name: {{ include "prowler.fullname" . }}-api-valkey + {{- end }} + {{- with .Values.api.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + env: + {{- include "prowler.django.env" . | nindent 12 }} + {{- include "prowler.postgresql.env" . | nindent 12 }} + {{- include "prowler.neo4j.env" . | nindent 12 }} + {{- with .Values.worker.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/hpa.yaml b/contrib/k8s/helm/prowler-app/templates/worker/hpa.yaml new file mode 100644 index 0000000000..d77ab3f47e --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.worker.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "prowler.fullname" . }}-worker + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "prowler.fullname" . }}-worker + minReplicas: {{ .Values.worker.autoscaling.minReplicas }} + maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }} + metrics: + {{- if .Values.worker.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.worker.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.worker.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/scaled-object.yaml b/contrib/k8s/helm/prowler-app/templates/worker/scaled-object.yaml new file mode 100644 index 0000000000..98ae3ae9d5 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/scaled-object.yaml @@ -0,0 +1,32 @@ +{{- if .Values.worker.keda.enabled }} +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: {{ include "prowler.fullname" . }}-worker + namespace: {{ $.Release.Namespace }} + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + scaleTargetRef: + name: {{ include "prowler.fullname" . }}-worker + envSourceContainerName: worker + kind: Deployment + minReplicaCount: {{ .Values.worker.keda.minReplicas }} + maxReplicaCount: {{ .Values.worker.keda.maxReplicas }} + pollingInterval: {{ .Values.worker.keda.pollingInterval }} + cooldownPeriod: {{ .Values.worker.keda.cooldownPeriod }} + triggers: + - type: {{ .Values.worker.keda.triggerType }} + metadata: + userName: "postgres" + passwordFromEnv: POSTGRES_ADMIN_PASSWORD + host: {{ .Release.Name }}-postgresql + port: {{ .Values.postgresql.port | quote }} + dbName: {{ .Values.postgresql.auth.database | quote }} + sslmode: disable + # Query for KEDA to count the number of scans that are in executing, available, or scheduled states, + # where the scheduled time is within the last 2 hours and is before NOW(). Used for scaling workers. + query: >- + SELECT COUNT(*) FROM scans WHERE ((state='executing' OR state='available' OR state='scheduled') and scheduled_at < NOW() and scheduled_at > NOW() - INTERVAL '2 hours') + targetQueryValue: "1" +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/worker/serviceaccount.yaml new file mode 100644 index 0000000000..8974d3ce04 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.worker.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.worker.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.worker.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.worker.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker_beat/_helpers.tpl b/contrib/k8s/helm/prowler-app/templates/worker_beat/_helpers.tpl new file mode 100644 index 0000000000..b9ce287667 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker_beat/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Create the name of the service account to use +*/}} +{{- define "prowler.worker_beat.serviceAccountName" -}} +{{- if .Values.worker_beat.serviceAccount.create }} +{{- default (printf "%s-%s" (include "prowler.fullname" .) "worker-beat") .Values.worker_beat.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.worker_beat.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml b/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml new file mode 100644 index 0000000000..749ea946fd --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker_beat/deployment.yaml @@ -0,0 +1,99 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "prowler.fullname" . }}-worker-beat + labels: + {{- include "prowler.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.worker_beat.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker-beat + template: + metadata: + annotations: + secret-hash: "{{ printf "%s%s%s" (.Files.Get "templates/api/configmap.yaml" | sha256sum) (.Files.Get "templates/api/secret-valkey.yaml" | sha256sum) | sha256sum }}" + {{- with .Values.worker.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "prowler.labels" . | nindent 8 }} + app.kubernetes.io/name: {{ include "prowler.fullname" . }}-worker-beat + {{- with .Values.worker_beat.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.worker_beat.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "prowler.worker_beat.serviceAccountName" . }} + {{- with .Values.worker_beat.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: worker-beat + {{- with .Values.worker_beat.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.worker_beat.image.repository }}:{{ .Values.worker_beat.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.worker_beat.image.pullPolicy }} + {{- with .Values.worker_beat.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "prowler.fullname" . }}-api + {{- if .Values.valkey.enabled }} + - secretRef: + name: {{ include "prowler.fullname" . }}-api-valkey + {{- end }} + {{- with .Values.api.secrets }} + {{- range $index, $secret := . }} + - secretRef: + name: {{ $secret }} + {{- end }} + {{- end }} + env: + {{- include "prowler.django.env" . | nindent 12 }} + {{- include "prowler.postgresql.env" . | nindent 12 }} + {{- include "prowler.neo4j.env" . | nindent 12 }} + {{- with .Values.worker_beat.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.worker_beat.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker_beat.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker_beat.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker_beat.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/contrib/k8s/helm/prowler-app/templates/worker_beat/serviceaccount.yaml b/contrib/k8s/helm/prowler-app/templates/worker_beat/serviceaccount.yaml new file mode 100644 index 0000000000..9718686c2a --- /dev/null +++ b/contrib/k8s/helm/prowler-app/templates/worker_beat/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.worker_beat.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "prowler.worker_beat.serviceAccountName" . }} + labels: + {{- include "prowler.labels" . | nindent 4 }} + {{- with .Values.worker_beat.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.worker_beat.serviceAccount.automount }} +{{- end }} diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml new file mode 100644 index 0000000000..46258351ad --- /dev/null +++ b/contrib/k8s/helm/prowler-app/values.yaml @@ -0,0 +1,566 @@ +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# Reference to the secret containing the API authentication secret. +# Used to inject the environment variable for the API container. +djangoTokenSigningKey: + secretKeyRef: + name: prowler-secret + key: DJANGO_TOKEN_SIGNING_KEY +djangoTokenVerifyingKey: + secretKeyRef: + name: prowler-secret + key: DJANGO_TOKEN_VERIFYING_KEY +djangoSecretsEncryptionKey: + secretKeyRef: + name: prowler-secret + key: DJANGO_SECRETS_ENCRYPTION_KEY + +ui: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-ui + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # Reference to the secret containing the UI authentication secret. + # Used to inject the environment variable for the UI container. + # By default, expects a Secret named 'prowler-secret' with a key 'AUTH_SECRET'. + authSecret: + secretKeyRef: + name: prowler-secret + key: AUTH_SECRET + + # Secret names to be used as env vars. + secrets: [] + # - "prowler-ui-secret" + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + # This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ + service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 3000 + + # The URL of the UI. This is only set if ingress is disabled. + authUrl: "" + + # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ + ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + + # This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + +api: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-api + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + # Shared with celery-worker and celery-beat + djangoConfig: + # API scan settings + # The path to the directory where scan output should be stored + DJANGO_TMP_OUTPUT_DIRECTORY: "/tmp/prowler_api_output" + # The maximum number of findings to process in a single batch + DJANGO_FINDINGS_BATCH_SIZE: "1000" + # Django settings + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_BIND_ADDRESS: "0.0.0.0" + DJANGO_PORT: "8080" + DJANGO_DEBUG: "False" + DJANGO_SETTINGS_MODULE: "config.django.production" + # Select one of [ndjson|human_readable] + DJANGO_LOGGING_FORMATTER: "ndjson" + # Select one of [DEBUG|INFO|WARNING|ERROR|CRITICAL] + # Applies to both Django and Celery Workers + DJANGO_LOGGING_LEVEL: "INFO" + # Defaults to the maximum available based on CPU cores if not set. + DJANGO_WORKERS: "4" + # Token lifetime is in minutes + DJANGO_ACCESS_TOKEN_LIFETIME: "30" + # Token lifetime is in minutes + DJANGO_REFRESH_TOKEN_LIFETIME: "1440" + DJANGO_CACHE_MAX_AGE: "3600" + DJANGO_STALE_WHILE_REVALIDATE: "60" + DJANGO_MANAGE_DB_PARTITIONS: "True" + DJANGO_BROKER_VISIBILITY_TIMEOUT: "86400" + + # Secret names to be used as env vars for api, worker, and worker_beat. + secrets: [] + # - "prowler-api-keys" + + command: + - /home/prowler/docker-entrypoint.sh + args: + - prod + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + # This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ + service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 8080 + + # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ + ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # 3m30s to setup DB + # startupProbe: + # httpGet: + # path: /api/v1/docs + # port: http + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: + failureThreshold: 10 + httpGet: + path: /api/v1/docs + port: http + periodSeconds: 20 + readinessProbe: + failureThreshold: 10 + httpGet: + path: /api/v1/docs + port: http + periodSeconds: 20 + + # This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + +worker: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-api + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + command: + - /home/prowler/docker-entrypoint.sh + args: + - worker + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: {} + readinessProbe: {} + + # This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + + # KEDA ScaledObject configuration + keda: + # -- Set to `true` to enable KEDA for the worker pods + # Note: When both KEDA and HPA are enabled, the deployment will fail. + enabled: false + # -- The minimum number of replicas to use for the worker pods + minReplicas: 1 + # -- The maximum number of replicas to use for the worker pods + maxReplicas: 2 + # -- The polling interval in seconds for checking metrics + pollingInterval: 30 + # -- The cooldown period in seconds for scaling + cooldownPeriod: 120 + # -- The trigger type for scaling (cpu or memory) + triggerType: "postgresql" + # -- The target utilization percentage for the worker pods + value: "50" + +worker_beat: + # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ + replicaCount: 1 + + # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ + image: + repository: prowlercloud/prowler-api + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + + command: + - ../docker-entrypoint.sh + args: + - beat + + # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + imagePullSecrets: [] + + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ + serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + # This is for setting Kubernetes Annotations to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + podAnnotations: {} + # This is for setting Kubernetes Labels to a Pod. + # For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + podLabels: {} + + podSecurityContext: {} + # fsGroup: 2000 + + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + # This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + livenessProbe: {} + readinessProbe: {} + + # Additional volumes on the output Deployment definition. + volumes: [] + # - name: foo + # secret: + # secretName: mysecret + # optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: [] + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true + + nodeSelector: {} + + tolerations: [] + + affinity: {} + +postgresql: + # -- Enable PostgreSQL deployment (via Bitnami Helm Chart). If you want to use an external Postgres server (or a managed one), set this to false + # If enabled, it will create a Secret with the credentials. + # Otherwise, create a secret with the following and add it to the api deployment: + # - POSTGRES_HOST + # - POSTGRES_PORT + # - POSTGRES_ADMIN_USER - Existing user in charge of migrations, tables, permissions, RLS + # - POSTGRES_ADMIN_PASSWORD + # - POSTGRES_USER - Will be created by ADMIN_USER + # - POSTGRES_PASSWORD + # - POSTGRES_DB - Existing DB + enabled: true + image: + repository: "bitnami/postgresql" + auth: + database: prowler_db + username: prowler + +valkey: + # If enabled, it will create a Secret with the following. + # Otherwise, create a secret with + # - VALKEY_HOST + # - VALKEY_PORT + # - VALKEY_DB + enabled: true + +neo4j: + enabled: true + + neo4j: + name: prowler-neo4j + edition: community + + # The name of the secret containing the Neo4j password with the key NEO4J_PASSWORD + passwordFromSecret: prowler-secret + + # Disable lookups during helm template rendering (required for ArgoCD) + disableLookups: true + + volumes: + data: + mode: defaultStorageClass + + services: + neo4j: + enabled: false + + # Neo4j Configuration (yaml format) + config: + dbms_security_procedures_allowlist: "apoc.*" + dbms_security_procedures_unrestricted: "apoc.*" + + apoc_config: + apoc.export.file.enabled: "true" + apoc.import.file.enabled: "true" + apoc.import.file.use_neo4j_config: "true" diff --git a/dashboard/assets/images/providers/alibabacloud_provider.png b/dashboard/assets/images/providers/alibabacloud_provider.png new file mode 100644 index 0000000000..7b14b0c33b Binary files /dev/null and b/dashboard/assets/images/providers/alibabacloud_provider.png differ diff --git a/dashboard/compliance/cis_1_12_kubernetes.py b/dashboard/compliance/cis_1_12_kubernetes.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_1_12_kubernetes.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_2_0_alibabacloud.py b/dashboard/compliance/cis_2_0_alibabacloud.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_2_0_alibabacloud.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_3_1_oraclecloud.py b/dashboard/compliance/cis_3_1_oraclecloud.py new file mode 100644 index 0000000000..7d51acf0f4 --- /dev/null +++ b/dashboard/compliance/cis_3_1_oraclecloud.py @@ -0,0 +1,41 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + """ + Generate CIS OCI Foundations Benchmark v3.1 compliance table. + + Args: + data: DataFrame containing compliance check results with columns: + - REQUIREMENTS_ID: CIS requirement ID (e.g., "1.1", "2.1") + - REQUIREMENTS_DESCRIPTION: Description of the requirement + - REQUIREMENTS_ATTRIBUTES_SECTION: CIS section name + - CHECKID: Prowler check identifier + - STATUS: Check status (PASS/FAIL) + - REGION: OCI region + - ACCOUNTID: OCI tenancy OCID (renamed from TENANCYID) + - RESOURCEID: Resource OCID or identifier + + Returns: + Section containers organized by CIS sections for dashboard display + """ + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_5_0_azure.py b/dashboard/compliance/cis_5_0_azure.py new file mode 100644 index 0000000000..9d33cc67a8 --- /dev/null +++ b/dashboard/compliance/cis_5_0_azure.py @@ -0,0 +1,25 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/cis_6_0_m365.py b/dashboard/compliance/cis_6_0_m365.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_6_0_m365.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/csa_ccm_4_0_alibabacloud.py b/dashboard/compliance/csa_ccm_4_0_alibabacloud.py new file mode 100644 index 0000000000..346576729d --- /dev/null +++ b/dashboard/compliance/csa_ccm_4_0_alibabacloud.py @@ -0,0 +1,31 @@ +import warnings + +from dashboard.common_methods import get_section_containers_kisa_ismsp + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_ID"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_ID"] = data["REQUIREMENTS_ID"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_kisa_ismsp( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/csa_ccm_4_0_aws.py b/dashboard/compliance/csa_ccm_4_0_aws.py new file mode 100644 index 0000000000..346576729d --- /dev/null +++ b/dashboard/compliance/csa_ccm_4_0_aws.py @@ -0,0 +1,31 @@ +import warnings + +from dashboard.common_methods import get_section_containers_kisa_ismsp + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_ID"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_ID"] = data["REQUIREMENTS_ID"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_kisa_ismsp( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/csa_ccm_4_0_azure.py b/dashboard/compliance/csa_ccm_4_0_azure.py new file mode 100644 index 0000000000..346576729d --- /dev/null +++ b/dashboard/compliance/csa_ccm_4_0_azure.py @@ -0,0 +1,31 @@ +import warnings + +from dashboard.common_methods import get_section_containers_kisa_ismsp + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_ID"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_ID"] = data["REQUIREMENTS_ID"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_kisa_ismsp( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/csa_ccm_4_0_gcp.py b/dashboard/compliance/csa_ccm_4_0_gcp.py new file mode 100644 index 0000000000..346576729d --- /dev/null +++ b/dashboard/compliance/csa_ccm_4_0_gcp.py @@ -0,0 +1,31 @@ +import warnings + +from dashboard.common_methods import get_section_containers_kisa_ismsp + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_ID"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_ID"] = data["REQUIREMENTS_ID"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_kisa_ismsp( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/csa_ccm_4_0_oraclecloud.py b/dashboard/compliance/csa_ccm_4_0_oraclecloud.py new file mode 100644 index 0000000000..346576729d --- /dev/null +++ b/dashboard/compliance/csa_ccm_4_0_oraclecloud.py @@ -0,0 +1,31 @@ +import warnings + +from dashboard.common_methods import get_section_containers_kisa_ismsp + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_ID"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_ID"] = data["REQUIREMENTS_ID"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_kisa_ismsp( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/fedramp_20x_ksi_low_aws.py b/dashboard/compliance/fedramp_20x_ksi_low_aws.py new file mode 100644 index 0000000000..5ca220301f --- /dev/null +++ b/dashboard/compliance/fedramp_20x_ksi_low_aws.py @@ -0,0 +1,46 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + # Shorten the long FedRAMP KSI descriptions for better display + ksi_short_names = { + "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", + "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", + "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", + "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", + "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", + "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", + "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", + "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", + "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", + "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", + } + + # Replace long descriptions with short names - use contains for partial matching + if not aux.empty: + for long_desc, short_name in ksi_short_names.items(): + mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( + long_desc, na=False, regex=False + ) + aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/fedramp_20x_ksi_low_azure.py b/dashboard/compliance/fedramp_20x_ksi_low_azure.py new file mode 100644 index 0000000000..5ca220301f --- /dev/null +++ b/dashboard/compliance/fedramp_20x_ksi_low_azure.py @@ -0,0 +1,46 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + # Shorten the long FedRAMP KSI descriptions for better display + ksi_short_names = { + "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", + "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", + "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", + "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", + "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", + "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", + "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", + "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", + "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", + "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", + } + + # Replace long descriptions with short names - use contains for partial matching + if not aux.empty: + for long_desc, short_name in ksi_short_names.items(): + mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( + long_desc, na=False, regex=False + ) + aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/fedramp_20x_ksi_low_gcp.py b/dashboard/compliance/fedramp_20x_ksi_low_gcp.py new file mode 100644 index 0000000000..5ca220301f --- /dev/null +++ b/dashboard/compliance/fedramp_20x_ksi_low_gcp.py @@ -0,0 +1,46 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + # Shorten the long FedRAMP KSI descriptions for better display + ksi_short_names = { + "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", + "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", + "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", + "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", + "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", + "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", + "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", + "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", + "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", + "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", + } + + # Replace long descriptions with short names - use contains for partial matching + if not aux.empty: + for long_desc, short_name in ksi_short_names.items(): + mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( + long_desc, na=False, regex=False + ) + aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/hipaa_azure.py b/dashboard/compliance/hipaa_azure.py new file mode 100644 index 0000000000..b0a8eb6582 --- /dev/null +++ b/dashboard/compliance/hipaa_azure.py @@ -0,0 +1,25 @@ +import warnings + +from dashboard.common_methods import get_section_containers_format3 + +warnings.filterwarnings("ignore") + + +def get_table(data): + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_DESCRIPTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_format3( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/hipaa_gcp.py b/dashboard/compliance/hipaa_gcp.py new file mode 100644 index 0000000000..b0a8eb6582 --- /dev/null +++ b/dashboard/compliance/hipaa_gcp.py @@ -0,0 +1,25 @@ +import warnings + +from dashboard.common_methods import get_section_containers_format3 + +warnings.filterwarnings("ignore") + + +def get_table(data): + + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_DESCRIPTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_format3( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/nist_csf_2_0_aws.py b/dashboard/compliance/nist_csf_2_0_aws.py new file mode 100644 index 0000000000..07b956b104 --- /dev/null +++ b/dashboard/compliance/nist_csf_2_0_aws.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_format3 + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_DESCRIPTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_format3( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/prowler_threatscore_alibabacloud.py b/dashboard/compliance/prowler_threatscore_alibabacloud.py new file mode 100644 index 0000000000..d86a13fd01 --- /dev/null +++ b/dashboard/compliance/prowler_threatscore_alibabacloud.py @@ -0,0 +1,28 @@ +import warnings + +from dashboard.common_methods import get_section_containers_threatscore + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_threatscore( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_ID", + ) diff --git a/dashboard/compliance/prowler_threatscore_kubernetes.py b/dashboard/compliance/prowler_threatscore_kubernetes.py new file mode 100644 index 0000000000..d86a13fd01 --- /dev/null +++ b/dashboard/compliance/prowler_threatscore_kubernetes.py @@ -0,0 +1,28 @@ +import warnings + +from dashboard.common_methods import get_section_containers_threatscore + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_threatscore( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_ID", + ) diff --git a/dashboard/lib/dropdowns.py b/dashboard/lib/dropdowns.py index 3e92759042..421801bc27 100644 --- a/dashboard/lib/dropdowns.py +++ b/dashboard/lib/dropdowns.py @@ -312,3 +312,28 @@ def create_table_row_dropdown(table_rows: list) -> html.Div: ), ], ) + + +def create_category_dropdown(categories: list) -> html.Div: + """ + Dropdown to select the category. + Args: + categories (list): List of categories. + Returns: + html.Div: Dropdown to select the category. + """ + return html.Div( + [ + html.Label( + "Category:", className="text-prowler-stone-900 font-bold text-sm" + ), + dcc.Dropdown( + id="category-filter", + options=[{"label": i, "value": i} for i in categories], + value=["All"], + clearable=False, + multi=True, + style={"color": "#000000"}, + ), + ], + ) diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index e054731684..930432b6c4 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -12,6 +12,7 @@ def create_layout_overview( provider_dropdown: html.Div, table_row_dropdown: html.Div, status_dropdown: html.Div, + category_dropdown: html.Div, table_div_header: html.Div, amount_providers: int, ) -> html.Div: @@ -51,8 +52,9 @@ def create_layout_overview( html.Div([service_dropdown], className=""), html.Div([provider_dropdown], className=""), html.Div([status_dropdown], className=""), + html.Div([category_dropdown], className=""), ], - className="grid gap-x-4 mb-[30px] sm:grid-cols-2 lg:grid-cols-4", + className="grid gap-x-4 mb-[30px] sm:grid-cols-2 lg:grid-cols-5", ), html.Div( [ @@ -61,6 +63,7 @@ def create_layout_overview( html.Div(className="flex", id="gcp_card", n_clicks=0), html.Div(className="flex", id="k8s_card", n_clicks=0), html.Div(className="flex", id="m365_card", n_clicks=0), + html.Div(className="flex", id="alibabacloud_card", n_clicks=0), ], className=f"grid gap-x-4 mb-[30px] sm:grid-cols-2 lg:grid-cols-{amount_providers}", ), diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index 33a2b0c268..20395539e5 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -78,6 +78,8 @@ def load_csv_files(csv_files): result = result.replace("_KUBERNETES", " - KUBERNETES") if "M65" in result: result = result.replace("_M65", " - M65") + if "ALIBABACLOUD" in result: + result = result.replace("_ALIBABACLOUD", " - ALIBABACLOUD") results.append(result) unique_results = set(results) @@ -125,7 +127,7 @@ if data is None: ) else: - data["ASSESSMENTDATE"] = pd.to_datetime(data["ASSESSMENTDATE"]) + data["ASSESSMENTDATE"] = pd.to_datetime(data["ASSESSMENTDATE"], format="mixed") data["ASSESSMENT_TIME"] = data["ASSESSMENTDATE"].dt.strftime("%Y-%m-%d %H:%M:%S") data_values = data["ASSESSMENT_TIME"].unique() @@ -278,9 +280,18 @@ def display_data( data["REQUIREMENTS_ATTRIBUTES_PROFILE"] = data[ "REQUIREMENTS_ATTRIBUTES_PROFILE" ].apply(lambda x: x.split(" - ")[0]) + + # Rename the column LOCATION to REGION for Alibaba Cloud + if "alibabacloud" in analytics_input: + data = data.rename(columns={"LOCATION": "REGION"}) + + # Rename the column TENANCYID to ACCOUNTID for Oracle Cloud + if "oraclecloud" in analytics_input: + data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True) + # Filter the chosen level of the CIS if is_level_1: - data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"] == "Level 1"] + data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")] # Rename the column PROJECTID to ACCOUNTID for GCP if data.columns.str.contains("PROJECTID").any(): @@ -401,9 +412,11 @@ def display_data( compliance_module = importlib.import_module( f"dashboard.compliance.{current}" ) - data = data.drop_duplicates( - subset=["CHECKID", "STATUS", "MUTED", "RESOURCEID", "STATUSEXTENDED"] - ) + # Build subset list based on available columns + dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] + if "MUTED" in data.columns: + dedup_columns.insert(2, "MUTED") + data = data.drop_duplicates(subset=dedup_columns) if "threatscore" in analytics_input: data = get_threatscore_mean_by_pillar(data) @@ -646,6 +659,7 @@ def get_table(current_compliance, table): def get_threatscore_mean_by_pillar(df): score_per_pillar = {} max_score_per_pillar = {} + counted_findings_per_pillar = {} for _, row in df.iterrows(): pillar = ( @@ -657,6 +671,18 @@ def get_threatscore_mean_by_pillar(df): if pillar not in score_per_pillar: score_per_pillar[pillar] = 0 max_score_per_pillar[pillar] = 0 + counted_findings_per_pillar[pillar] = set() + + # Skip muted findings for score calculation + is_muted = "MUTED" in df.columns and row.get("MUTED") == "True" + if is_muted: + continue + + # Create unique finding identifier to avoid counting duplicates + finding_id = f"{row.get('CHECKID', '')}_{row.get('RESOURCEID', '')}" + if finding_id in counted_findings_per_pillar[pillar]: + continue + counted_findings_per_pillar[pillar].add(finding_id) level_of_risk = pd.to_numeric( row["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"], errors="coerce" @@ -700,6 +726,10 @@ def get_table_prowler_threatscore(df): score_per_pillar = {} max_score_per_pillar = {} pillars = {} + counted_findings_per_pillar = {} + counted_pass = set() + counted_fail = set() + counted_muted = set() df_copy = df.copy() @@ -714,6 +744,24 @@ def get_table_prowler_threatscore(df): pillars[pillar] = {"FAIL": 0, "PASS": 0, "MUTED": 0} score_per_pillar[pillar] = 0 max_score_per_pillar[pillar] = 0 + counted_findings_per_pillar[pillar] = set() + + # Create unique finding identifier + finding_id = f"{row.get('CHECKID', '')}_{row.get('RESOURCEID', '')}" + + # Check if muted + is_muted = "MUTED" in df_copy.columns and row.get("MUTED") == "True" + + # Count muted findings (separate from score calculation) + if is_muted and finding_id not in counted_muted: + counted_muted.add(finding_id) + pillars[pillar]["MUTED"] += 1 + continue # Skip muted findings for score calculation + + # Skip if already counted for this pillar + if finding_id in counted_findings_per_pillar[pillar]: + continue + counted_findings_per_pillar[pillar].add(finding_id) level_of_risk = pd.to_numeric( row["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"], errors="coerce" @@ -732,13 +780,14 @@ def get_table_prowler_threatscore(df): max_score_per_pillar[pillar] += level_of_risk * weight if row["STATUS"] == "PASS": - pillars[pillar]["PASS"] += 1 + if finding_id not in counted_pass: + counted_pass.add(finding_id) + pillars[pillar]["PASS"] += 1 score_per_pillar[pillar] += level_of_risk * weight elif row["STATUS"] == "FAIL": - pillars[pillar]["FAIL"] += 1 - - if "MUTED" in row and row["MUTED"] == "True": - pillars[pillar]["MUTED"] += 1 + if finding_id not in counted_fail: + counted_fail.add(finding_id) + pillars[pillar]["FAIL"] += 1 result_df = [] diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 4ce749a9e4..665aa8e195 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -35,6 +35,7 @@ from dashboard.config import ( from dashboard.lib.cards import create_provider_card from dashboard.lib.dropdowns import ( create_account_dropdown, + create_category_dropdown, create_date_dropdown, create_provider_dropdown, create_region_dropdown, @@ -79,6 +80,9 @@ ks8_provider_logo = html.Img( m365_provider_logo = html.Img( src="assets/images/providers/m365_provider.png", alt="m365 provider" ) +alibabacloud_provider_logo = html.Img( + src="assets/images/providers/alibabacloud_provider.png", alt="alibabacloud provider" +) def load_csv_files(csv_files): @@ -253,6 +257,10 @@ else: accounts.append(account + " - AWS") if "kubernetes" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): accounts.append(account + " - K8S") + if "alibabacloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): + accounts.append(account + " - ALIBABACLOUD") + if "oraclecloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): + accounts.append(account + " - OCI") account_dropdown = create_account_dropdown(accounts) @@ -298,6 +306,10 @@ else: services.append(service + " - GCP") if "m365" in list(data[data["SERVICE_NAME"] == service]["PROVIDER"]): services.append(service + " - M365") + if "alibabacloud" in list(data[data["SERVICE_NAME"] == service]["PROVIDER"]): + services.append(service + " - ALIBABACLOUD") + if "oraclecloud" in list(data[data["SERVICE_NAME"] == service]["PROVIDER"]): + services.append(service + " - OCI") services = ["All"] + services services = [ @@ -336,6 +348,18 @@ else: status = [x for x in status if str(x) != "nan" and x.__class__.__name__ == "str"] status_dropdown = create_status_dropdown(status) + + # Create the category dropdown + categories = [] + if "CATEGORIES" in data.columns: + for cat_list in data["CATEGORIES"].dropna().unique(): + if cat_list and str(cat_list) != "nan": + for cat in str(cat_list).split(","): + cat = cat.strip() + if cat and cat not in categories: + categories.append(cat) + categories = ["All"] + sorted(categories) + category_dropdown = create_category_dropdown(categories) table_div_header = [] table_div_header.append( html.Div( @@ -497,6 +521,7 @@ else: provider_dropdown, table_row_dropdown, status_dropdown, + category_dropdown, table_div_header, len(data["PROVIDER"].unique()), ) @@ -520,6 +545,7 @@ else: Output("gcp_card", "children"), Output("k8s_card", "children"), Output("m365_card", "children"), + Output("alibabacloud_card", "children"), Output("subscribe_card", "children"), Output("info-file-over", "title"), Output("severity-filter", "value"), @@ -532,11 +558,14 @@ else: Output("table-rows", "options"), Output("status-filter", "value"), Output("status-filter", "options"), + Output("category-filter", "value"), + Output("category-filter", "options"), Output("aws_card", "n_clicks"), Output("azure_card", "n_clicks"), Output("gcp_card", "n_clicks"), Output("k8s_card", "n_clicks"), Output("m365_card", "n_clicks"), + Output("alibabacloud_card", "n_clicks"), ], Input("cloud-account-filter", "value"), Input("region-filter", "value"), @@ -548,6 +577,7 @@ else: Input("provider-filter", "value"), Input("table-rows", "value"), Input("status-filter", "value"), + Input("category-filter", "value"), Input("search-input", "value"), Input("aws_card", "n_clicks"), Input("azure_card", "n_clicks"), @@ -560,6 +590,7 @@ else: Input("sort_button_region", "n_clicks"), Input("sort_button_service", "n_clicks"), Input("sort_button_account", "n_clicks"), + Input("alibabacloud_card", "n_clicks"), ) def filter_data( cloud_account_values, @@ -572,6 +603,7 @@ def filter_data( provider_values, table_row_values, status_values, + category_values, search_value, aws_clicks, azure_clicks, @@ -584,6 +616,7 @@ def filter_data( sort_button_region, sort_button_service, sort_button_account, + alibabacloud_clicks, ): # Use n_clicks for vulture n_clicks_csv = n_clicks_csv @@ -599,6 +632,7 @@ def filter_data( gcp_clicks = 0 k8s_clicks = 0 m365_clicks = 0 + alibabacloud_clicks = 0 if azure_clicks > 0: filtered_data = data.copy() if azure_clicks % 2 != 0 and "azure" in list(data["PROVIDER"]): @@ -607,6 +641,7 @@ def filter_data( gcp_clicks = 0 k8s_clicks = 0 m365_clicks = 0 + alibabacloud_clicks = 0 if gcp_clicks > 0: filtered_data = data.copy() if gcp_clicks % 2 != 0 and "gcp" in list(data["PROVIDER"]): @@ -615,6 +650,7 @@ def filter_data( azure_clicks = 0 k8s_clicks = 0 m365_clicks = 0 + alibabacloud_clicks = 0 if k8s_clicks > 0: filtered_data = data.copy() if k8s_clicks % 2 != 0 and "kubernetes" in list(data["PROVIDER"]): @@ -623,6 +659,7 @@ def filter_data( azure_clicks = 0 gcp_clicks = 0 m365_clicks = 0 + alibabacloud_clicks = 0 if m365_clicks > 0: filtered_data = data.copy() if m365_clicks % 2 != 0 and "m365" in list(data["PROVIDER"]): @@ -631,7 +668,16 @@ def filter_data( azure_clicks = 0 gcp_clicks = 0 k8s_clicks = 0 - + alibabacloud_clicks = 0 + if alibabacloud_clicks > 0: + filtered_data = data.copy() + if alibabacloud_clicks % 2 != 0 and "alibabacloud" in list(data["PROVIDER"]): + filtered_data = filtered_data[filtered_data["PROVIDER"] == "alibabacloud"] + aws_clicks = 0 + azure_clicks = 0 + gcp_clicks = 0 + k8s_clicks = 0 + m365_clicks = 0 # For all the data, we will add to the status column the value 'MUTED (FAIL)' and 'MUTED (PASS)' depending on the value of the column 'STATUS' and 'MUTED' if "MUTED" in filtered_data.columns: filtered_data["STATUS"] = filtered_data.apply( @@ -723,6 +769,10 @@ def filter_data( all_account_ids.append(account) if "kubernetes" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): all_account_ids.append(account) + if "alibabacloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): + all_account_ids.append(account) + if "oraclecloud" in list(data[data["ACCOUNT_UID"] == account]["PROVIDER"]): + all_account_ids.append(account) all_account_names = [] if "ACCOUNT_NAME" in filtered_data.columns: @@ -745,6 +795,12 @@ def filter_data( cloud_accounts_options.append(item + " - AWS") if "kubernetes" in list(data[data["ACCOUNT_UID"] == item]["PROVIDER"]): cloud_accounts_options.append(item + " - K8S") + if "alibabacloud" in list( + data[data["ACCOUNT_UID"] == item]["PROVIDER"] + ): + cloud_accounts_options.append(item + " - ALIBABACLOUD") + if "oraclecloud" in list(data[data["ACCOUNT_UID"] == item]["PROVIDER"]): + cloud_accounts_options.append(item + " - OCI") if "ACCOUNT_NAME" in filtered_data.columns: if "azure" in list(data[data["ACCOUNT_NAME"] == item]["PROVIDER"]): cloud_accounts_options.append(item + " - AZURE") @@ -873,6 +929,14 @@ def filter_data( filtered_data[filtered_data["SERVICE_NAME"] == item]["PROVIDER"] ): service_filter_options.append(item + " - M365") + if "alibabacloud" in list( + filtered_data[filtered_data["SERVICE_NAME"] == item]["PROVIDER"] + ): + service_filter_options.append(item + " - ALIBABACLOUD") + if "oraclecloud" in list( + filtered_data[filtered_data["SERVICE_NAME"] == item]["PROVIDER"] + ): + service_filter_options.append(item + " - OCI") # Filter Service if service_values == ["All"]: @@ -931,6 +995,41 @@ def filter_data( status_filter_options = ["All"] + list(filtered_data["STATUS"].unique()) + # Filter Category + if "CATEGORIES" in filtered_data.columns: + if category_values == ["All"]: + updated_category_values = None + elif "All" in category_values and len(category_values) > 1: + category_values.remove("All") + updated_category_values = category_values + elif len(category_values) == 0: + updated_category_values = None + category_values = ["All"] + else: + updated_category_values = category_values + + if updated_category_values: + filtered_data = filtered_data[ + filtered_data["CATEGORIES"].apply( + lambda x: any( + cat.strip() in updated_category_values + for cat in str(x).split(",") + if str(x) != "nan" + ) + ) + ] + + category_filter_options = ["All"] + for cat_list in filtered_data["CATEGORIES"].dropna().unique(): + if cat_list and str(cat_list) != "nan": + for cat in str(cat_list).split(","): + cat = cat.strip() + if cat and cat not in category_filter_options: + category_filter_options.append(cat) + category_filter_options = sorted(category_filter_options) + else: + category_filter_options = ["All"] + if len(filtered_data_sp) == 0: fig = px.pie() fig.update_layout( @@ -1032,7 +1131,12 @@ def filter_data( figure=fig, config={"displayModeBar": False}, ) + pie_3 = dcc.Graph( + figure=fig, + config={"displayModeBar": False}, + ) table = dcc.Graph(figure=fig, config={"displayModeBar": False}) + table_row_options = [] else: # Status Pie Chart @@ -1088,22 +1192,25 @@ def filter_data( style={"height": "300px", "overflow-y": "auto"}, ) - color_bars = [ - color_mapping_severity[severity] - for severity in df1["SEVERITY"].value_counts().index - ] - - figure_bars = go.Figure( - data=[ + # Prepare bar chart data only if df1 has FAIL findings + if len(df1) > 0: + color_bars = [ + color_mapping_severity[severity] + for severity in df1["SEVERITY"].value_counts().index + ] + bar_data = [ go.Bar( - x=df1["SEVERITY"] - .value_counts() - .index, # assign x as the dataframe column 'x' + x=df1["SEVERITY"].value_counts().index, y=df1["SEVERITY"].value_counts().values, marker=dict(color=color_bars), textposition="auto", ) - ], + ] + else: + bar_data = [] + + figure_bars = go.Figure( + data=bar_data, layout=go.Layout( paper_bgcolor="#FFF", font=dict(size=12, color="#292524"), @@ -1324,6 +1431,12 @@ def filter_data( filtered_data.loc[ filtered_data["ACCOUNT_UID"] == account, "ACCOUNT_UID" ] = (account + " - M365") + if "alibabacloud" in list( + data[data["ACCOUNT_UID"] == account]["PROVIDER"] + ): + filtered_data.loc[ + filtered_data["ACCOUNT_UID"] == account, "ACCOUNT_UID" + ] = (account + " - ALIBABACLOUD") table_collapsible = [] for item in filtered_data.to_dict("records"): @@ -1410,6 +1523,13 @@ def filter_data( else: m365_card = None + if "alibabacloud" in list(data["PROVIDER"].unique()): + alibabacloud_card = create_provider_card( + "alibabacloud", alibabacloud_provider_logo, "Accounts", full_filtered_data + ) + else: + alibabacloud_card = None + # Subscribe to Prowler Cloud card subscribe_card = [ html.Div( @@ -1454,21 +1574,27 @@ def filter_data( gcp_card, k8s_card, m365_card, + alibabacloud_card, subscribe_card, list_files, severity_values, severity_filter_options, service_values, + provider_values, + provider_filter_options, service_filter_options, table_row_values, table_row_options, status_values, status_filter_options, + category_values, + category_filter_options, aws_clicks, azure_clicks, gcp_clicks, k8s_clicks, m365_clicks, + alibabacloud_clicks, ) else: return ( @@ -1487,6 +1613,7 @@ def filter_data( gcp_card, k8s_card, m365_card, + alibabacloud_card, subscribe_card, list_files, severity_values, @@ -1499,11 +1626,14 @@ def filter_data( table_row_options, status_values, status_filter_options, + category_values, + category_filter_options, aws_clicks, azure_clicks, gcp_clicks, k8s_clicks, m365_clicks, + alibabacloud_clicks, ) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 05ed89c397..b5de1af83f 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,6 +1,7 @@ services: api-dev: hostname: "prowler-api" + image: prowler-api-dev build: context: ./api dockerfile: Dockerfile @@ -24,6 +25,8 @@ services: condition: service_healthy valkey: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "/home/prowler/docker-entrypoint.sh" - "dev" @@ -41,6 +44,9 @@ services: volumes: - "./ui:/app" - "/app/node_modules" + depends_on: + mcp-server: + condition: service_healthy postgres: image: postgres:16.3-alpine3.20 @@ -57,7 +63,11 @@ services: ports: - "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}" healthcheck: - test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER} -d ${POSTGRES_DB}'"] + test: + [ + "CMD-SHELL", + "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER} -d ${POSTGRES_DB}'", + ] interval: 5s timeout: 5s retries: 5 @@ -78,7 +88,41 @@ services: timeout: 5s retries: 3 + neo4j: + image: graphstack/dozerdb:5.26.3.0 + hostname: "neo4j" + volumes: + - ./_data/neo4j:/data + environment: + # We can't add our .env file because some of our current variables are not compatible with Neo4j env vars + # Auth + - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD} + # Memory limits + - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000} + - NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G} + - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G} + - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G} + # APOC + - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true} + - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true} + - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} + - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}" + - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}" + - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}" + # Networking + - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}" + # 7474 is the UI port + ports: + - 7474:7474 + - ${NEO4J_PORT:-7687}:7687 + healthcheck: + test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"] + interval: 10s + timeout: 10s + retries: 10 + worker-dev: + image: prowler-api-dev build: context: ./api dockerfile: Dockerfile @@ -89,17 +133,23 @@ services: - path: .env required: false volumes: - - "outputs:/tmp/prowler_api_output" + - ./api/src/backend:/home/prowler/backend + - ./api/pyproject.toml:/home/prowler/pyproject.toml + - ./api/docker-entrypoint.sh:/home/prowler/docker-entrypoint.sh + - outputs:/tmp/prowler_api_output depends_on: valkey: condition: service_healthy postgres: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "/home/prowler/docker-entrypoint.sh" - "worker" worker-beat: + image: prowler-api-dev build: context: ./api dockerfile: Dockerfile @@ -114,10 +164,38 @@ services: condition: service_healthy postgres: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "../docker-entrypoint.sh" - "beat" + mcp-server: + build: + context: ./mcp_server + dockerfile: Dockerfile + environment: + - PROWLER_MCP_TRANSPORT_MODE=http + env_file: + - path: .env + required: false + ports: + - "8000:8000" + volumes: + - ./mcp_server/prowler_mcp_server:/app/prowler_mcp_server + - ./mcp_server/pyproject.toml:/app/pyproject.toml + - ./mcp_server/entrypoint.sh:/app/entrypoint.sh + command: ["uvicorn", "--host", "0.0.0.0", "--port", "8000"] + healthcheck: + test: + [ + "CMD-SHELL", + "wget -q -O /dev/null http://127.0.0.1:8000/health || exit 1", + ] + interval: 10s + timeout: 5s + retries: 3 + volumes: outputs: driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 2e2469e0c8..992a753ac0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,9 @@ +# Production Docker Compose configuration +# Uses pre-built images from Docker Hub (prowlercloud/*) +# +# For development with local builds and hot-reload, use docker-compose-dev.yml instead: +# docker compose -f docker-compose-dev.yml up +# services: api: hostname: "prowler-api" @@ -15,6 +21,8 @@ services: condition: service_healthy valkey: condition: service_healthy + neo4j: + condition: service_healthy entrypoint: - "/home/prowler/docker-entrypoint.sh" - "prod" @@ -26,6 +34,9 @@ services: required: false ports: - ${UI_PORT:-3000}:${UI_PORT:-3000} + depends_on: + mcp-server: + condition: service_healthy postgres: image: postgres:16.3-alpine3.20 @@ -63,6 +74,37 @@ services: timeout: 5s retries: 3 + neo4j: + image: graphstack/dozerdb:5.26.3.0 + hostname: "neo4j" + volumes: + - ./_data/neo4j:/data + environment: + # We can't add our .env file because some of our current variables are not compatible with Neo4j env vars + # Auth + - NEO4J_AUTH=${NEO4J_USER}/${NEO4J_PASSWORD} + # Memory limits + - NEO4J_dbms_max__databases=${NEO4J_DBMS_MAX__DATABASES:-1000} + - NEO4J_server_memory_pagecache_size=${NEO4J_SERVER_MEMORY_PAGECACHE_SIZE:-1G} + - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G} + - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G} + # APOC + - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true} + - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true} + - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} + - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}" + - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}" + - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}" + # Networking + - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}" + ports: + - ${NEO4J_PORT:-7687}:7687 + healthcheck: + test: ["CMD", "wget", "--no-verbose", "http://localhost:7474"] + interval: 10s + timeout: 10s + retries: 10 + worker: image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} env_file: @@ -93,6 +135,22 @@ services: - "../docker-entrypoint.sh" - "beat" + mcp-server: + image: prowlercloud/prowler-mcp:${PROWLER_MCP_VERSION:-stable} + environment: + - PROWLER_MCP_TRANSPORT_MODE=http + env_file: + - path: .env + required: false + ports: + - "8000:8000" + command: ["uvicorn", "--host", "0.0.0.0", "--port", "8000"] + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + volumes: output: driver: local diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 47e24d419d..2f9efd405d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -479,6 +479,66 @@ Effective headers and section titles enhance document readability and structure, --- +## Version Badge for Feature Documentation + +The Version Badge component indicates when a specific feature or functionality was introduced in Prowler. This component is located at `docs/snippets/version-badge.mdx` and should be used consistently across the documentation. + +### When to Use the Version Badge + +Use the Version Badge when documenting: + +* New features added in a specific version. +* New CLI options or flags. +* New API endpoints or SDK methods. +* New compliance frameworks or security checks. +* Breaking changes or deprecated features (with appropriate context). + +### How to Use the Version Badge + +1. **Import the Component** + + At the top of the MDX file, import the snippet: + + ```mdx + import { VersionBadge } from "/snippets/version-badge.mdx" + ``` + +2. **Place the Badge** + + Insert the badge immediately after the section header or feature title: + + ```mdx + ## New Feature Name + + + + Description of the feature... + ``` + +3. **Version Format** + + Use semantic versioning format (e.g., `4.5.0`, `5.0.0`). Do not include the "v" prefix. + +### Placement Guidelines + +* Place the Version Badge on its own line, directly below the header. +* Leave a blank line after the badge before continuing with the content. +* For subsections, place the badge only if the subsection introduces something new independently from the parent section. + +**Example:** + +```mdx +## Tag-Based Scanning + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Tag-Based Scanning allows filtering resources by AWS tags during security assessments... +``` + +--- + ## Avoid Assumptions Regarding Audience’s Expertise ### Understand Your Audience’s Expertise 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/developer-guide/ai-skills.mdx b/docs/developer-guide/ai-skills.mdx new file mode 100644 index 0000000000..6a0787dac8 --- /dev/null +++ b/docs/developer-guide/ai-skills.mdx @@ -0,0 +1,219 @@ +--- +title: 'AI Skills System' +--- + +This guide explains the AI Skills system that provides on-demand context and patterns to AI agents working with the Prowler codebase. + + +**What are AI Skills?** Skills are structured instructions that help AI agents (Claude Code, Cursor, Copilot, etc.) understand Prowler's conventions, patterns, and best practices. + + +## Architecture Overview + +```mermaid +graph LR + subgraph FLOW["AI Skills Architecture"] + A["AI Agent"] -->|"1. matches trigger"| B["AGENTS.md"] + B -->|"2. loads"| C["Skill"] + C -->|"3. provides"| D["Patterns
Templates
Commands"] + C -->|"4. references"| E["Local Docs"] + D --> F["Correct Output"] + E --> F + end + + style A fill:#1e3a5f,stroke:#4a9eff,color:#fff + style B fill:#5c4d1a,stroke:#ffd700,color:#fff + style C fill:#1a4d1a,stroke:#4caf50,color:#fff + style E fill:#4a1a4d,stroke:#ba68c8,color:#fff + style F fill:#1a4d2e,stroke:#66bb6a,color:#fff +``` + +## How It Works + +```mermaid +sequenceDiagram + participant U as User + participant A as AI Agent + participant R as AGENTS.md + participant S as Skill + participant AS as assets/ + participant RF as references/ + participant D as Local Docs + + U->>A: "Create an AWS security check" + + Note over A: Analyze request context + + A->>R: Find matching skill trigger + R-->>A: prowler-sdk-check matches + + A->>S: Load SKILL.md + S-->>A: Patterns, rules, templates, commands + + Note over A: Need code template? + + A->>AS: Read assets/aws_check.py + AS-->>A: Check implementation template + + Note over A: Need more details? + + A->>RF: Read references/metadata-docs.md + RF-->>A: Points to local docs + + A->>D: Read docs/developer-guide/checks.mdx + D-->>A: Full documentation + + Note over A: Execute with full context + + A->>U: Creates check with correct patterns +``` + +## Before vs After + +```mermaid +graph TD + subgraph COMPARISON["BEFORE vs AFTER"] + direction LR + + subgraph BEFORE["Without Skills"] + B1["AI guesses conventions"] + B2["Wrong structure"] + B3["Multiple iterations"] + B4["Web searches for docs"] + B5["Inconsistent patterns"] + end + + subgraph AFTER["With Skills"] + A1["AI loads exact patterns"] + A2["Correct structure"] + A3["First-time right"] + A4["Local docs referenced"] + A5["Consistent patterns"] + end + end + + style BEFORE fill:#5c1a1a,stroke:#ef5350,color:#fff + style AFTER fill:#1a4d1a,stroke:#66bb6a,color:#fff +``` + +## Complete Architecture + +```mermaid +flowchart TB + subgraph ENTRY["ENTRY POINT"] + AGENTS["AGENTS.md
━━━━━━━━━━━━━━━━━
• Available skills registry
• Skill → Trigger mapping
• Component navigation"] + end + + subgraph SKILLS["SKILLS LIBRARY"] + direction TB + + subgraph GENERIC["Generic Skills"] + G1["typescript"] + G2["react-19"] + G3["nextjs-15"] + G4["tailwind-4"] + G5["pytest"] + G6["playwright"] + G7["django-drf"] + G8["zod-4"] + G9["zustand-5"] + G10["ai-sdk-5"] + end + + subgraph PROWLER["Prowler Skills"] + P1["prowler"] + P2["prowler-sdk-check"] + P3["prowler-api"] + P4["prowler-ui"] + P5["prowler-mcp"] + P6["prowler-provider"] + P7["prowler-compliance"] + P8["prowler-compliance-review"] + P9["prowler-docs"] + P10["prowler-pr"] + P11["prowler-ci"] + end + + subgraph TESTING["Testing Skills"] + T1["prowler-test-sdk"] + T2["prowler-test-api"] + T3["prowler-test-ui"] + end + + subgraph META["Meta Skills"] + M1["skill-creator"] + M2["skill-sync"] + end + end + + subgraph STRUCTURE["SKILL STRUCTURE"] + direction LR + + SKILLMD["SKILL.md
━━━━━━━━━━━━━━
• Frontmatter
• Critical patterns
• Decision trees
• Code examples
• Commands
• Keywords"] + + ASSETS["assets/
━━━━━━━━━━━━━━
• Code templates
• JSON schemas
• Config examples"] + + REFS["references/
━━━━━━━━━━━━━━
• Local doc paths
• No web URLs
• Single source"] + end + + subgraph DOCS["DOCUMENTATION"] + direction TB + DD["docs/developer-guide/"] + D1["checks.mdx"] + D2["unit-testing.mdx"] + D3["provider.mdx"] + D4["mcp-server.mdx"] + D5["..."] + + DD --> D1 + DD --> D2 + DD --> D3 + DD --> D4 + DD --> D5 + end + + ENTRY --> SKILLS + SKILLS --> STRUCTURE + SKILLMD --> ASSETS + SKILLMD --> REFS + REFS -.->|"points to"| DOCS + + style ENTRY fill:#1e3a5f,stroke:#4a9eff,color:#fff + style GENERIC fill:#5c4d1a,stroke:#ffd700,color:#fff + style PROWLER fill:#1a4d1a,stroke:#66bb6a,color:#fff + style TESTING fill:#4d1a3d,stroke:#f06292,color:#fff + style META fill:#4a1a4d,stroke:#ba68c8,color:#fff + style STRUCTURE fill:#5c3d1a,stroke:#ffb74d,color:#fff + style DOCS fill:#1a3d4d,stroke:#4dd0e1,color:#fff +``` + +## Skills Included + +| Type | Skills | +|------|--------| +| **Generic** | typescript, react-19, nextjs-15, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5 | +| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci | +| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui | +| **Meta** | skill-creator, skill-sync | + +## Skill Structure + +Each skill follows the [Agent Skills spec](https://agentskills.io): + +``` +skills/{skill-name}/ +├── SKILL.md # Patterns, rules, decision trees +├── assets/ # Code templates, schemas +└── references/ # Links to local docs (single source of truth) +``` + +## Key Design Decisions + +1. **Self-contained skills** - Critical patterns inline for fast loading +2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx` +3. **Single source of truth** - Skills reference docs, no duplication +4. **On-demand loading** - AI loads only what's needed for the task + +## Creating New Skills + +Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See `AGENTS.md` for the full list of available skills and their triggers. diff --git a/docs/developer-guide/alibabacloud-details.mdx b/docs/developer-guide/alibabacloud-details.mdx new file mode 100644 index 0000000000..4c21e17b29 --- /dev/null +++ b/docs/developer-guide/alibabacloud-details.mdx @@ -0,0 +1,212 @@ +--- +title: 'Alibaba Cloud Provider' +--- + +This page details the [Alibaba Cloud](https://www.alibabacloud.com/) provider implementation in Prowler. + +By default, Prowler will audit all the Alibaba Cloud regions that are available. To configure it, follow the [Alibaba Cloud getting started guide](/user-guide/providers/alibabacloud/getting-started-alibabacloud). + +## Alibaba Cloud Provider Classes Architecture + +The Alibaba Cloud provider implementation follows the general [Provider structure](/developer-guide/provider). This section focuses on the Alibaba Cloud-specific implementation, highlighting how the generic provider concepts are realized for Alibaba Cloud in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](/developer-guide/provider). + +### Main Class + +- **Location:** [`prowler/providers/alibabacloud/alibabacloud_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/alibabacloud/alibabacloud_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for Alibaba Cloud-specific logic, session management, credential validation, and configuration. +- **Key Alibaba Cloud Responsibilities:** + - Initializes and manages Alibaba Cloud sessions (supports Access Keys, STS Temporary Credentials, RAM Role Assumption, ECS RAM Role, OIDC Authentication, and Credentials URI). + - Validates credentials using STS GetCallerIdentity. + - Loads and manages configuration, mutelist, and fixer settings. + - Discovers and manages Alibaba Cloud regions. + - Provides properties and methods for downstream Alibaba Cloud service classes to access session, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/alibabacloud/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/alibabacloud/models.py) +- **Purpose:** Define structured data for Alibaba Cloud identity, session, credentials, and region info. +- **Key Alibaba Cloud Models:** + - `AlibabaCloudCallerIdentity`: Stores caller identity information from STS GetCallerIdentity (account_id, principal_id, arn, identity_type). + - `AlibabaCloudIdentityInfo`: Holds Alibaba Cloud identity metadata including account ID, user info, profile, and audited regions. + - `AlibabaCloudCredentials`: Stores credentials (access_key_id, access_key_secret, security_token). + - `AlibabaCloudRegion`: Represents an Alibaba Cloud region with region_id and region_name. + - `AlibabaCloudSession`: Manages the session and provides methods to create service clients. + +### `AlibabaCloudService` (Service Base Class) + +- **Location:** [`prowler/providers/alibabacloud/lib/service/service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/alibabacloud/lib/service/service.py) +- **Purpose:** Abstract base class that all Alibaba Cloud service-specific classes inherit from. This implements the generic service pattern (described in [service page](/developer-guide/services#service-base-class)) specifically for Alibaba Cloud. +- **Key Alibaba Cloud Responsibilities:** + - Receives an `AlibabacloudProvider` instance to access session, identity, and configuration. + - Manages regional clients for services that are region-specific. + - Provides `__threading_call__` method to make API calls in parallel by region or resource. + - Exposes common audit context (`audited_account`, `audited_account_name`, `audit_resources`, `audit_config`) to subclasses. + +### Exception Handling + +- **Location:** [`prowler/providers/alibabacloud/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/alibabacloud/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for Alibaba Cloud-specific error handling. +- **Key Alibaba Cloud Exceptions:** + - `AlibabaCloudClientError`: General client errors + - `AlibabaCloudNoCredentialsError`: No credentials found + - `AlibabaCloudInvalidCredentialsError`: Invalid credentials provided + - `AlibabaCloudSetUpSessionError`: Session setup failures + - `AlibabaCloudAssumeRoleError`: RAM role assumption failures + - `AlibabaCloudInvalidRegionError`: Invalid region specified + - `AlibabaCloudHTTPError`: HTTP/API errors + +### Session and Utility Helpers + +- **Location:** [`prowler/providers/alibabacloud/lib/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/alibabacloud/lib/) +- **Purpose:** Helpers for argument parsing, mutelist management, and other cross-cutting concerns. + +## Specific Patterns in Alibaba Cloud Services + +The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/alibabacloud/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/alibabacloud/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all Alibaba Cloud services. + +### Alibaba Cloud Service Common Patterns + +- Services communicate with Alibaba Cloud using the official Alibaba Cloud Python SDKs. Documentation for individual services can be found in the [Alibaba Cloud SDK documentation](https://www.alibabacloud.com/help/en/sdk). +- Every Alibaba Cloud service class inherits from `AlibabaCloudService`, ensuring access to session, identity, configuration, and client utilities. +- The constructor (`__init__`) always calls `super().__init__` with the service name, provider, and optionally `global_service=True` for services that are not regional (e.g., RAM). +- Resource containers **must** be initialized in the constructor. For regional services, resources are typically stored in dictionaries keyed by region and resource ID. +- All Alibaba Cloud resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- Alibaba Cloud SDK functions are wrapped in try/except blocks, with specific handling for errors, always logging errors. +- Regional services use `self.regional_clients` to maintain clients for each audited region. +- The `__threading_call__` method is used for parallel execution across regions or resources. + +### Example Service Implementation + +```python +from prowler.lib.logger import logger +from prowler.providers.alibabacloud.lib.service.service import AlibabaCloudService + + +class MyService(AlibabaCloudService): + def __init__(self, provider): + # Initialize parent class with service name + super().__init__("myservice", provider) + + # Initialize resource containers + self.resources = {} + + # Discover resources using threading + self.__threading_call__(self._describe_resources) + + def _describe_resources(self, regional_client): + try: + region = regional_client.region + response = regional_client.describe_resources() + + for resource in response.body.resources: + self.resources[resource.id] = MyResource( + id=resource.id, + name=resource.name, + region=region, + # ... other attributes + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) +``` + +## Specific Patterns in Alibaba Cloud Checks + +The Alibaba Cloud checks pattern is described in [checks page](/developer-guide/checks). You can find all the currently implemented checks: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/alibabacloud/services/ram/ram_no_root_access_key/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/alibabacloud/services/ram/ram_no_root_access_key)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is following the [check implementation documentation](/developer-guide/checks#creating-a-check) and taking other similar checks as reference. + +### Check Report Class + +The `CheckReportAlibabaCloud` class models a single finding for an Alibaba Cloud resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`CheckReportAlibabaCloud` extends the base report structure with Alibaba Cloud-specific fields, enabling detailed tracking of the resource, resource ID, ARN, and region associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `CheckReportAlibabaCloud`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its Alibaba Cloud-specific attributes from the resource, using the following logic: + +- **`resource_id`**: + - Uses `resource.id` if present. + - Otherwise, uses `resource.name` if present. + - Defaults to an empty string if not available. + +- **`resource_arn`**: + - Uses `resource.arn` if present. + - Defaults to an empty string if not available. + +- **`region`**: + - Uses `resource.region` if present. + - Defaults to an empty string if not available. + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +from prowler.lib.check.models import Check, CheckReportAlibabaCloud +from prowler.providers.alibabacloud.services.myservice.myservice_client import myservice_client + + +class myservice_example_check(Check): + def execute(self) -> list[CheckReportAlibabaCloud]: + findings = [] + + for resource in myservice_client.resources.values(): + report = CheckReportAlibabaCloud( + metadata=self.metadata(), + resource=resource + ) + report.region = resource.region + report.resource_id = resource.id + report.resource_arn = f"acs:myservice::{myservice_client.audited_account}:resource/{resource.id}" + + if resource.is_compliant: + report.status = "PASS" + report.status_extended = f"Resource {resource.name} is compliant." + else: + report.status = "FAIL" + report.status_extended = f"Resource {resource.name} is not compliant." + + findings.append(report) + + return findings +``` + +## Authentication Methods + +The Alibaba Cloud provider supports multiple authentication methods, prioritized in the following order: + +1. **Credentials URI** - Retrieve credentials from an external URI endpoint +2. **OIDC Role Authentication** - For applications running in ACK with RRSA enabled +3. **ECS RAM Role** - For ECS instances with attached RAM roles +4. **RAM Role Assumption** - Cross-account access with role assumption +5. **STS Temporary Credentials** - Pre-obtained temporary credentials +6. **Permanent Access Keys** - Static access key credentials +7. **Default Credential Chain** - Automatic credential discovery + +For detailed authentication configuration, see the [Authentication documentation](/user-guide/providers/alibabacloud/authentication). + +## Regions + +Alibaba Cloud has multiple regions across the globe. By default, Prowler audits all available regions. You can specify specific regions using the `--regions` CLI argument: + +```bash +prowler alibabacloud --regions cn-hangzhou cn-shanghai +``` + +The list of supported regions is maintained in [`prowler/providers/alibabacloud/config.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/alibabacloud/config.py). diff --git a/docs/developer-guide/check-metadata-guidelines.mdx b/docs/developer-guide/check-metadata-guidelines.mdx index 2b6524bcc6..7bc335c3de 100644 --- a/docs/developer-guide/check-metadata-guidelines.mdx +++ b/docs/developer-guide/check-metadata-guidelines.mdx @@ -213,3 +213,5 @@ Also is important to keep all code examples as short as possible, including the | software-supply-chain | Detects or prevents tampering, unauthorized packages, or third-party risks in software supply chain | | e3 | M365-specific controls enabled by or dependent on an E3 license (e.g., baseline security policies, conditional access) | | e5 | M365-specific controls enabled by or dependent on an E5 license (e.g., advanced threat protection, audit, DLP, and eDiscovery) | +| privilege-escalation | Detects IAM policies or permissions that allow identities to elevate their privileges beyond their intended scope, potentially gaining administrator or higher-level access through specific action combinations | +| ec2-imdsv1 | Identifies EC2 instances using Instance Metadata Service version 1 (IMDSv1), which is vulnerable to SSRF attacks and should be replaced with IMDSv2 for enhanced security | \ No newline at end of file diff --git a/docs/developer-guide/checks.mdx b/docs/developer-guide/checks.mdx index deeb7b50a5..1b1f1cc4c9 100644 --- a/docs/developer-guide/checks.mdx +++ b/docs/developer-guide/checks.mdx @@ -125,11 +125,11 @@ Each check **must** populate the `report.status` and `report.status_extended` fi The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below. -- `critical` – Issue that must be addressed immediately. -- `high` – Issue that should be addressed as soon as possible. -- `medium` – Issue that should be addressed within a reasonable timeframe. -- `low` – Issue that can be addressed in the future. -- `informational` – Not an issue but provides valuable information. +- `critical` – Highest potential impact with broad exposure that could affect core security boundaries or business operations. +- `high` – Substantial potential impact with significant exposure that could affect important security controls or resources. +- `medium` – Moderate potential impact with limited exposure that weakens defense layers but has contained scope. +- `low` – Minimal potential impact with negligible exposure that represents minor gaps in security posture. +- `informational` – Provides valuable information but does not affect the security posture. If the check involves multiple scenarios that may alter its severity, adjustments can be made dynamically within the check's logic using the severity `report.check_metadata.Severity` attribute: @@ -237,6 +237,7 @@ Below is a generic example of a check metadata file. **Do not include comments i "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "Other", + "ResourceGroup": "security", "Description": "This check verifies that the service resource has the required **security setting** enabled to protect against potential vulnerabilities.\n\nIt ensures that the resource follows security best practices and maintains proper access controls. The check evaluates whether the security configuration is properly implemented and active.", "Risk": "Without proper security settings, the resource may be vulnerable to:\n\n- **Unauthorized access** - Malicious actors could gain entry\n- **Data breaches** - Sensitive information could be compromised\n- **Security threats** - Various attack vectors could be exploited\n\nThis could result in compliance violations and potential financial or reputational damage.", "RelatedUrl": "", @@ -312,7 +313,33 @@ The type of resource being audited. This field helps categorize and organize fin - **Azure**: Use types from [Azure Resource Graph](https://learn.microsoft.com/en-us/azure/governance/resource-graph/reference/supported-tables-resources), for example: `Microsoft.Storage/storageAccounts`. - **Google Cloud**: Use [Cloud Asset Inventory asset types](https://cloud.google.com/asset-inventory/docs/asset-types), for example: `compute.googleapis.com/Instance`. - **Kubernetes**: Use types shown under `KIND` from `kubectl api-resources`. -- **M365 / GitHub**: Leave empty due to lack of standardized types. +- **Oracle Cloud Infrastructure**: Use types from [Oracle Cloud Infrastructure documentation](https://docs.public.oneportal.content.oci.oraclecloud.com/en-us/iaas/Content/Search/Tasks/queryingresources_topic-Listing_Supported_Resource_Types.htm). +- **M365 / GitHub / MongoDB Atlas**: Leave empty due to lack of standardized types. + +#### ResourceGroup + +A high-level classification that groups checks by the type of cloud resource they audit. This field enables filtering and organizing findings by resource category across all providers. The value must be one of the following predefined groups: + +| Group | Description | +|-------|-------------| +| `compute` | Virtual machines, instances, auto-scaling groups, workspaces, streaming | +| `container` | Container orchestration, Kubernetes, registries, pods | +| `serverless` | Functions, step functions, event-driven compute | +| `database` | Relational, NoSQL, caches, search engines, data warehouses, graph databases | +| `storage` | Object storage, block storage, file systems, backups, archives | +| `network` | VPCs, subnets, load balancers, DNS, VPN, firewalls, CDN | +| `IAM` | IAM users, roles, policies, access keys, service accounts, directories | +| `messaging` | Queues, topics, event buses, streaming, email services | +| `security` | WAF, secrets, KMS, certificates, security tools, defenders, DDoS protection | +| `monitoring` | Logs, metrics, alerts, audit trails, observability, config tracking | +| `api_gateway` | API management, REST APIs, GraphQL endpoints | +| `ai_ml` | Machine learning, AI services, notebooks, training, LLM | +| `governance` | Accounts, organizations, projects, policies, settings, compliance tools | +| `collaboration` | Productivity SaaS apps (Exchange, Teams, SharePoint) | +| `devops` | CI/CD, infrastructure as code, automation, code repositories, version control | +| `analytics` | Data warehouses, query engines, ETL pipelines, BI tools, data lakes | + +The group is determined by the resource type being audited, not the service. For example, an EC2 security group check would use `network` (not `compute`), while an EC2 instance check would use `compute`. #### Description diff --git a/docs/developer-guide/documentation.mdx b/docs/developer-guide/documentation.mdx index a7a4e97d4e..f1fae30d35 100644 --- a/docs/developer-guide/documentation.mdx +++ b/docs/developer-guide/documentation.mdx @@ -4,7 +4,26 @@ title: 'Contributing to Documentation' Prowler documentation is built using [Mintlify](https://www.mintlify.com/docs), allowing contributors to easily add or enhance documentation. -## Installation and Setup +## Documentation Structure + +The Prowler documentation is organized into several sections. The main ones are: + +- **Getting Started**: Provides an overview of the Prowler platform and its different solutions, including Prowler Cloud/App, Prowler CLI, Prowler MCP Server, Prowler Hub, and Prowler Lighthouse AI. This section helps new users understand which Prowler solution best fits their needs and includes product comparisons. + +- **Guides**: Contains practical tutorials and how-to guides organized by product (Prowler Cloud/App, CLI) and provider (AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, etc.). This section covers authentication, integrations, compliance, and advanced usage scenarios. + +- **Developer Guide**: Documentation for contributors looking to extend Prowler functionality. This includes guides on creating providers, services, checks, output formats, integrations, and compliance frameworks. Provider-specific implementation details and testing strategies are also covered here. + +- **Troubleshooting**: Common issues, error messages, and their solutions. This section helps users resolve problems encountered during installation, configuration, or execution. + + +## AI-Driven Documentation + +As mentioned in the [Introduction](/developer-guide/introduction#ai-driven-contributions), we have specialized resources to enhance AI-driven development. + +This includes the [AGENTS.md](https://github.com/prowler-cloud/prowler/blob/master/docs/AGENTS.md) file that contains the guidelines and style guide for the AI agents in the Prowler documentation. + +## Local Development @@ -33,10 +52,10 @@ Prowler documentation is built using [Mintlify](https://www.mintlify.com/docs), - Once documentation updates are complete, submit a pull request for review. + Once documentation updates are complete, [submit a pull request for review](/developer-guide/introduction#sending-the-pull-request). The Prowler team will assess and merge contributions. -Your efforts help improve Prowler documentation—thank you for contributing! +Your efforts help improve Prowler documentation. Thank you for contributing! 🤘 diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx new file mode 100644 index 0000000000..0a62251531 --- /dev/null +++ b/docs/developer-guide/end2end-testing.mdx @@ -0,0 +1,327 @@ +--- +title: 'End-2-End Tests for Prowler App' +--- + +End-to-end (E2E) tests validate complete user flows in Prowler App (UI + API). These tests are implemented with [Playwright](https://playwright.dev/) under the `ui/tests` folder and are designed to run against a Prowler App environment. + +## General Recommendations + +When adding or maintaining E2E tests for Prowler App, follow these guidelines: + +1. **Test real user journeys** + Focus on full workflows (for example, sign-up → login → add provider → launch scan) instead of low-level UI details already covered by unit or integration tests. + +2. **Group tests by entity or feature area** + - Organize E2E tests by entity or feature area (for example, `providers.spec.ts`, `scans.spec.ts`, `invitations.spec.ts`, `sign-up.spec.ts`). + - Each entity should have its own test file and corresponding page model class (for example, `ProvidersPage`, `ScansPage`, `InvitationsPage`). + - Related tests for the same entity should be grouped together in the same test file to improve maintainability and make it easier to find and update tests for a specific feature. + +3. **Use a Page Model (Page Object Model)** + - Encapsulate selectors and common actions in page classes instead of repeating them in each test. + - Leverage and extend the existing Playwright page models in `ui/tests`—such as `ProvidersPage`, `ScansPage`, and others—which are all based on the shared `BasePage`. + - Page models for Prowler App pages should be placed in their respective entity folders (for example, `ui/tests/providers/providers-page.ts`). + - Page models for external pages (not part of Prowler App) should be grouped in the `external` folder (for example, `ui/tests/external/github-page.ts`). + - This approach improves readability, reduces duplication, and makes refactors safer. + +4. **Reuse authentication states (StorageState)** + - Multiple authentication setup projects are available that generate pre-authenticated state files stored in `playwright/.auth/`. Each project requires specific environment variables: + - `admin.auth.setup` – Admin users with full system permissions (requires `E2E_ADMIN_USER` / `E2E_ADMIN_PASSWORD`) + - `manage-scans.auth.setup` – Users with scan management permissions (requires `E2E_MANAGE_SCANS_USER` / `E2E_MANAGE_SCANS_PASSWORD`) + - `manage-integrations.auth.setup` – Users with integration management permissions (requires `E2E_MANAGE_INTEGRATIONS_USER` / `E2E_MANAGE_INTEGRATIONS_PASSWORD`) + - `manage-account.auth.setup` – Users with account management permissions (requires `E2E_MANAGE_ACCOUNT_USER` / `E2E_MANAGE_ACCOUNT_PASSWORD`) + - `manage-cloud-providers.auth.setup` – Users with cloud provider management permissions (requires `E2E_MANAGE_CLOUD_PROVIDERS_USER` / `E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD`) + - `unlimited-visibility.auth.setup` – Users with unlimited visibility permissions (requires `E2E_UNLIMITED_VISIBILITY_USER` / `E2E_UNLIMITED_VISIBILITY_PASSWORD`) + - `invite-and-manage-users.auth.setup` – Users with user invitation and management permissions (requires `E2E_INVITE_AND_MANAGE_USERS_USER` / `E2E_INVITE_AND_MANAGE_USERS_PASSWORD`) + + If fixtures have been applied (fixtures are used to populate the database with initial development data), you can use the user `e2e@prowler.com` with password `Thisisapassword123@` to configure the Admin credentials by setting `E2E_ADMIN_USER=e2e@prowler.com` and `E2E_ADMIN_PASSWORD=Thisisapassword123@`. + + + - Within test files, use `test.use({ storageState: "playwright/.auth/admin_user.json" })` to load the pre-authenticated state, avoiding redundant authentication steps in each test. This must be placed at the test level (not inside the test function) to apply the authentication state to all tests in that scope. This approach is preferred over declaring dependencies in `playwright.config.ts` because it provides more control over which authentication states are used in specific tests. + + **Example:** + + ```typescript + // Use admin authentication state for all tests in this scope + test.use({ storageState: "playwright/.auth/admin_user.json" }); + + test("should perform admin action", async ({ page }) => { + // Test implementation + }); + ``` + +5. **Tag and document scenarios** + - Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`,`@aws`) to filter and organize tests. + + **Example:** + ```typescript + test( + "should add a new AWS provider with static credentials", + { + tag: [ + "@critical", + "@e2e", + "@providers", + "@aws", + "@serial", + "@PROVIDER-E2E-001", + ], + }, + async ({ page }) => { + // Test implementation + } + ); + ``` + - Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**,**Key verification points** and **Notes**. + + **Example** + ```Markdown + ## Test Case: `SCANS-E2E-001` - Execute On-Demand Scan + + **Priority:** `critical` + + **Tags:** + + - type → @e2e, @serial + - feature → @scans + + **Description/Objective:** Validates the complete flow to execute an on-demand scan selecting a provider by UID and confirming success on the Scans page. + + **Preconditions:** + + - Admin user authentication required (admin.auth.setup setup) + - Environment variables configured for : E2E_AWS_PROVIDER_ACCOUNT_ID,E2E_AWS_PROVIDER_ACCESS_KEY and E2E_AWS_PROVIDER_SECRET_KEY + - Remove any existing AWS provider with the same Account ID before starting the test + - This test must be run serially and never in parallel with other tests, as it requires the Account ID Provider to be already registered. + + ### Flow Steps: + + 1. Navigate to Scans page + 2. Open provider selector and choose the entry whose text contains E2E_AWS_PROVIDER_ACCOUNT_ID + 3. Optionally fill scan label (alias) + 4. Click "Start now" to launch the scan + 5. Verify the success toast appears + 6. Verify a row in the Scans table contains the provided scan label (or shows the new scan entry) + + ### Expected Result: + + - Scan is launched successfully + - Success toast is displayed to the user + - Scans table displays the new scan entry (including the alias when provided) + + ### Key verification points: + + - Scans page loads correctly + - Provider select is available and lists the configured provider UID + - "Start now" button is rendered and enabled when form is valid + - Success toast message: "The scan was launched successfully." + - Table contains a row with the scan label or new scan state (queued/available/executing) + + ### Notes: + + - The table may take a short time to reflect the new scan; assertions look for a row containing the alias. + - Provider cleanup performed before each test to ensure clean state + - Tests should run serially to avoid state conflicts. + + ``` + +6. **Use environment variables for secrets and dynamic data** + Credentials, provider identifiers, secrets, tokens must come from environment variables (for example, `E2E_AWS_PROVIDER_ACCOUNT_ID`, `E2E_AWS_PROVIDER_ACCESS_KEY`, `E2E_AWS_PROVIDER_SECRET_KEY`, `E2E_GCP_PROJECT_ID`). + + + Never commit real secrets, tokens, or account IDs to the repository. + + +7. **Keep tests deterministic and isolated** + - Use Playwright's `test.beforeEach()` and `test.afterEach()` hooks to manage test state: + - **`test.beforeEach()`**: Execute cleanup or setup logic before each test runs (for example, delete existing providers with a specific account ID to ensure a clean state). + - **`test.afterEach()`**: Execute cleanup logic after each test completes (for example, remove test data created during the test execution to prevent interference with subsequent tests). + - Define tests as serial using `test.describe.serial()` when they share state or resources that could interfere with parallel execution (for example, tests that use the same provider account ID or create dependent resources). This ensures tests within the serial group run sequentially, preventing race conditions and data conflicts. + - Use unique identifiers (for example, random suffixes for emails or labels) to prevent data collisions. + +8. **Use explicit waiting strategies** + - Avoid using `waitForLoadState('networkidle')` as it is unreliable and can lead to flaky tests or unnecessary delays. + - Leverage Playwright's auto-waiting capabilities by waiting for specific elements to be actionable (for example, `locator.click()`, `locator.fill()`, `locator.waitFor()`). + - **Prioritize selector strategies**: Prefer `page.getByRole()` over other approaches like `page.getByText()`. `getByRole()` is more resilient to UI changes, aligns with accessibility best practices, and better reflects how users interact with the application (by role and accessible name rather than implementation details). + - For dynamic content, wait for specific UI elements that indicate the page is ready (for example, button becoming enabled, a specific text appearing, etc). + - This approach makes tests more reliable, faster, and aligned with how users actually interact with the application. + + **Common waiting patterns used in Prowler E2E tests:** + + - **Element visibility assertions**: Use `expect(locator).toBeVisible()` or `expect(locator).not.toBeVisible()` to wait for elements to appear or disappear (Playwright automatically waits for these conditions). + + - **URL changes**: Use `expect(page).toHaveURL(url)` or `page.waitForURL(url)` to wait for navigation to complete. + + - **Element states**: Use `locator.waitFor({ state: "visible" })` or `locator.waitFor({ state: "hidden" })` when you need explicit state control. + + - **Text content**: Use `expect(locator).toHaveText(text)` or `expect(locator).toContainText(text)` to wait for specific text to appear. + + - **Element attributes**: Use `expect(locator).toHaveAttribute(name, value)` to wait for attributes like `aria-disabled="false"` indicating a button is enabled. + + - **Custom conditions**: Use `page.waitForFunction(() => condition)` for complex conditions that cannot be expressed with locators (for example, checking DOM element dimensions or computed styles). + + - **Retryable assertions**: Use `expect(async () => { ... }).toPass({ timeout })` for conditions that may take time to stabilize (for example, waiting for table rows to filter after a server request). + + - **Scroll into view**: Use `locator.scrollIntoViewIfNeeded()` before interacting with elements that may be outside the viewport. + + **Example from Prowler tests:** + + ```typescript + // Wait for page to load by checking main content is visible + await expect(page.locator("main")).toBeVisible(); + + // Wait for URL change after form submission + await expect(page).toHaveURL("/providers"); + + // Wait for button to become enabled + await expect(submitButton).toHaveAttribute("aria-disabled", "false"); + + // Wait for loading spinner to disappear + await expect(page.getByText("Loading")).not.toBeVisible(); + + // Wait for custom condition + await page.waitForFunction(() => { + const main = document.querySelector("main"); + return main && main.offsetHeight > 0; + }); + + // Wait for retryable condition (e.g., table filtering) + await expect(async () => { + const rowCount = await tableRows.count(); + expect(rowCount).toBeLessThanOrEqual(1); + }).toPass({ timeout: 20000 }); + ``` + +## Running Prowler Tests + +E2E tests for Prowler App run from the `ui` project using Playwright. The Playwright configuration lives in `ui/playwright.config.ts` and defines: + +- `testDir: "./tests"` – location of E2E test files (relative to the `ui` project root, so `ui/tests`). +- `webServer` – how to start the Next.js development server and connect to Prowler API. +- `use.baseURL` – base URL for browser interactions (defaults to `http://localhost:3000` or `AUTH_URL` if set). +- `reporter: [["list"]]` – uses the list reporter to display test results in a concise format in the terminal. Other reporter options are available (for example, `html`, `json`, `junit`, `github`), and multiple reporters can be configured simultaneously. See the [Playwright reporter documentation](https://playwright.dev/docs/test-reporters) for all available options. +- `expect.timeout: 20000` – timeout for assertions (20 seconds). This is the maximum time Playwright will wait for an assertion to pass before considering it failed. +- **Test artifacts** (in `use` configuration): By default, `trace`, `screenshot`, and `video` are set to `"off"` to minimize resource usage. To review test failures or debug issues, these can be enabled in `playwright.config.ts` by changing them to `"on"`, `"on-first-retry"`, or `"retain-on-failure"` depending on your needs. +- `outputDir: "/tmp/playwright-tests"` – directory where Playwright stores test artifacts (screenshots, videos, traces) during test execution. +- **CI-specific configuration**: The configuration uses different settings when running in CI environments (detected via `process.env.CI`): + - **Retries**: `2` retries in CI (to handle flaky tests), `0` retries locally (for faster feedback during development). + - **Workers**: `1` worker in CI (sequential execution for stability), `undefined` locally (parallel execution by default for faster test runs). + +### Prerequisites + +Before running E2E tests: + +- **Install root and UI dependencies** + - Follow the [developer guide introduction](/developer-guide/introduction#getting-the-code-and-installing-all-dependencies) to clone the repository and install core dependencies. + - From the `ui` directory, install frontend dependencies: + + ```bash + cd ui + pnpm install + pnpm run test:e2e:install # Install Playwright browsers + ``` + +- **Ensure Prowler API is available** + - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). + - Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally). + - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests. + +- **Ensure Prowler App UI is available** + - Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default). + - If the UI is already running on `http://localhost:3000`, Playwright will reuse the existing server when `reuseExistingServer` is `true`. + +- **Configure E2E environment variables** + - Suite-specific variables (for example, provider account IDs, credentials, and E2E user data) must be provided before running tests. + - They can be defined either: + - As exported environment variables in the shell before executing the Playwright commands, or + - In a `.env.local` or `.env` file under `ui/`, and then loaded into the shell before running tests, for example: + + ```bash + cd ui + set -a + source .env.local # or .env + set +a + ``` + - Refer to the Markdown documentation files in `ui/tests` for each E2E suite (for example, the `*.md` files that describe sign-up, providers, scans, invitations, and other flows) to see the exact list of required variables and their meaning. + - Each E2E test suite explicitly checks that its required environment variables are defined at runtime and will fail with a clear error message if any mandatory variable is missing, making misconfiguration easy to detect. + +### Executing Tests + +To execute E2E tests for Prowler App: + +1. **Run the full E2E suite (headless)** + + From the `ui` directory: + + ```bash + pnpm run test:e2e + ``` + + This command runs Playwright with the configured projects + +2. **Run E2E tests with the Playwright UI runner** + + ```bash + pnpm run test:e2e:ui + ``` + + This opens the Playwright test runner UI to inspect, debug, and rerun specific tests or projects. + +3. **Debug E2E tests interactively** + + ```bash + pnpm run test:e2e:debug + ``` + + Use this mode to step through flows, inspect selectors, and adjust timings. It runs tests in headed mode with debugging tools enabled. + +4. **Run tests in headed mode without debugger** + + ```bash + pnpm run test:e2e:headed + ``` + + This is useful to visually confirm flows while still running the full suite. + +5. **View previous test reports** + + ```bash + pnpm run test:e2e:report + ``` + + This opens the latest Playwright HTML report, including traces and screenshots when enabled. + +6. **Run specific tests or subsets** + + In addition to the predefined scripts, Playwright allows filtering which tests run. These examples use the Playwright CLI directly through `pnpm`: + + - **By test ID (`@ID` in the test metadata or description)** + + To run a single test case identified by its ID (for example, `@PROVIDER-E2E-001` or `@SCANS-E2E-001`): + + ```bash + pnpm playwright test --grep @PROVIDER-E2E-001 + ``` + + - **By tags** + + To run all tests that share a common tag (for example, all provider E2E tests tagged with `@providers`): + + ```bash + pnpm playwright test --grep @providers + ``` + + This is useful to focus on a specific feature area such as providers, scans, invitations, or sign-up. + + - **By Playwright project** + + To run only the tests associated with a given project defined in `playwright.config.ts` (for example, `providers` or `scans`): + + ```bash + pnpm playwright test --project=providers + ``` + + Combining project and grep filters is also supported, enabling very narrow runs (for example, a single test ID within the `providers` project). For additional CLI options and combinations, see the [Playwright command line documentation](https://playwright.dev/docs/test-cli). + + +For detailed flows, preconditions, and environment variable requirements per feature, always refer to the Markdown files in `ui/tests`. Those documents are the single source of truth for business expectations and validation points in each E2E suite. + diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 5361d4460a..11076baa10 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -2,19 +2,77 @@ title: 'Introduction to developing in Prowler' --- -Extending Prowler +Thanks for your interest in contributing to Prowler! -Prowler can be extended in various ways, with common use cases including: +Prowler can be extended in various ways. This guide provides the different ways to contribute and how to get started. -- New security checks -- New compliance frameworks -- New output formats -- New integrations -- New proposed features + +Maintainers will assess whether a change fits the project roadmap and scope before merging. + -All the relevant information for these cases is included in this guide. +## Contributing to Prowler -## Getting the Code and Installing All Dependencies +### Review Current Issues +Check out our [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page for ideas to contribute. + + + We tag issues as `good first issue` for new contributors. These are typically well-defined and manageable in scope. + + + We tag issues as `help wanted` for other issues that require more time to complete. + + + +### Expand Prowler's Capabilities +Prowler is constantly evolving. Contributions to checks, services, or integrations help improve the tool for everyone. Here is how to get involved: + + + + Want to improve Prowler's detection capabilities for your favorite cloud provider? You can contribute by writing new checks. + + + One key service for your favorite cloud provider is missing? Add it to Prowler! Do not forget to include relevant checks to validate functionality. + + + If you would like to extend Prowler to work with a new cloud provider, this typically involves setting up new services and checks to ensure compatibility. + + + Need to ensure Prowler supports a specific compliance framework? Add new security compliance frameworks to map checks against regulatory or industry standards. + + + Want to tailor how results are displayed or exported? You can add custom output formats. + + + Prowler can work with other tools and platforms through integrations. + + + Propose brand-new features or enhancements to existing ones, or help implement community-requested improvements. + + + +### Improve Documentation +Help make Prowler more accessible by enhancing our documentation, fixing typos, or adding examples/tutorials. + + + + Enhance our documentation, fix typos, or add examples/tutorials. + + + +### Bug Fixes +If you find any issues or bugs, you can report them in the [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page and if you want you can also fix them. + + + + Report or fix issues or bugs. + + + +Remember, our community is here to help! If you need guidance, do not hesitate to ask questions in the issues or join our [ Slack workspace](https://goto.prowler.com/slack). + + + +## Setting up your development environment ### Prerequisites @@ -26,11 +84,11 @@ Before proceeding, ensure the following: ### Forking the Prowler Repository -To contribute to Prowler, fork the Prowler GitHub repository. This allows you to propose changes, submit new features, and fix bugs. For guidance on forking, refer to the [official GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui#forking-a-repository). +Fork the Prowler GitHub repository to contribute to Prowler. This allows proposing changes, submitting new features, and fixing bugs. For guidance on forking, refer to the [official GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui#forking-a-repository). ### Cloning Your Forked Repository -Once your fork is created, clone it using the following commands: +Once your fork is created, clone it using the following commands (replace `` with your GitHub username): ``` git clone https://github.com//prowler @@ -56,39 +114,7 @@ If your poetry version is below 2.0.0 you must keep using `poetry shell` to acti In case you have any doubts, consult the [Poetry environment activation guide](https://python-poetry.org/docs/managing-environments/#activating-the-environment). -## Contributing to Prowler -### Ways to Contribute - -Here are some ideas for collaborating with Prowler: - -1. **Review Current Issues**: Check out our [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page. We often tag issues as `good first issue` - these are perfect for new contributors as they are typically well-defined and manageable in scope. - -2. **Expand Prowler's Capabilities**: Prowler is constantly evolving, and you can be a part of its growth. Whether you are adding checks, supporting new services, or introducing integrations, your contributions help improve the tool for everyone. Here is how you can get involved: - - - **Adding New Checks** - Want to improve Prowler's detection capabilities for your favorite cloud provider? You can contribute by writing new checks. To get started, follow the [create a new check guide](/developer-guide/checks). - - - **Adding New Services** - One key service for your favorite cloud provider is missing? Add it to Prowler! To add a new service, check out the [create a new service guide](/developer-guide/services). Do not forget to include relevant checks to validate functionality. - - - **Adding New Providers** - If you would like to extend Prowler to work with a new cloud provider, follow the [create a new provider guide](/developer-guide/provider). This typically involves setting up new services and checks to ensure compatibility. - - - **Adding New Output Formats** - Want to tailor how results are displayed or exported? You can add custom output formats by following the [create a new output format guide](/developer-guide/outputs). - - - **Adding New Integrations** - Prowler can work with other tools and platforms through integrations. If you would like to add one, see the [create a new integration guide](/developer-guide/integrations). - - - **Proposing or Implementing Features** - Got an idea to make Prowler better? Whether it is a brand-new feature or an enhancement to an existing one, you are welcome to propose it or help implement community-requested improvements. - -3. **Improve Documentation**: Help make Prowler more accessible by enhancing our documentation, fixing typos, or adding examples/tutorials. See the tutorial of how we write our documentation [here](/developer-guide/documentation). - -4. **Bug Fixes**: If you find any issues or bugs, you can report them in the [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page and if you want you can also fix them. - -Remember, our community is here to help! If you need guidance, do not hesitate to ask questions in the issues or join our [Slack workspace](https://goto.prowler.com/slack). ### Pre-Commit Hooks @@ -121,6 +147,18 @@ These should have been already installed if `poetry install --with dev` was alre Additionally, ensure the latest version of [`TruffleHog`](https://github.com/trufflesecurity/trufflehog) is installed to scan for sensitive data in the code. Follow the official [installation guide](https://github.com/trufflesecurity/trufflehog?tab=readme-ov-file#floppy_disk-installation) for setup. +### AI-Driven Contributions + +If you are using AI assistants to help with your contributions, Prowler provides specialized resources to enhance AI-driven development: + +- **Prowler MCP Server**: The [Prowler MCP Server](/getting-started/products/prowler-mcp) provides AI assistants with access to the entire Prowler ecosystem, including security checks, compliance frameworks, documentation, and more. This enables AI tools to better understand Prowler's architecture and help you create contributions that align with project standards. + +- **AGENTS.md Files**: Each component of the Prowler monorepo includes an `AGENTS.md` file that contains specific guidelines for AI agents working on that component. These files provide context about project structure, coding standards, and best practices. When working on a specific component, refer to the relevant `AGENTS.md` file (e.g., `prowler/AGENTS.md`, `ui/AGENTS.md`, `api/AGENTS.md`) to ensure your AI assistant follows the appropriate guidelines. + +- **AI Skills System**: The [AI Skills system](/developer-guide/ai-skills) provides on-demand patterns, templates, and best practices for AI agents. Skills help AI assistants understand Prowler's conventions and generate code that aligns with project standards. The skills are located in the `skills/` directory and are registered in the `AGENTS.md` files. + +These resources help ensure that AI-assisted contributions maintain consistency with Prowler's codebase and development practices. + ### Dependency Management All dependencies are listed in the `pyproject.toml` file. @@ -133,7 +171,7 @@ If you encounter issues when committing to the Prowler repository, use the `--no ### Repository Folder Structure -Understanding the layout of the Prowler codebase will help you quickly find where to add new features, checks, or integrations. The following is a high-level overview from the root of the repository: +The Prowler codebase layout helps quickly locate where to add new features, checks, or integrations. The following is a high-level overview from the root of the repository: ``` prowler/ @@ -148,7 +186,7 @@ prowler/ ├── permissions/ # Permission-related files and policies ├── contrib/ # Community-contributed scripts or modules ├── kubernetes/ # Kubernetes deployment files -├── .github/ # GitHub related files (workflows, issue templates, etc.) +├── .github/ # GitHub-related files (workflows, issue templates, etc.) ├── pyproject.toml # Python project configuration (Poetry) ├── poetry.lock # Poetry lock file ├── README.md # Project overview and getting started @@ -158,19 +196,23 @@ prowler/ └── ... # Other supporting files ``` -## Pull Request Checklist +## Sending the Pull Request -When creating or reviewing a pull request in https://github.com/prowler-cloud/prowler, follow [this checklist](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md#checklist). +When creating or reviewing a pull request in [Prowler](https://github.com/prowler-cloud/prowler), follow [this template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and fill it with the relevant information: + +- **Context** and **Description** of the change: This will help the reviewers to understand the change and the purpose of the pull request. +- **Steps to review**: A detailed description of how to review the change. +- **Checklist**: A mandatory checklist of the things that should be reviewed before merging the pull request. ## Contribution Appreciation -If you enjoy swag, we’d love to thank you for your contribution with laptop stickers or other Prowler merchandise! +If you enjoy swag, we'd love to thank you for your contribution with laptop stickers or other Prowler merchandise! To request swag: Share your pull request details in our [Slack workspace](https://goto.prowler.com/slack). You can also reach out to Toni de la Fuente on [Twitter](https://twitter.com/ToniBlyx)—his DMs are open! -# Testing a Pull Request from a Specific Branch +## Testing a Pull Request from a Specific Branch To test Prowler from a specific branch (for example, to try out changes from a pull request before it is merged), you can use `pipx` to install directly from GitHub: @@ -179,3 +221,5 @@ pipx install "git+https://github.com/prowler-cloud/prowler.git@branch-name" ``` Replace `branch-name` with the name of the branch you want to test. This will install Prowler in an isolated environment, allowing you to try out the changes safely. + +For more details on testing go to the [Testing section](/developer-guide/unit-testing) of this documentation. diff --git a/docs/developer-guide/kubernetes-details.mdx b/docs/developer-guide/kubernetes-details.mdx index fa0c9291f6..0ccda94f38 100644 --- a/docs/developer-guide/kubernetes-details.mdx +++ b/docs/developer-guide/kubernetes-details.mdx @@ -4,7 +4,7 @@ title: 'Kubernetes Provider' This page details the [Kubernetes](https://kubernetes.io/) provider implementation in Prowler. -By default, Prowler will audit all namespaces in the Kubernetes cluster accessible by the configured context. To configure it, see the [In-Cluster Execution](/user-guide/providers/kubernetes/in-cluster) or [Non In-Cluster Execution](/user-guide/providers/kubernetes/outside-cluster) guides. +By default, Prowler will audit all namespaces in the Kubernetes cluster accessible by the configured context. To configure it, see the [In-Cluster Execution](/user-guide/providers/kubernetes/getting-started-k8s#in-cluster-execution) or [Non In-Cluster Execution](/user-guide/providers/kubernetes/getting-started-k8s#non-in-cluster-execution) guides. ## Kubernetes Provider Classes Architecture diff --git a/docs/developer-guide/lighthouse-architecture.mdx b/docs/developer-guide/lighthouse-architecture.mdx new file mode 100644 index 0000000000..38ee50c5b8 --- /dev/null +++ b/docs/developer-guide/lighthouse-architecture.mdx @@ -0,0 +1,407 @@ +--- +title: 'Lighthouse AI Architecture' +--- + +This document describes the internal architecture of Prowler Lighthouse AI, enabling developers to understand how components interact and where to add new functionality. + + +**Looking for user documentation?** See: +- [Lighthouse AI Overview](/getting-started/products/prowler-lighthouse-ai) - Capabilities and FAQs +- [How Lighthouse AI Works](/user-guide/tutorials/prowler-app-lighthouse) - Configuration and usage +- [Multi-LLM Provider Setup](/user-guide/tutorials/prowler-app-lighthouse-multi-llm) - Provider configuration + + +## Architecture Overview + +Lighthouse AI operates as a Langchain-based agent that connects Large Language Models (LLMs) with Prowler security data through the Model Context Protocol (MCP). + +Prowler Lighthouse Architecture +Prowler Lighthouse Architecture + +### Three-Tier Architecture + +The system follows a three-tier architecture: + +1. **Frontend (Next.js)**: Chat interface, message rendering, model selection +2. **API Route**: Request handling, authentication, stream transformation +3. **Langchain Agent**: LLM orchestration, tool calling through MCP + +### Request Flow + +When a user sends a message through the Lighthouse chat interface, the system processes it through several stages: + +1. **User Submits a Message**. + The chat component (`ui/components/lighthouse/chat.tsx`) captures the user's question (e.g., "What are my critical findings in AWS?") and sends it as an HTTP POST request to the backend API route. + +2. **Authentication and Context Assembly**. + The API route (`ui/app/api/lighthouse/analyst/route.ts`) validates the user's session, extracts the JWT token (stored via `auth-context.ts`), and gathers context including the tenant's business context and current security posture data (assembled in `data.ts`). + +3. **Agent Initialization**. + The workflow orchestrator (`ui/lib/lighthouse/workflow.ts`) creates a Langchain agent configured with: + - The selected LLM, instantiated through the factory (`llm-factory.ts`) + - A system prompt containing available tools and instructions (`system-prompt.ts`) + - Two meta-tools (`describe_tool` and `execute_tool`) for accessing Prowler data + +4. **LLM Reasoning and Tool Calling**. + The agent sends the conversation to the LLM, which decides whether to respond directly or call tools to fetch data. When tools are needed, the meta-tools in `ui/lib/lighthouse/tools/meta-tool.ts` interact with the MCP client (`mcp-client.ts`) to: + - First call `describe_tool` to understand the tool's parameters + - Then call `execute_tool` to retrieve data from the MCP Server + - Continue reasoning with the returned data + +5. **Streaming Response**. + As the LLM generates its response, the stream handler (`ui/lib/lighthouse/analyst-stream.ts`) transforms Langchain events into UI-compatible messages and streams tokens back to the browser in real-time using Server-Sent Events. The stream includes both text tokens and tool execution events (displayed as "chain of thought"). + +6. **Message Rendering**. + The frontend receives the stream and renders it through `message-item.tsx` with markdown formatting. Any tool calls that occurred during reasoning are displayed via `chain-of-thought-display.tsx`. + +## Frontend Components + +Frontend components reside in `ui/components/lighthouse/` and handle the chat interface and configuration workflows. + +### Core Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| `chat.tsx` | `ui/components/lighthouse/` | Main chat interface managing message history and input handling | +| `message-item.tsx` | `ui/components/lighthouse/` | Individual message rendering with markdown support | +| `select-model.tsx` | `ui/components/lighthouse/` | Model and provider selection dropdown | +| `chain-of-thought-display.tsx` | `ui/components/lighthouse/` | Displays tool calls and reasoning steps during execution | + +### Configuration Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| `lighthouse-settings.tsx` | `ui/components/lighthouse/` | Settings panel for business context and preferences | +| `connect-llm-provider.tsx` | `ui/components/lighthouse/` | Provider connection workflow | +| `llm-providers-table.tsx` | `ui/components/lighthouse/` | Provider management table | +| `forms/delete-llm-provider-form.tsx` | `ui/components/lighthouse/forms/` | Provider deletion confirmation dialog | + +### Supporting Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| `banner.tsx` / `banner-client.tsx` | `ui/components/lighthouse/` | Status banners and notifications | +| `workflow/` | `ui/components/lighthouse/workflow/` | Multi-step configuration workflows | +| `ai-elements/` | `ui/components/lighthouse/ai-elements/` | Custom UI primitives for chat interface (input, select, dropdown, tooltip) | + +## Library Code + +Core library code resides in `ui/lib/lighthouse/` and handles agent orchestration, MCP communication, and stream processing. + +### Workflow Orchestrator + +**Location:** `ui/lib/lighthouse/workflow.ts` + +The workflow module serves as the core orchestrator, responsible for: + +- Initializing the Langchain agent with system prompt and tools +- Loading tenant configuration (default provider, model, business context) +- Creating the LLM instance through the factory +- Generating dynamic tool listings from available MCP tools + +```typescript +// Simplified workflow initialization +export async function initLighthouseWorkflow(runtimeConfig?: RuntimeConfig) { + await initializeMCPClient(); + + const toolListing = generateToolListing(); + const systemPrompt = LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE.replace( + "{{TOOL_LISTING}}", + toolListing, + ); + + const llm = createLLM({ + provider: providerType, + model: modelId, + credentials, + // ... + }); + + return createAgent({ + model: llm, + tools: [describeTool, executeTool], + systemPrompt, + }); +} +``` + +### MCP Client Manager + +**Location:** `ui/lib/lighthouse/mcp-client.ts` + +The MCP client manages connections to the Prowler MCP Server using a singleton pattern: + +- **Connection Management**: Retry logic with configurable attempts and delays +- **Tool Discovery**: Fetches available tools from MCP server on initialization +- **Authentication Injection**: Automatically adds JWT tokens to `prowler_app_*` tool calls +- **Reconnection**: Supports forced reconnection after server restarts + +Key constants: +- `MAX_RETRY_ATTEMPTS`: 3 connection attempts +- `RETRY_DELAY_MS`: 2000ms between retries +- `RECONNECT_INTERVAL_MS`: 5 minutes before retry after failure + +```typescript +// Authentication injection for Prowler App tools +private handleBeforeToolCall = ({ name, args }) => { + // Only inject auth for prowler_app_* tools (user-specific data) + if (!name.startsWith("prowler_app_")) { + return { args }; + } + + const accessToken = getAuthContext(); + return { + args, + headers: { Authorization: `Bearer ${accessToken}` }, + }; +}; +``` + +### Meta-Tools + +**Location:** `ui/lib/lighthouse/tools/meta-tool.ts` + +Instead of registering all MCP tools directly with the agent, Lighthouse uses two meta-tools for dynamic tool discovery and execution: + +| Tool | Purpose | +|------|---------| +| `describe_tool` | Retrieves full schema and parameter details for a specific tool | +| `execute_tool` | Executes a tool with provided parameters | + +This pattern reduces the number of tools the LLM must track while maintaining access to all MCP capabilities. + +### Additional Library Modules + +| Module | Location | Purpose | +|--------|----------|---------| +| `analyst-stream.ts` | `ui/lib/lighthouse/` | Transforms Langchain stream events to UI message format | +| `llm-factory.ts` | `ui/lib/lighthouse/` | Creates LLM instances for OpenAI, Bedrock, and OpenAI-compatible providers | +| `system-prompt.ts` | `ui/lib/lighthouse/` | System prompt template with dynamic tool listing injection | +| `auth-context.ts` | `ui/lib/lighthouse/` | AsyncLocalStorage for JWT token propagation across async boundaries | +| `types.ts` | `ui/lib/lighthouse/` | TypeScript type definitions | +| `constants.ts` | `ui/lib/lighthouse/` | Configuration constants and error messages | +| `utils.ts` | `ui/lib/lighthouse/` | Message conversion and model parameter extraction | +| `validation.ts` | `ui/lib/lighthouse/` | Input validation utilities | +| `data.ts` | `ui/lib/lighthouse/` | Current data section generation for context enrichment | + +## API Route + +**Location:** `ui/app/api/lighthouse/analyst/route.ts` + +The API route handles chat requests and manages the streaming response pipeline: + +1. **Request Parsing**: Extracts messages, model, and provider from request body +2. **Authentication**: Validates session and extracts access token +3. **Context Assembly**: Gathers business context and current data +4. **Agent Initialization**: Creates Langchain agent with runtime configuration +5. **Stream Processing**: Transforms agent events to UI-compatible format +6. **Error Handling**: Captures errors with Sentry integration + +```typescript +export async function POST(req: Request) { + const { messages, model, provider } = await req.json(); + + const session = await auth(); + if (!session?.accessToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + return await authContextStorage.run(accessToken, async () => { + const app = await initLighthouseWorkflow(runtimeConfig); + const agentStream = app.streamEvents({ messages }, { version: "v2" }); + + // Transform stream events to UI format + const stream = new ReadableStream({ + async start(controller) { + for await (const streamEvent of agentStream) { + // Handle on_chat_model_stream, on_tool_start, on_tool_end, etc. + } + }, + }); + + return createUIMessageStreamResponse({ stream }); + }); +} +``` + +## Backend Components + +Backend components handle LLM provider configuration, model management, and credential storage. + +### Database Models + +**Location:** `api/src/backend/api/models.py` + +| Model | Purpose | +|-------|---------| +| `LighthouseProviderConfiguration` | Per-tenant LLM provider credentials (encrypted with Fernet) | +| `LighthouseTenantConfiguration` | Tenant-level settings including business context and default provider/model | +| `LighthouseProviderModels` | Available models per provider configuration | + +All models implement Row-Level Security (RLS) for tenant isolation. + +#### LighthouseProviderConfiguration + +Stores provider-specific credentials for each tenant: + +- **provider_type**: `openai`, `bedrock`, or `openai_compatible` +- **credentials**: Encrypted JSON containing API keys or AWS credentials +- **base_url**: Custom endpoint for OpenAI-compatible providers +- **is_active**: Connection validation status + +#### LighthouseTenantConfiguration + +Stores tenant-wide Lighthouse settings: + +- **business_context**: Optional context for personalized responses +- **default_provider**: Default LLM provider type +- **default_models**: JSON mapping provider types to default model IDs + +#### LighthouseProviderModels + +Catalogs available models for each provider: + +- **model_id**: Provider-specific model identifier +- **model_name**: Human-readable display name +- **default_parameters**: Optional model-specific parameters + +### Background Jobs + +**Location:** `api/src/backend/tasks/jobs/lighthouse_providers.py` + +#### check_lighthouse_provider_connection + +Validates provider credentials by making a test API call: + +- OpenAI: Lists models via `client.models.list()` +- Bedrock: Lists foundation models via `bedrock_client.list_foundation_models()` +- OpenAI-compatible: Lists models via custom base URL + +Updates `is_active` status based on connection result. + +#### refresh_lighthouse_provider_models + +Synchronizes available models from provider APIs: + +- Fetches current model catalog from provider +- Filters out non-chat models (DALL-E, Whisper, TTS, embeddings) +- Upserts model records in `LighthouseProviderModels` +- Removes stale models no longer available + +**Excluded OpenAI model prefixes:** +```python +EXCLUDED_OPENAI_MODEL_PREFIXES = ( + "dall-e", "whisper", "tts-", "sora", + "text-embedding", "text-moderation", + # Legacy models + "text-davinci", "davinci", "curie", "babbage", "ada", +) +``` + +## MCP Server Integration + +Lighthouse AI communicates with the Prowler MCP Server to access security data. For detailed MCP Server architecture, see [Extending the MCP Server](/developer-guide/mcp-server). + +### Tool Namespacing + +MCP tools are organized into three namespaces based on authentication requirements: + +| Namespace | Auth Required | Description | +|-----------|---------------|-------------| +| `prowler_app_*` | Yes (JWT) | Prowler Cloud/App tools for findings, providers, scans, resources | +| `prowler_hub_*` | No | Security checks catalog, compliance frameworks | +| `prowler_docs_*` | No | Documentation search and retrieval | + +### Authentication Flow + +1. User authenticates with Prowler App, receiving a JWT token +2. Token is stored in session and propagated via `authContextStorage` +3. MCP client injects `Authorization: Bearer ` header for `prowler_app_*` calls +4. MCP Server validates token and applies RLS filtering + +### Tool Execution Pattern + +The agent uses meta-tools rather than direct tool registration: + +``` +Agent needs data → describe_tool("prowler_app_search_findings") + → Returns parameter schema → execute_tool with parameters + → MCP client adds auth header → MCP Server executes + → Results returned to agent → Agent continues reasoning +``` + +## Extension Points + +### Adding New LLM Providers + +To add a new LLM provider: + +1. **Frontend**: Update `ui/lib/lighthouse/llm-factory.ts` with provider-specific initialization +2. **Backend**: Add provider type to `LighthouseProviderConfiguration.LLMProviderChoices` +3. **Jobs**: Add credential extraction and model fetching in `lighthouse_providers.py` +4. **UI**: Add connection workflow in `ui/components/lighthouse/workflow/` + +### Modifying System Prompt + +The system prompt template lives in `ui/lib/lighthouse/system-prompt.ts`. The `{{TOOL_LISTING}}` placeholder is dynamically replaced with available MCP tools during agent initialization. + +### Adding Stream Events + +To handle new Langchain stream events, modify `ui/lib/lighthouse/analyst-stream.ts`. Current handlers include: + +- `on_chat_model_stream`: Token-by-token text streaming +- `on_chat_model_end`: Model completion with tool call detection +- `on_tool_start`: Tool execution started +- `on_tool_end`: Tool execution completed + +### Adding MCP Tools + +See [Extending the MCP Server](/developer-guide/mcp-server) for detailed instructions on adding new tools to the Prowler MCP Server. + +## Configuration + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `PROWLER_MCP_SERVER_URL` | MCP server endpoint (e.g., `https://mcp.prowler.com/mcp`) | + +### Database Configuration + +Provider credentials are stored encrypted in `LighthouseProviderConfiguration`: + +- **OpenAI**: `{"api_key": "sk-..."}` +- **Bedrock**: `{"access_key_id": "...", "secret_access_key": "...", "region": "us-east-1"}` or `{"api_key": "...", "region": "us-east-1"}` +- **OpenAI-compatible**: `{"api_key": "..."}` with `base_url` field + +### Tenant Configuration + +Business context and default settings are stored in `LighthouseTenantConfiguration`: + +```python +{ + "business_context": "Optional organization context for personalized responses", + "default_provider": "openai", + "default_models": { + "openai": "gpt-4o", + "bedrock": "anthropic.claude-3-5-sonnet-20240620-v1:0" + } +} +``` + +## Related Documentation + + + + Adding new tools to the Prowler MCP Server + + + Capabilities, FAQs, and limitations + + + Configuring multiple LLM providers + + + User-facing architecture and setup guide + + diff --git a/docs/developer-guide/lighthouse.mdx b/docs/developer-guide/lighthouse.mdx deleted file mode 100644 index 25afd51728..0000000000 --- a/docs/developer-guide/lighthouse.mdx +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: 'Extending Prowler Lighthouse AI' ---- - -This guide helps developers customize and extend Prowler Lighthouse AI by adding or modifying AI agents. - -## Understanding AI Agents - -AI agents combine Large Language Models (LLMs) with specialized tools that provide environmental context. These tools can include API calls, system command execution, or any function-wrapped capability. - -### Types of AI Agents - -AI agents fall into two main categories: - -- **Autonomous Agents**: Freely chooses from available tools to complete tasks, adapting their approach based on context. They decide which tools to use and when. -- **Workflow Agents**: Follows structured paths with predefined logic. They execute specific tool sequences and can include conditional logic. - -Prowler Lighthouse AI is an autonomous agent - selecting the right tool(s) based on the users query. - - -To learn more about AI agents, read [Anthropic's blog post on building effective agents](https://www.anthropic.com/engineering/building-effective-agents). - - -### LLM Dependency - -The autonomous nature of agents depends on the underlying LLM. Autonomous agents using identical system prompts and tools but powered by different LLM providers might approach user queries differently. Agent with one LLM might solve a problem efficiently, while with another it might take a different route or fail entirely. - -After evaluating multiple LLM providers (OpenAI, Gemini, Claude, LLama) based on tool calling features and response accuracy, we recommend using the `gpt-4o` model. - -## Prowler Lighthouse AI Architecture - -Prowler Lighthouse AI uses a multi-agent architecture orchestrated by the [Langgraph-Supervisor](https://www.npmjs.com/package/@langchain/langgraph-supervisor) library. - -### Architecture Components - -Prowler Lighthouse architecture - -Prowler Lighthouse AI integrates with the NextJS application: - -- The [Langgraph-Supervisor](https://www.npmjs.com/package/@langchain/langgraph-supervisor) library integrates directly with NextJS -- The system uses the authenticated user session to interact with the Prowler API server -- Agents only access data the current user is authorized to view -- Session management operates automatically, ensuring Role-Based Access Control (RBAC) is maintained - -## Available Prowler AI Agents - -The following specialized AI agents are available in Prowler: - -### Agent Overview - -- **provider_agent**: Fetches information about cloud providers connected to Prowler -- **user_info_agent**: Retrieves information about Prowler users -- **scans_agent**: Fetches information about Prowler scans -- **compliance_agent**: Retrieves compliance overviews across scans -- **findings_agent**: Fetches information about individual findings across scans -- **overview_agent**: Retrieves overview information (providers, findings by status and severity, etc.) - -## How to Add New Capabilities - -### Updating the Supervisor Prompt - -The supervisor agent controls system behavior, tone, and capabilities. You can find the supervisor prompt at: [https://github.com/prowler-cloud/prowler/blob/master/ui/lib/lighthouse/prompts.ts](https://github.com/prowler-cloud/prowler/blob/master/ui/lib/lighthouse/prompts.ts) - -#### Supervisor Prompt Modifications - -Modifying the supervisor prompt allows you to: - -- Change personality or response style -- Add new high-level capabilities -- Modify task delegation to specialized agents -- Set up guardrails (query types to answer or decline) - - -The supervisor agent should not have its own tools. This design keeps the system modular and maintainable. - - -### How to Create New Specialized Agents - -The supervisor agent and all specialized agents are defined in the `route.ts` file. The supervisor agent uses [langgraph-supervisor](https://www.npmjs.com/package/@langchain/langgraph-supervisor), while other agents use the prebuilt [create-react-agent](https://langchain-ai.github.io/langgraphjs/how-tos/create-react-agent/). - -To add new capabilities or all Lighthouse AI to interact with other APIs, create additional specialized agents: - -1. First determine what the new agent would do. Create a detailed prompt defining the agent's purpose and capabilities. You can see an example from [here](https://github.com/prowler-cloud/prowler/blob/master/ui/lib/lighthouse/prompts.ts#L359-L385). - -Ensure that the new agent's capabilities don't collide with existing agents. For example, if there's already a *findings_agent* that talks to findings APIs don't create a new agent to do the same. - - -2. Create necessary tools for the agents to access specific data or perform actions. A tool is a specialized function that extends the capabilities of LLM by allowing it to access external data or APIs. A tool is triggered by LLM based on the description of the tool and the user's query. -For example, the description of `getScanTool` is "Fetches detailed information about a specific scan by its ID." If the description doesn't convey what the tool is capable of doing, LLM will not invoke the function. If the description of `getScanTool` was set to something random or not set at all, LLM will not answer queries like "Give me the critical issues from the scan ID xxxxxxxxxxxxxxx" - -Ensure that one tool is added to one agent only. Adding tools is optional. There can be agents with no tools at all. - - -3. Use the `createReactAgent` function to define a new agent. For example, the rolesAgent name is "roles_agent" and has access to call tools "*getRolesTool*" and "*getRoleTool*" -```js -const rolesAgent = createReactAgent({ - llm: llm, - tools: [getRolesTool, getRoleTool], - name: "roles_agent", - prompt: rolesAgentPrompt, -}); -``` - -4. Create a detailed prompt defining the agent's purpose and capabilities. - -5. Add the new agent to the available agents list: -```js -const agents = [ - userInfoAgent, - providerAgent, - overviewAgent, - scansAgent, - complianceAgent, - findingsAgent, - rolesAgent, // New agent added here -]; -// Create supervisor workflow -const workflow = createSupervisor({ - agents: agents, - llm: supervisorllm, - prompt: supervisorPrompt, - outputMode: "last_message", -}); -``` - -6. Update the supervisor's system prompt to summarize the new agent's capabilities. - -### Best Practices for Agent Development - -When developing new agents or capabilities: - -- **Clear Responsibility Boundaries**: Each agent should have a defined purpose with minimal overlap. No two agents should access the same tools or different tools accessing the same Prowler APIs. -- **Minimal Data Access**: Agents should only request the data they need, keeping requests specific to minimize context window usage, cost, and response time. -- **Thorough Prompting:** Ensure agent prompts include clear instructions about: - - The agent's purpose and limitations - - How to use its tools - - How to format responses for the supervisor - - Error handling procedures (Optional) -- **Security Considerations:** Agents should never modify data or access sensitive information like secrets or credentials. -- **Testing:** Thoroughly test new agents with various queries before deploying to production. diff --git a/docs/developer-guide/mcp-server.mdx b/docs/developer-guide/mcp-server.mdx new file mode 100644 index 0000000000..4174f0f730 --- /dev/null +++ b/docs/developer-guide/mcp-server.mdx @@ -0,0 +1,447 @@ +--- +title: 'Extending the MCP Server' +--- + +This guide explains how to extend the Prowler MCP Server with new tools and features. + + +**New to Prowler MCP Server?** Start with the user documentation: +- [Overview](/getting-started/products/prowler-mcp) - Key capabilities, use cases, and deployment options +- [Installation](/getting-started/installation/prowler-mcp) - Install locally or use the managed server +- [Configuration](/getting-started/basic-usage/prowler-mcp) - Configure Claude Desktop, Cursor, and other MCP hosts +- [Tools Reference](/getting-started/basic-usage/prowler-mcp-tools) - Complete list of all available tools + + +## Introduction + +The Prowler MCP Server brings the entire Prowler ecosystem to AI assistants through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). It enables seamless integration with AI tools like Claude Desktop, Cursor, and other MCP clients. + +The server follows a modular architecture with three independent sub-servers: + +| Sub-Server | Auth Required | Description | +|------------|---------------|-------------| +| Prowler App | Yes | Full access to Prowler Cloud and Self-Managed features | +| Prowler Hub | No | Security checks catalog with **over 1000 checks**, fixers, and **70+ compliance frameworks** | +| Prowler Documentation | No | Full-text search and retrieval of official documentation | + + +For a complete list of tools and their descriptions, see the [Tools Reference](/getting-started/basic-usage/prowler-mcp-tools). + + +## Architecture Overview + +The MCP Server architecture is illustrated in the [Overview documentation](/getting-started/products/prowler-mcp#mcp-server-architecture). AI assistants connect through the MCP protocol to access Prowler's three main components. + +### Server Structure + +The main server orchestrates three sub-servers with prefixed namespacing: + +``` +mcp_server/prowler_mcp_server/ +├── server.py # Main orchestrator +├── main.py # CLI entry point +├── prowler_hub/ +├── prowler_app/ +│ ├── tools/ # Tool implementations +│ ├── models/ # Pydantic models +│ └── utils/ # API client, auth, loader +└── prowler_documentation/ +``` + +### Tool Registration Patterns + +The MCP Server uses two patterns for tool registration: + +1. **Direct Decorators** (Prowler Hub/Docs): Tools are registered using `@mcp.tool()` decorators +2. **Auto-Discovery** (Prowler App): All public methods of `BaseTool` subclasses are auto-registered + +## Adding Tools to Prowler App + +### Step 1: Create the Tool Class + +Create a new file or add to an existing file in `prowler_app/tools/`: + +```python +# prowler_app/tools/new_feature.py +from typing import Any + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.new_feature import ( + FeatureListResponse, + DetailedFeature, +) +from prowler_mcp_server.prowler_app.tools.base import BaseTool + + +class NewFeatureTools(BaseTool): + """Tools for managing new features.""" + + async def list_features( + self, + status: str | None = Field( + default=None, + description="Filter by status (active, inactive, pending)" + ), + page_size: int = Field( + default=50, + description="Number of results per page (1-100)" + ), + ) -> dict[str, Any]: + """List all features with optional filtering. + + Returns a lightweight list of features optimized for LLM consumption. + Use get_feature for complete information about a specific feature. + """ + # Validate parameters + self.api_client.validate_page_size(page_size) + + # Build query parameters + params: dict[str, Any] = {"page[size]": page_size} + if status: + params["filter[status]"] = status + + # Make API request + clean_params = self.api_client.build_filter_params(params) + response = await self.api_client.get("/api/v1/features", params=clean_params) + + # Transform to LLM-friendly format + return FeatureListResponse.from_api_response(response).model_dump() + + async def get_feature( + self, + feature_id: str = Field(description="The UUID of the feature"), + ) -> dict[str, Any]: + """Get detailed information about a specific feature. + + Returns complete feature details including configuration and metadata. + """ + try: + response = await self.api_client.get(f"/api/v1/features/{feature_id}") + return DetailedFeature.from_api_response(response["data"]).model_dump() + except Exception as e: + self.logger.error(f"Failed to get feature {feature_id}: {e}") + return {"error": str(e), "status": "failed"} +``` + +### Step 2: Create the Models + +Create corresponding models in `prowler_app/models/`: + +```python +# prowler_app/models/new_feature.py +from typing import Any + +from pydantic import Field + +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + + +class SimplifiedFeature(MinimalSerializerMixin): + """Lightweight feature for list operations.""" + + id: str = Field(description="Unique feature identifier") + name: str = Field(description="Feature name") + status: str = Field(description="Current status") + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "SimplifiedFeature": + """Transform API response to simplified format.""" + attributes = data.get("attributes", {}) + return cls( + id=data["id"], + name=attributes["name"], + status=attributes["status"], + ) + + +class DetailedFeature(SimplifiedFeature): + """Extended feature with complete details.""" + + description: str | None = Field(default=None, description="Feature description") + configuration: dict[str, Any] | None = Field(default=None, description="Configuration") + created_at: str = Field(description="Creation timestamp") + updated_at: str = Field(description="Last update timestamp") + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> "DetailedFeature": + """Transform API response to detailed format.""" + attributes = data.get("attributes", {}) + return cls( + id=data["id"], + name=attributes["name"], + status=attributes["status"], + description=attributes.get("description"), + configuration=attributes.get("configuration"), + created_at=attributes["created_at"], + updated_at=attributes["updated_at"], + ) + + +class FeatureListResponse(MinimalSerializerMixin): + """Response wrapper for feature list operations.""" + + count: int = Field(description="Total number of features") + features: list[SimplifiedFeature] = Field(description="List of features") + + @classmethod + def from_api_response(cls, response: dict[str, Any]) -> "FeatureListResponse": + """Transform API response to list format.""" + data = response.get("data", []) + features = [SimplifiedFeature.from_api_response(item) for item in data] + return cls(count=len(features), features=features) +``` + +### Step 3: Verify Auto-Discovery + +No manual registration is needed. The `tool_loader.py` automatically discovers and registers all `BaseTool` subclasses. Verify your tool is loaded by checking the server logs: + +``` +INFO - Auto-registered 2 tools from NewFeatureTools +INFO - Loaded and registered: NewFeatureTools +``` + +## Adding Tools to Prowler Hub/Docs + +For Prowler Hub or Documentation tools, use the `@mcp.tool()` decorator directly: + +```python +# prowler_hub/server.py +from fastmcp import FastMCP + +hub_mcp_server = FastMCP("prowler-hub") + +@hub_mcp_server.tool() +async def get_new_artifact( + artifact_id: str, +) -> dict: + """Fetch a specific artifact from Prowler Hub. + + Args: + artifact_id: The unique identifier of the artifact + + Returns: + Dictionary containing artifact details + """ + response = prowler_hub_client.get(f"/artifact/{artifact_id}") + response.raise_for_status() + return response.json() +``` + +## Model Design Patterns + +### MinimalSerializerMixin + +All models should use `MinimalSerializerMixin` to optimize responses for LLM consumption: + +```python +from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin + +class MyModel(MinimalSerializerMixin): + """Model that excludes empty values from serialization.""" + required_field: str + optional_field: str | None = None # Excluded if None + empty_list: list = [] # Excluded if empty +``` + +This mixin automatically excludes: +- `None` values +- Empty strings +- Empty lists +- Empty dictionaries + +### Two-Tier Model Pattern + +Use two-tier models for efficient responses: + +- **Simplified**: Lightweight models for list operations +- **Detailed**: Extended models for single-item retrieval + +```python +class SimplifiedItem(MinimalSerializerMixin): + """Use for list operations - minimal fields.""" + id: str + name: str + status: str + +class DetailedItem(SimplifiedItem): + """Use for get operations - extends simplified with details.""" + description: str | None = None + configuration: dict | None = None + created_at: str + updated_at: str +``` + +### Factory Method Pattern + +Always implement `from_api_response()` for API transformation: + +```python +@classmethod +def from_api_response(cls, data: dict[str, Any]) -> "MyModel": + """Transform API response to model. + + This method handles the JSON:API format used by Prowler API, + extracting attributes and relationships as needed. + """ + attributes = data.get("attributes", {}) + return cls( + id=data["id"], + name=attributes["name"], + # ... map other fields + ) +``` + +## API Client Usage + +The `ProwlerAPIClient` is a singleton that handles authentication and HTTP requests: + +```python +class MyTools(BaseTool): + async def my_tool(self) -> dict: + # GET request + response = await self.api_client.get("/api/v1/endpoint", params={"key": "value"}) + + # POST request + response = await self.api_client.post( + "/api/v1/endpoint", + json_data={"data": {"type": "items", "attributes": {...}}} + ) + + # PATCH request + response = await self.api_client.patch( + f"/api/v1/endpoint/{id}", + json_data={"data": {"attributes": {...}}} + ) + + # DELETE request + response = await self.api_client.delete(f"/api/v1/endpoint/{id}") +``` + +### Helper Methods + +The API client provides useful helper methods: + +```python +# Validate page size (1-1000) +self.api_client.validate_page_size(page_size) + +# Normalize date range with max days limit +date_range = self.api_client.normalize_date_range(date_from, date_to, max_days=2) + +# Build filter parameters (handles type conversion) +clean_params = self.api_client.build_filter_params({ + "filter[status]": "active", + "filter[severity__in]": ["high", "critical"], # Converts to comma-separated + "filter[muted]": True, # Converts to "true" +}) + +# Poll async task until completion +result = await self.api_client.poll_task_until_complete( + task_id=task_id, + timeout=60, + poll_interval=1.0 +) +``` + +## Best Practices + +### Tool Docstrings + +Tool docstrings become description that is going to be read by the LLM. Provide clear usage instructions and common workflows: + +```python +async def search_items(self, status: str = Field(...)) -> dict: + """Search items with advanced filtering. + + Returns a lightweight list optimized for LLM consumption. + Use get_item for complete details about a specific item. + + Common workflows: + - Find critical items: status="critical" + - Find recent items: Use date_from parameter + """ +``` + +### Error Handling + +Return structured error responses instead of raising exceptions: + +```python +async def get_item(self, item_id: str) -> dict: + try: + response = await self.api_client.get(f"/api/v1/items/{item_id}") + return DetailedItem.from_api_response(response["data"]).model_dump() + except Exception as e: + self.logger.error(f"Failed to get item {item_id}: {e}") + return {"error": str(e), "status": "failed"} +``` + +### Parameter Descriptions + +Use Pydantic `Field()` with clear descriptions. This also helps LLMs understand +the purpose of each parameter, so be as descriptive as possible: + +```python +async def list_items( + self, + severity: list[str] = Field( + default=[], + description="Filter by severity levels (critical, high, medium, low)" + ), + status: str | None = Field( + default=None, + description="Filter by status (PASS, FAIL, MANUAL)" + ), + page_size: int = Field( + default=50, + description="Results per page" + ), +) -> dict: +``` + +## Development Commands + +```bash +# Navigate to MCP server directory +cd mcp_server + +# Run in STDIO mode (default) +uv run prowler-mcp + +# Run in HTTP mode +uv run prowler-mcp --transport http --host 0.0.0.0 --port 8000 + +# Run with environment variables +PROWLER_APP_API_KEY="pk_xxx" uv run prowler-mcp +``` + +For complete installation and deployment options, see: +- [Installation Guide](/getting-started/installation/prowler-mcp#from-source-development) - Development setup instructions +- [Configuration Guide](/getting-started/basic-usage/prowler-mcp) - MCP client configuration + +For development I recommend to use the [Model Context Protocol Inspector](https://github.com/modelcontextprotocol/inspector) as MCP client to test and debug your tools. + +## Related Documentation + + + + Key capabilities, use cases, and deployment options + + + Complete reference of all available tools + + + Security checks and compliance frameworks catalog + + + AI-powered security analyst + + + +## Additional Resources + +- [MCP Protocol Specification](https://modelcontextprotocol.io) - Model Context Protocol details +- [Prowler API Documentation](https://api.prowler.com/api/v1/docs) - API reference +- [Prowler Hub API](https://hub.prowler.com/api/docs) - Hub API reference +- [GitHub Repository](https://github.com/prowler-cloud/prowler) - Source code diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index 85dafc6300..867dff14d8 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -220,6 +220,7 @@ The function returns a JSON file containing the list of regions for the provider "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2" ], "aws-cn": ["cn-north-1", "cn-northwest-1"], + "aws-eusc": ["eusc-de-east-1"], "aws-us-gov": ["us-gov-east-1", "us-gov-west-1"] } } diff --git a/docs/developer-guide/unit-testing.mdx b/docs/developer-guide/unit-testing.mdx index 8713988440..ca6d068bd4 100644 --- a/docs/developer-guide/unit-testing.mdx +++ b/docs/developer-guide/unit-testing.mdx @@ -35,6 +35,16 @@ Create tests that generate both a passing (`PASS`) and a failing (`FAIL`) result 3. Multi-Resource Evaluations: Design tests with multiple resources to verify check behavior and ensure the correct number of findings. +## Test File Naming Conventions + +Test files follow the pattern `{service}_{check_name}_test.py` for checks and `{service}_service_test.py` for services. + +### Duplicate Names Across Providers + +When a test file name already exists in another provider, add your provider prefix to avoid conflicts. A GitHub Action will fail if duplicate names are detected. + +**Example:** If `kms_service_test.py` already exists in AWS, name your Oracle Cloud test `oraclecloud_kms_service_test.py`. + ## Running Prowler Tests To execute the Prowler test suite, install the necessary dependencies listed in the `pyproject.toml` file. @@ -63,6 +73,82 @@ Other Commands for Running Tests Refer to the [pytest documentation](https://docs.pytest.org/en/7.1.x/getting-started.html) for more details. + +## AWS Service Dependency Table (CI Optimization) + +To optimize CI pipeline execution time, the GitHub Actions workflow for AWS tests uses a **service dependency table** that determines which tests to run based on changed files. This ensures that when a service is modified, all dependent services are also tested. + +### How It Works + +The dependency table is defined in `.github/workflows/sdk-tests.yml` within the "Resolve AWS services under test" step. When files in a specific AWS service are changed: + +1. Tests for the changed service are run +2. Tests for all services that **depend on** the changed service are also run + +For example, if you modify the `ec2` service, tests will also run for `dlm`, `dms`, `elbv2`, `emr`, `inspector2`, `rds`, `redshift`, `route53`, `shield`, `ssm`, and `workspaces` because these services use the EC2 client. + +### Current Dependency Table + +The table maps a service (key) to the list of services that depend on it (values): + +| Service | Dependent Services | +|---------|-------------------| +| `acm` | `elb` | +| `autoscaling` | `dynamodb` | +| `awslambda` | `ec2`, `inspector2` | +| `backup` | `dynamodb`, `ec2`, `rds` | +| `cloudfront` | `shield` | +| `cloudtrail` | `awslambda`, `cloudwatch` | +| `cloudwatch` | `bedrock` | +| `ec2` | `dlm`, `dms`, `elbv2`, `emr`, `inspector2`, `rds`, `redshift`, `route53`, `shield`, `ssm` | +| `ecr` | `inspector2` | +| `elb` | `shield` | +| `elbv2` | `shield` | +| `globalaccelerator` | `shield` | +| `iam` | `bedrock`, `cloudtrail`, `cloudwatch`, `codebuild` | +| `kafka` | `firehose` | +| `kinesis` | `firehose` | +| `kms` | `kafka` | +| `organizations` | `iam`, `servicecatalog` | +| `route53` | `shield` | +| `s3` | `bedrock`, `cloudfront`, `cloudtrail`, `macie` | +| `ssm` | `ec2` | +| `vpc` | `awslambda`, `ec2`, `efs`, `elasticache`, `neptune`, `networkfirewall`, `rds`, `redshift`, `workspaces` | +| `waf` | `elbv2` | +| `wafv2` | `cognito`, `elbv2` | + +### When to Update the Table + +You must update the dependency table when: + +1. **A new check or service uses another service's client**: If your check imports a client from another service (e.g., `from prowler.providers.aws.services.ec2.ec2_client import ec2_client` in a non-ec2 check), add your service to the dependent services list of that client's service. + +2. **A service relationship changes**: If you remove or add a service client dependency in an existing check, update the table accordingly. + +### How to Update the Table + +1. Open `.github/workflows/sdk-tests.yml` +2. Find the `dependents` dictionary in the "Resolve AWS services under test" step +3. Add or modify entries as needed +4. **Update this documentation page** (`docs/developer-guide/unit-testing.mdx`) to reflect the changes in the [Current Dependency Table](#current-dependency-table) section above + +```python +dependents = { + # ... existing entries ... + "service_being_used": ["service_that_uses_it"], +} +``` + +**Example**: If you create a new check in the `newservice` service that imports `ec2_client`, add `newservice` to the `ec2` entry: + +```python +"ec2": ["dlm", "dms", "elbv2", "emr", "inspector2", "newservice", "rds", "redshift", "route53", "shield", "ssm"], +``` + + +Failing to update this table when adding cross-service dependencies may result in CI tests passing even when related functionality is broken, as the dependent service tests won't be triggered. + + ## AWS Testing Approaches For AWS provider, different testing approaches apply based on API coverage based on several criteria. diff --git a/docs/docs.json b/docs/docs.json index 54402e5d8a..8305388369 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -52,7 +52,7 @@ { "group": "Prowler Lighthouse AI", "pages": [ - "user-guide/tutorials/prowler-app-lighthouse" + "getting-started/products/prowler-lighthouse-ai" ] }, { @@ -99,7 +99,14 @@ }, "user-guide/tutorials/prowler-app-rbac", "user-guide/tutorials/prowler-app-api-keys", - "user-guide/tutorials/prowler-app-mute-findings", + { + "group": "Mutelist", + "expanded": true, + "pages": [ + "user-guide/tutorials/prowler-app-simple-mutelist", + "user-guide/tutorials/prowler-app-mute-findings" + ] + }, { "group": "Integrations", "expanded": true, @@ -109,7 +116,14 @@ "user-guide/tutorials/prowler-app-jira-integration" ] }, - "user-guide/tutorials/prowler-app-lighthouse", + { + "group": "Lighthouse AI", + "pages": [ + "user-guide/tutorials/prowler-app-lighthouse", + "user-guide/tutorials/prowler-app-lighthouse-multi-llm" + ] + }, + "user-guide/tutorials/prowler-cloud-public-ips", { "group": "Tutorials", "pages": [ @@ -191,11 +205,17 @@ "user-guide/providers/gcp/retry-configuration" ] }, + { + "group": "Alibaba Cloud", + "pages": [ + "user-guide/providers/alibabacloud/getting-started-alibabacloud", + "user-guide/providers/alibabacloud/authentication" + ] + }, { "group": "Kubernetes", "pages": [ - "user-guide/providers/kubernetes/in-cluster", - "user-guide/providers/kubernetes/outside-cluster", + "user-guide/providers/kubernetes/getting-started-k8s", "user-guide/providers/kubernetes/misc" ] }, @@ -228,6 +248,19 @@ "user-guide/providers/mongodbatlas/authentication" ] }, + { + "group": "Cloudflare", + "pages": [ + "user-guide/providers/cloudflare/getting-started-cloudflare", + "user-guide/providers/cloudflare/authentication" + ] + }, + { + "group": "Image", + "pages": [ + "user-guide/providers/image/getting-started-image" + ] + }, { "group": "LLM", "pages": [ @@ -240,6 +273,13 @@ "user-guide/providers/oci/getting-started-oci", "user-guide/providers/oci/authentication" ] + }, + { + "group": "OpenStack", + "pages": [ + "user-guide/providers/openstack/getting-started-openstack", + "user-guide/providers/openstack/authentication" + ] } ] }, @@ -264,7 +304,9 @@ "developer-guide/outputs", "developer-guide/integrations", "developer-guide/security-compliance-framework", - "developer-guide/lighthouse" + "developer-guide/lighthouse-architecture", + "developer-guide/mcp-server", + "developer-guide/ai-skills" ] }, { @@ -273,6 +315,7 @@ "developer-guide/aws-details", "developer-guide/azure-details", "developer-guide/gcp-details", + "developer-guide/alibabacloud-details", "developer-guide/kubernetes-details", "developer-guide/m365-details", "developer-guide/github-details", @@ -287,7 +330,8 @@ "group": "Testing", "pages": [ "developer-guide/unit-testing", - "developer-guide/integration-testing" + "developer-guide/integration-testing", + "developer-guide/end2end-testing" ] }, "developer-guide/debugging", @@ -300,14 +344,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" ] }, { @@ -428,6 +486,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/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index dc984c9537..bf0092a069 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool |----------|------------|------------------------| | Prowler Hub | 10 tools | No | | Prowler Documentation | 2 tools | No | -| Prowler Cloud/App | 28 tools | Yes | +| Prowler Cloud/App | 24 tools | Yes | ## Tool Naming Convention @@ -20,39 +20,6 @@ All tools follow a consistent naming pattern with prefixes: - `prowler_docs_*` - Prowler documentation search and retrieval - `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools -## Prowler Hub Tools - -Access Prowler's security check catalog and compliance frameworks. **No authentication required.** - -### Check Discovery - -- **`prowler_hub_get_checks`** - List security checks with advanced filtering options -- **`prowler_hub_get_check_filters`** - Return available filter values for checks (providers, services, severities, categories, compliances) -- **`prowler_hub_search_checks`** - Full-text search across check metadata -- **`prowler_hub_get_check_raw_metadata`** - Fetch raw check metadata in JSON format - -### Check Code - -- **`prowler_hub_get_check_code`** - Fetch the Python implementation code for a security check -- **`prowler_hub_get_check_fixer`** - Fetch the automated fixer code for a check (if available) - -### Compliance Frameworks - -- **`prowler_hub_get_compliance_frameworks`** - List and filter compliance frameworks -- **`prowler_hub_search_compliance_frameworks`** - Full-text search across compliance frameworks - -### Provider Information - -- **`prowler_hub_list_providers`** - List Prowler official providers and their services -- **`prowler_hub_get_artifacts_count`** - Get total count of checks and frameworks in Prowler Hub - -## Prowler Documentation Tools - -Search and access official Prowler documentation. **No authentication required.** - -- **`prowler_docs_search`** - Search the official Prowler documentation using full-text search -- **`prowler_docs_get_document`** - Retrieve the full markdown content of a specific documentation file - ## Prowler Cloud/App Tools Manage Prowler Cloud or Prowler App (Self-Managed) features. **Requires authentication.** @@ -63,49 +30,97 @@ These tools require a valid API key. See the [Configuration Guide](/getting-star ### Findings Management -- **`prowler_app_list_findings`** - List security findings with advanced filtering -- **`prowler_app_get_finding`** - Get detailed information about a specific finding -- **`prowler_app_get_latest_findings`** - Retrieve latest findings from the most recent scans -- **`prowler_app_get_findings_metadata`** - Get unique metadata values from filtered findings -- **`prowler_app_get_latest_findings_metadata`** - Get metadata from latest findings across all providers +Tools for searching, viewing, and analyzing security findings across all cloud providers. + +- **`prowler_app_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status) +- **`prowler_app_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships +- **`prowler_app_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report ### Provider Management -- **`prowler_app_list_providers`** - List all providers with filtering options -- **`prowler_app_create_provider`** - Create a new provider in the current tenant -- **`prowler_app_get_provider`** - Get detailed information about a specific provider -- **`prowler_app_update_provider`** - Update provider details (alias, etc.) -- **`prowler_app_delete_provider`** - Delete a specific provider -- **`prowler_app_test_provider_connection`** - Test provider connection status +Tools for managing cloud provider connections in Prowler. -### Provider Secrets Management - -- **`prowler_app_list_provider_secrets`** - List all provider secrets with filtering -- **`prowler_app_add_provider_secret`** - Add or update credentials for a provider -- **`prowler_app_get_provider_secret`** - Get detailed information about a provider secret -- **`prowler_app_update_provider_secret`** - Update provider secret details -- **`prowler_app_delete_provider_secret`** - Delete a provider secret +- **`prowler_app_search_providers`** - Search and view configured providers with their connection status +- **`prowler_app_connect_provider`** - Register and connect a provider with credentials for security scanning +- **`prowler_app_delete_provider`** - Permanently remove a provider from Prowler ### Scan Management -- **`prowler_app_list_scans`** - List all scans with filtering options -- **`prowler_app_create_scan`** - Trigger a manual scan for a specific provider -- **`prowler_app_get_scan`** - Get detailed information about a specific scan -- **`prowler_app_update_scan`** - Update scan details -- **`prowler_app_get_scan_compliance_report`** - Download compliance report as CSV -- **`prowler_app_get_scan_report`** - Download ZIP file containing complete scan report +Tools for managing and monitoring security scans. -### Schedule Management +- **`prowler_app_list_scans`** - List and filter security scans across all providers +- **`prowler_app_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts) +- **`prowler_app_trigger_scan`** - Trigger a manual security scan for a provider +- **`prowler_app_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring +- **`prowler_app_update_scan`** - Update scan name for better organization -- **`prowler_app_schedules_daily_scan`** - Create a daily scheduled scan for a provider +### Resources Management -### Processor Management +Tools for searching, viewing, and analyzing cloud resources discovered by Prowler. -- **`prowler_app_processors_list`** - List all processors with filtering -- **`prowler_app_processors_create`** - Create a new processor (currently only mute lists supported) -- **`prowler_app_processors_retrieve`** - Get processor details by ID -- **`prowler_app_processors_partial_update`** - Update processor configuration -- **`prowler_app_processors_destroy`** - Delete a processor +- **`prowler_app_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags) +- **`prowler_app_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships +- **`prowler_app_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report + +### Muting Management + +Tools for managing finding muting, including pattern-based bulk muting (mutelist) and finding-specific mute rules. + +#### Mutelist (Pattern-Based Muting) + +- **`prowler_app_get_mutelist`** - Retrieve the current mutelist configuration for the tenant +- **`prowler_app_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting +- **`prowler_app_delete_mutelist`** - Remove the mutelist configuration from the tenant + +#### Mute Rules (Finding-Specific Muting) + +- **`prowler_app_list_mute_rules`** - Search and filter mute rules with pagination support +- **`prowler_app_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule +- **`prowler_app_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail +- **`prowler_app_update_mute_rule`** - Update a mute rule's name, reason, or enabled status +- **`prowler_app_delete_mute_rule`** - Delete a mute rule from the system + +### Compliance Management + +Tools for viewing compliance status and framework details across all cloud providers. + +- **`prowler_app_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework +- **`prowler_app_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs + +## Prowler Hub Tools + +Access Prowler's security check catalog and compliance frameworks. **No authentication required.** + +Tools follow a **two-tier pattern**: lightweight listing for browsing + detailed retrieval for complete information. + +### Check Discovery and Details + +- **`prowler_hub_list_checks`** - List security checks with lightweight data (id, title, severity, provider) and advanced filtering options +- **`prowler_hub_semantic_search_checks`** - Full-text search across check metadata with lightweight results +- **`prowler_hub_get_check_details`** - Get comprehensive details for a specific check including risk, remediation guidance, and compliance mappings + +### Check Code + +- **`prowler_hub_get_check_code`** - Fetch the Python implementation code for a security check +- **`prowler_hub_get_check_fixer`** - Fetch the automated fixer code for a check (if available) + +### Compliance Frameworks + +- **`prowler_hub_list_compliances`** - List compliance frameworks with lightweight data (id, name, provider) and filtering options +- **`prowler_hub_semantic_search_compliances`** - Full-text search across compliance frameworks with lightweight results +- **`prowler_hub_get_compliance_details`** - Get comprehensive compliance details including requirements and mapped checks + +### Providers Information + +- **`prowler_hub_list_providers`** - List Prowler official providers +- **`prowler_hub_get_provider_services`** - Get available services for a specific provider + +## Prowler Documentation Tools + +Search and access official Prowler documentation. **No authentication required.** + +- **`prowler_docs_search`** - Search the official Prowler documentation using full-text search with the `term` parameter +- **`prowler_docs_get_document`** - Retrieve the full markdown content of a specific documentation file using the path from search results ## Usage Tips diff --git a/docs/getting-started/basic-usage/prowler-mcp.mdx b/docs/getting-started/basic-usage/prowler-mcp.mdx index b6d59093e7..a9357dcdeb 100644 --- a/docs/getting-started/basic-usage/prowler-mcp.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp.mdx @@ -139,7 +139,7 @@ STDIO mode is only available when running the MCP server locally. "args": ["/absolute/path/to/prowler/mcp_server/"], "env": { "PROWLER_APP_API_KEY": "", - "PROWLER_API_BASE_URL": "https://api.prowler.com" + "API_BASE_URL": "https://api.prowler.com/api/v1" } } } @@ -147,7 +147,7 @@ STDIO mode is only available when running the MCP server locally. ``` - Replace `/absolute/path/to/prowler/mcp_server/` with the actual path. The `PROWLER_API_BASE_URL` is optional and defaults to Prowler Cloud API. + Replace `/absolute/path/to/prowler/mcp_server/` with the actual path. The `API_BASE_URL` is optional and defaults to Prowler Cloud API. @@ -167,7 +167,7 @@ STDIO mode is only available when running the MCP server locally. "--env", "PROWLER_APP_API_KEY=", "--env", - "PROWLER_API_BASE_URL=https://api.prowler.com", + "API_BASE_URL=https://api.prowler.com/api/v1", "prowlercloud/prowler-mcp" ] } @@ -176,7 +176,7 @@ STDIO mode is only available when running the MCP server locally. ``` - The `PROWLER_API_BASE_URL` is optional and defaults to Prowler Cloud API. + The `API_BASE_URL` is optional and defaults to Prowler Cloud API. diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 4c49b8d4f0..8b5382f156 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -1,49 +1,46 @@ --- -title: 'Installation' +title: "Installation" --- ### Installation -Prowler App supports multiple installation methods based on your environment. +Prowler App offers flexible installation methods tailored to various environments. Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detailed usage instructions. -Prowler configuration is based in `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag. - + Prowler configuration is based on `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag. + _Requirements_: - * `Docker Compose` installed: https://docs.docker.com/compose/install/. + - `Docker Compose` installed: https://docs.docker.com/compose/install/. _Commands_: - ``` bash - curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/docker-compose.yml - curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/.env + ```bash + VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name) + curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" + curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/.env" docker compose up -d ``` - - > Containers are built for `linux/amd64`. If your workstation's architecture is different, please set `DOCKER_DEFAULT_PLATFORM=linux/amd64` in your environment or use the `--platform linux/amd64` flag in the docker command. - _Requirements_: - * `git` installed. - * `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation). - * `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). - * `Docker Compose` installed: https://docs.docker.com/compose/install/. + - `git` installed. + - `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation). + - `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). + - `Docker Compose` installed: https://docs.docker.com/compose/install/. - Make sure to have `api/.env` and `ui/.env.local` files with the required environment variables. You can find the required environment variables in the [`api/.env.template`](https://github.com/prowler-cloud/prowler/blob/master/api/.env.example) and [`ui/.env.template`](https://github.com/prowler-cloud/prowler/blob/master/ui/.env.template) files. + Make sure to have `api/.env` and `ui/.env.local` files with the required environment variables. You can find the required environment variables in the [`api/.env.template`](https://github.com/prowler-cloud/prowler/blob/master/api/.env.example) and [`ui/.env.template`](https://github.com/prowler-cloud/prowler/blob/master/ui/.env.template) files. - _Commands to run the API_: - ``` bash + ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/api \ poetry install \ @@ -57,17 +54,15 @@ Prowler configuration is based in `.env` files. Every version of Prowler can hav ``` - Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`. + Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`. - If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment. - In case you have any doubts, consult the Poetry environment activation guide: https://python-poetry.org/docs/managing-environments/#activating-the-environment + If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment. In case you have any doubts, consult the Poetry environment activation guide: https://python-poetry.org/docs/managing-environments/#activating-the-environment - > Now, you can access the API documentation at http://localhost:8080/api/v1/docs. _Commands to run the API Worker_: - ``` bash + ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/api \ poetry install \ @@ -80,7 +75,7 @@ Prowler configuration is based in `.env` files. Every version of Prowler can hav _Commands to run the API Scheduler_: - ``` bash + ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/api \ poetry install \ @@ -93,7 +88,7 @@ Prowler configuration is based in `.env` files. Every version of Prowler can hav _Commands to run the UI_: - ``` bash + ```bash git clone https://github.com/prowler-cloud/prowler \ cd prowler/ui \ npm install \ @@ -104,24 +99,32 @@ Prowler configuration is based in `.env` files. Every version of Prowler can hav > Enjoy Prowler App at http://localhost:3000 by signing up with your email and password. - Google and GitHub authentication is only available in [Prowler Cloud](https://prowler.com). + Google and GitHub authentication is only available in [Prowler Cloud](https://prowler.com). -### Update Prowler App + +### Updating Prowler App Upgrade Prowler App installation using one of two options: -#### Option 1: Update Environment File +#### Option 1: Updating the Environment File + +To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.9.0" -PROWLER_API_VERSION="5.9.0" +PROWLER_UI_VERSION="5.18.0" +PROWLER_API_VERSION="5.18.0" ``` -#### Option 2: Use Docker Compose Pull + + You can find the latest versions of Prowler App in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation. + + + +#### Option 2: Using Docker Compose Pull ```bash docker compose pull --policy always @@ -129,14 +132,13 @@ docker compose pull --policy always The `--policy always` flag ensures that Docker pulls the latest images even if they already exist locally. - -**What Gets Preserved During Upgrade** - -Everything is preserved, nothing will be deleted after the update. + **What Gets Preserved During Upgrade** + Everything is preserved, nothing will be deleted after the update. -### Troubleshooting + +### Troubleshooting Installation Issues If containers don't start, check logs for errors: @@ -148,17 +150,16 @@ docker compose logs docker images | grep prowler ``` -If you encounter issues, you can rollback to the previous version by changing the `.env` file back to your previous version and running: +If issues are encountered, rollback to the previous version by changing the `.env` file back to the previous version and running: ```bash docker compose pull docker compose up -d ``` +### Container Versions -### Container versions - -The available versions of Prowler CLI are the following: +The available versions of Prowler App are the following: - `latest`: in sync with `master` branch (please note that it is not a stable version) - `v4-latest`: in sync with `v4` branch (please note that it is not a stable version) @@ -171,6 +172,5 @@ The available versions of Prowler CLI are the following: The container images are available here: - Prowler App: - - - [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags) - - [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags) + - [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags) + - [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags) diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx index a3c87c8ce7..8892aaa1d2 100644 --- a/docs/getting-started/installation/prowler-cli.mdx +++ b/docs/getting-started/installation/prowler-cli.mdx @@ -4,7 +4,7 @@ title: 'Installation' ## Installation -Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). Install it as a Python package with `Python >= 3.9, <= 3.12`: +To install Prowler as a Python package, use `Python >= 3.9, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): @@ -41,7 +41,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). prowler -v ``` - Upgrade Prowler to the latest version: + To upgrade Prowler to the latest version: ``` bash pip install --upgrade prowler @@ -54,8 +54,6 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). * In the command below, change `-v` to your local directory path in order to access the reports. * AWS, GCP, Azure and/or Kubernetes credentials - > Containers are built for `linux/amd64`. If your workstation's architecture is different, please set `DOCKER_DEFAULT_PLATFORM=linux/amd64` in your environment or use the `--platform linux/amd64` flag in the docker command. - _Commands_: ``` bash @@ -75,7 +73,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). _Commands_: - ``` + ```bash git clone https://github.com/prowler-cloud/prowler cd prowler poetry install @@ -94,7 +92,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). _Commands_: - ``` + ```bash python3 -m pip install --user pipx python3 -m pipx ensurepath pipx install prowler @@ -104,7 +102,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). _Requirements_: - * `Ubuntu 23.04` or above, if you are using an older version of Ubuntu check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure you have `Python >= 3.9, <= 3.12`. + * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.9, <= 3.12` is installed. * `Python >= 3.9, <= 3.12` * AWS, GCP, Azure and/or Kubernetes credentials @@ -121,7 +119,7 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). _Requirements_: - * `Brew` installed in your Mac or Linux + * `Brew` installed on Mac or Linux * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: @@ -171,7 +169,8 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/). ``` -## Container versions + +## Container Versions The available versions of Prowler CLI are the following: diff --git a/docs/getting-started/installation/prowler-mcp.mdx b/docs/getting-started/installation/prowler-mcp.mdx index 8c3fd2f955..7bf766e143 100644 --- a/docs/getting-started/installation/prowler-mcp.mdx +++ b/docs/getting-started/installation/prowler-mcp.mdx @@ -52,7 +52,7 @@ Choose one of the following installation methods: ```bash docker run --rm -i \ -e PROWLER_APP_API_KEY="pk_your_api_key" \ - -e PROWLER_API_BASE_URL="https://api.prowler.com" \ + -e API_BASE_URL="https://api.prowler.com/api/v1" \ prowlercloud/prowler-mcp ``` @@ -181,19 +181,19 @@ Configure the server using environment variables: | Variable | Description | Required | Default | |----------|-------------|----------|---------| | `PROWLER_APP_API_KEY` | Prowler API key | Only for STDIO mode | - | -| `PROWLER_API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com` | +| `API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com/api/v1` | | `PROWLER_MCP_TRANSPORT_MODE` | Default transport mode (overwritten by `--transport` argument) | No | `stdio` | ```bash macOS/Linux export PROWLER_APP_API_KEY="pk_your_api_key_here" -export PROWLER_API_BASE_URL="https://api.prowler.com" +export API_BASE_URL="https://api.prowler.com/api/v1" export PROWLER_MCP_TRANSPORT_MODE="http" ``` ```bash Windows PowerShell $env:PROWLER_APP_API_KEY="pk_your_api_key_here" -$env:PROWLER_API_BASE_URL="https://api.prowler.com" +$env:API_BASE_URL="https://api.prowler.com/api/v1" $env:PROWLER_MCP_TRANSPORT_MODE="http" ``` @@ -208,7 +208,7 @@ For convenience, create a `.env` file in the `mcp_server` directory: ```bash .env PROWLER_APP_API_KEY=pk_your_api_key_here -PROWLER_API_BASE_URL=https://api.prowler.com +API_BASE_URL=https://api.prowler.com/api/v1 PROWLER_MCP_TRANSPORT_MODE=stdio ``` diff --git a/docs/getting-started/products/prowler-hub.mdx b/docs/getting-started/products/prowler-hub.mdx index a8dde7549f..b84d48b7d5 100644 --- a/docs/getting-started/products/prowler-hub.mdx +++ b/docs/getting-started/products/prowler-hub.mdx @@ -6,7 +6,7 @@ title: "Overview" **Why this matters**: Every engineer has asked, “What does this check actually do?” Prowler Hub answers that question in one place, lets you pin to a specific version, and pulls definitions into your own tools or dashboards. -![](/images/products/prowler-hub.webp) +![](/images/products/prowler-hub.png) @@ -14,4 +14,4 @@ Prowler Hub also provides a fully documented public API that you can integrate i 📚 Explore the API docs at: https://hub.prowler.com/api/docs -Whether you’re customizing policies, managing compliance, or enhancing visibility, Prowler Hub is built to support your security operations. \ No newline at end of file +Whether you’re customizing policies, managing compliance, or enhancing visibility, Prowler Hub is built to support your security operations. diff --git a/docs/getting-started/products/prowler-lighthouse-ai.mdx b/docs/getting-started/products/prowler-lighthouse-ai.mdx new file mode 100644 index 0000000000..6f197ae546 --- /dev/null +++ b/docs/getting-started/products/prowler-lighthouse-ai.mdx @@ -0,0 +1,108 @@ +--- +title: 'Overview' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler Lighthouse AI is a Cloud Security Analyst chatbot that helps you understand, prioritize, and remediate security findings in your cloud environments. It's designed to provide security expertise for teams without dedicated resources, acting as your 24/7 virtual cloud security analyst. + +Prowler Lighthouse + + + Learn how to configure Lighthouse AI with your preferred LLM provider + + +## Capabilities + +Prowler Lighthouse AI is designed to be your AI security team member, with capabilities including: + +### Natural Language Querying + +Ask questions in plain English about your security findings. Examples: + +- "What are my highest risk findings?" +- "Show me all S3 buckets with public access." +- "What security issues were found in my production accounts?" + +Natural language querying + +### Detailed Remediation Guidance + +Get tailored step-by-step instructions for fixing security issues: + +- Clear explanations of the problem and its impact +- Commands or console steps to implement fixes +- Alternative approaches with different solutions + +Detailed Remediation + +### Enhanced Context and Analysis + +Lighthouse AI can provide additional context to help you understand the findings: + +- Explain security concepts related to findings in simple terms +- Provide risk assessments based on your environment and context +- Connect related findings to show broader security patterns + +Business Context + +Contextual Responses + +## Important Notes + +Prowler Lighthouse AI is powerful, but there are limitations: + +- **Continuous improvement**: Please report any issues, as the feature may make mistakes or encounter errors, despite extensive testing. +- **Access limitations**: Lighthouse AI can only access data the logged-in user can view. If you can't see certain information, Lighthouse AI can't see it either. +- **NextJS session dependence**: If your Prowler application session expires or logs out, Lighthouse AI will error out. Refresh and log back in to continue. +- **Response quality**: The response quality depends on the selected LLM provider and model. Choose models with strong tool-calling capabilities for best results. We recommend `gpt-5` model from OpenAI. + +## Extending Lighthouse AI + +Lighthouse AI retrieves data through Prowler MCP. To add new capabilities, extend the Prowler MCP Server with additional tools and Lighthouse AI discovers them automatically. + +For development details, see: +- [Lighthouse AI Architecture](/developer-guide/lighthouse-architecture) - Internal architecture and extension points +- [Extending the MCP Server](/developer-guide/mcp-server) - Adding new tools to Prowler MCP + +### Getting Help + +If you encounter issues with Prowler Lighthouse AI or have suggestions for improvements, please [reach out through our Slack channel](https://goto.prowler.com/slack). + +### What Data Is Shared to LLM Providers? + +The following API endpoints are accessible to Prowler Lighthouse AI. Data from the following API endpoints could be shared with LLM provider depending on the scope of user's query: + +## FAQs + +**1. Which LLM providers are supported?** + +Lighthouse AI supports three providers: + +- **OpenAI** - GPT models (GPT-5, GPT-4o, etc.) +- **Amazon Bedrock** - Claude, Llama, Titan, and other models via AWS +- **OpenAI Compatible** - Custom endpoints like OpenRouter, Ollama, or any OpenAI-compatible service + +For detailed configuration instructions, see [Using Multiple LLM Providers with Lighthouse](/user-guide/tutorials/prowler-app-lighthouse-multi-llm). + +**2. Why some models don't appear in Lighthouse AI?** + +LLM providers offer different types of models. Not every model can be integrated with Lighthouse AI (for example, text-to-speech, vision, embedding, computer use, etc.). + +Lighthouse AI requires models that support: + +- Text input +- Text output +- Tool calling + +Lighthouse AI [automatically filters](https://github.com/prowler-cloud/prowler/blob/master/api/src/backend/tasks/jobs/lighthouse_providers.py#L341-L353) out models that do not support these capabilities, so some provider models may not appear in the Lighthouse AI model list. + +**3. Is my security data shared with LLM providers?** + +Minimal data is shared to generate useful responses. Agent can access security findings and remediation details when needed. Provider secrets are protected by design and cannot be read. The LLM provider credentials configured with Lighthouse AI are only accessible to the Next.js server and are never sent to the LLM providers. Resource metadata (names, tags, account/project IDs, etc.) may be shared with the configured LLM provider based on query requirements. + +**4. Can the Lighthouse AI change my cloud environment?** + +No. The agent doesn't have the tools to make the changes, even if the configured cloud provider API keys contain permissions to modify resources. diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index 40d627007c..df402f605b 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -5,7 +5,7 @@ title: "Overview" **Prowler MCP Server** brings the entire Prowler ecosystem to AI assistants through the Model Context Protocol (MCP). It enables seamless integration with AI tools like Claude Desktop, Cursor, and other MCP clients, allowing interaction with Prowler's security capabilities through natural language. -**Preview Feature**: This MCP server is currently in preview and under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts. +**Preview Feature**: This MCP server is currently under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts. ## What is the Model Context Protocol? @@ -19,12 +19,11 @@ The Prowler MCP Server provides three main integration points: ### 1. Prowler Cloud and Prowler App (Self-Managed) Full access to Prowler Cloud platform and self-managed Prowler App for: -- **Provider Management**: Create, configure, and manage cloud providers (AWS, Azure, GCP, etc.). -- **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments. -- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments. -- **Compliance Reporting**: Generate compliance reports for various frameworks (CIS, PCI-DSS, HIPAA, etc.). -- **Secrets Management**: Securely manage provider credentials and connection details. -- **Processor Configuration**: Set up the [Prowler Mutelist](/user-guide/tutorials/prowler-app-mute-findings) to mute findings. +- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments +- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.) +- **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments +- **Resource Inventory**: Search and view detailed information about your audited resources +- **Muting Management**: Create and manage muting lists/rules to suppress non-relevant findings ### 2. Prowler Hub @@ -42,18 +41,30 @@ Search and retrieve official Prowler documentation: - **Contextual Results**: Get relevant documentation pages with highlighted snippets. - **Document Retrieval**: Access complete markdown content of any documentation file. +## MCP Server Architecture + +The following diagram illustrates the Prowler MCP Server architecture and its integration points: + +Prowler MCP Server Schema +Prowler MCP Server Schema + +The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components: +- Prowler Cloud/App for security operations +- Prowler Hub for security knowledge +- Prowler Documentation for guidance and reference. + ## Use Cases The Prowler MCP Server enables powerful workflows through AI assistants: **Security Operations** - "Show me all critical findings from my AWS production accounts" -- "What is my compliance status for the PCI standards accross all my AWS accounts according to the latest Prowler scan results?" -- "Register my new AWS account in Prowler and run an scheduled scan every day" +- "Register my new AWS account in Prowler and run a scheduled scan every day" +- "List all muted findings and detect what findgings are muted by a not enough good reason in relation to their severity" **Security Research** -- "Explain what the S3 bucket public access check does" -- "Find all checks related to encryption at rest" +- "Explain what the S3 bucket public access Prowler check does" +- "Find all Prowler checks related to encryption at rest" - "What is the latest version of the CIS that Prowler is covering per provider?" **Documentation & Learning** diff --git a/docs/images/cli/add-cloud-provider.png b/docs/images/cli/add-cloud-provider.png index dd42e73047..d8f19b2054 100644 Binary files a/docs/images/cli/add-cloud-provider.png and b/docs/images/cli/add-cloud-provider.png differ diff --git a/docs/images/cli/cloud-providers-page.png b/docs/images/cli/cloud-providers-page.png index 0581e0132f..dcbce73a10 100644 Binary files a/docs/images/cli/cloud-providers-page.png and b/docs/images/cli/cloud-providers-page.png differ diff --git a/docs/images/cli/lighthouse-architecture.png b/docs/images/cli/lighthouse-architecture.png deleted file mode 100644 index 63202ce7c7..0000000000 Binary files a/docs/images/cli/lighthouse-architecture.png and /dev/null differ diff --git a/docs/images/cli/rbac/membership.png b/docs/images/cli/rbac/membership.png deleted file mode 100644 index e2b96e40f5..0000000000 Binary files a/docs/images/cli/rbac/membership.png and /dev/null differ diff --git a/docs/images/lighthouse-architecture-dark.png b/docs/images/lighthouse-architecture-dark.png new file mode 100644 index 0000000000..0ed77712c5 Binary files /dev/null and b/docs/images/lighthouse-architecture-dark.png differ diff --git a/docs/images/lighthouse-architecture-light.png b/docs/images/lighthouse-architecture-light.png new file mode 100644 index 0000000000..076240ccb2 Binary files /dev/null and b/docs/images/lighthouse-architecture-light.png differ diff --git a/docs/images/products/overview.png b/docs/images/products/overview.png index 724ffc9588..697e1f74dc 100644 Binary files a/docs/images/products/overview.png and b/docs/images/products/overview.png differ diff --git a/docs/images/products/prowler-hub.png b/docs/images/products/prowler-hub.png new file mode 100644 index 0000000000..149d0142ca Binary files /dev/null and b/docs/images/products/prowler-hub.png differ diff --git a/docs/images/products/prowler-hub.webp b/docs/images/products/prowler-hub.webp deleted file mode 100644 index b489ff9012..0000000000 Binary files a/docs/images/products/prowler-hub.webp and /dev/null differ diff --git a/docs/images/products/risk-pipeline.png b/docs/images/products/risk-pipeline.png new file mode 100644 index 0000000000..43dae07470 Binary files /dev/null and b/docs/images/products/risk-pipeline.png differ diff --git a/docs/images/products/threat-map.png b/docs/images/products/threat-map.png new file mode 100644 index 0000000000..7a4a276473 Binary files /dev/null and b/docs/images/products/threat-map.png differ diff --git a/docs/images/providers/add-alibaba-account-id.png b/docs/images/providers/add-alibaba-account-id.png new file mode 100644 index 0000000000..2e49f995cc Binary files /dev/null and b/docs/images/providers/add-alibaba-account-id.png differ diff --git a/docs/images/providers/add-iac-repo.png b/docs/images/providers/add-iac-repo.png new file mode 100644 index 0000000000..31981e7fd1 Binary files /dev/null and b/docs/images/providers/add-iac-repo.png differ diff --git a/docs/images/providers/alibaba-account-id.png b/docs/images/providers/alibaba-account-id.png new file mode 100644 index 0000000000..89e79b1f20 Binary files /dev/null and b/docs/images/providers/alibaba-account-id.png differ diff --git a/docs/images/providers/alibaba-connect-via-credentials-static.png b/docs/images/providers/alibaba-connect-via-credentials-static.png new file mode 100644 index 0000000000..f9b9a00ee6 Binary files /dev/null and b/docs/images/providers/alibaba-connect-via-credentials-static.png differ diff --git a/docs/images/providers/alibaba-connect-via-credentials.png b/docs/images/providers/alibaba-connect-via-credentials.png new file mode 100644 index 0000000000..f9b9a00ee6 Binary files /dev/null and b/docs/images/providers/alibaba-connect-via-credentials.png differ diff --git a/docs/images/providers/alibaba-credentials-form.png b/docs/images/providers/alibaba-credentials-form.png new file mode 100644 index 0000000000..3cefc0253c Binary files /dev/null and b/docs/images/providers/alibaba-credentials-form.png differ diff --git a/docs/images/providers/alibaba-get-role-arn.png b/docs/images/providers/alibaba-get-role-arn.png new file mode 100644 index 0000000000..d95822a18e Binary files /dev/null and b/docs/images/providers/alibaba-get-role-arn.png differ diff --git a/docs/images/providers/alibaba-ram-role-overview.png b/docs/images/providers/alibaba-ram-role-overview.png new file mode 100644 index 0000000000..a9420d0749 Binary files /dev/null and b/docs/images/providers/alibaba-ram-role-overview.png differ diff --git a/docs/images/providers/grant-admin-consent.png b/docs/images/providers/grant-admin-consent.png index 0b242308f9..b080cffb3e 100644 Binary files a/docs/images/providers/grant-admin-consent.png and b/docs/images/providers/grant-admin-consent.png differ diff --git a/docs/images/providers/granted-admin-consent.png b/docs/images/providers/granted-admin-consent.png new file mode 100644 index 0000000000..ceafbbd675 Binary files /dev/null and b/docs/images/providers/granted-admin-consent.png differ diff --git a/docs/images/providers/iac-authentication.png b/docs/images/providers/iac-authentication.png new file mode 100644 index 0000000000..c6caad4ae1 Binary files /dev/null and b/docs/images/providers/iac-authentication.png differ diff --git a/docs/images/providers/iac-verify-connection.png b/docs/images/providers/iac-verify-connection.png new file mode 100644 index 0000000000..bc918f6c24 Binary files /dev/null and b/docs/images/providers/iac-verify-connection.png differ diff --git a/docs/images/providers/launch-scan-alibaba.png b/docs/images/providers/launch-scan-alibaba.png new file mode 100644 index 0000000000..324e1334c4 Binary files /dev/null and b/docs/images/providers/launch-scan-alibaba.png differ diff --git a/docs/images/providers/select-alibaba-cloud.png b/docs/images/providers/select-alibaba-cloud.png new file mode 100644 index 0000000000..8b65931472 Binary files /dev/null and b/docs/images/providers/select-alibaba-cloud.png differ diff --git a/docs/images/providers/select-auth-method-alibaba.png b/docs/images/providers/select-auth-method-alibaba.png new file mode 100644 index 0000000000..ffd0083bf0 Binary files /dev/null and b/docs/images/providers/select-auth-method-alibaba.png differ diff --git a/docs/images/providers/select-iac.png b/docs/images/providers/select-iac.png new file mode 100644 index 0000000000..1dc474cbe8 Binary files /dev/null and b/docs/images/providers/select-iac.png differ diff --git a/docs/images/prowler-app/add-cloud-provider.png b/docs/images/prowler-app/add-cloud-provider.png index dd42e73047..d8f19b2054 100644 Binary files a/docs/images/prowler-app/add-cloud-provider.png and b/docs/images/prowler-app/add-cloud-provider.png differ diff --git a/docs/images/prowler-app/cloud-providers-page.png b/docs/images/prowler-app/cloud-providers-page.png index 0581e0132f..dcbce73a10 100644 Binary files a/docs/images/prowler-app/cloud-providers-page.png and b/docs/images/prowler-app/cloud-providers-page.png differ diff --git a/docs/images/prowler-app/lighthouse-architecture.png b/docs/images/prowler-app/lighthouse-architecture.png deleted file mode 100644 index 63202ce7c7..0000000000 Binary files a/docs/images/prowler-app/lighthouse-architecture.png and /dev/null differ diff --git a/docs/images/prowler-app/lighthouse-config.png b/docs/images/prowler-app/lighthouse-config.png index 8bae46f51b..e6a1dd1691 100644 Binary files a/docs/images/prowler-app/lighthouse-config.png and b/docs/images/prowler-app/lighthouse-config.png differ diff --git a/docs/images/prowler-app/lighthouse-configuration.png b/docs/images/prowler-app/lighthouse-configuration.png new file mode 100644 index 0000000000..1255ad0bd7 Binary files /dev/null and b/docs/images/prowler-app/lighthouse-configuration.png differ diff --git a/docs/images/prowler-app/lighthouse-feature1.png b/docs/images/prowler-app/lighthouse-feature1.png index 4568f5752a..1ae7659119 100644 Binary files a/docs/images/prowler-app/lighthouse-feature1.png and b/docs/images/prowler-app/lighthouse-feature1.png differ diff --git a/docs/images/prowler-app/lighthouse-feature2.png b/docs/images/prowler-app/lighthouse-feature2.png index 01e72bbaf4..b956eb43a3 100644 Binary files a/docs/images/prowler-app/lighthouse-feature2.png and b/docs/images/prowler-app/lighthouse-feature2.png differ diff --git a/docs/images/prowler-app/lighthouse-feature3.png b/docs/images/prowler-app/lighthouse-feature3.png index a40177cf18..4cd013e36a 100644 Binary files a/docs/images/prowler-app/lighthouse-feature3.png and b/docs/images/prowler-app/lighthouse-feature3.png differ diff --git a/docs/images/prowler-app/lighthouse-intro.png b/docs/images/prowler-app/lighthouse-intro.png index 38d3f0819c..a9b0f74a96 100644 Binary files a/docs/images/prowler-app/lighthouse-intro.png and b/docs/images/prowler-app/lighthouse-intro.png differ diff --git a/docs/images/prowler-app/lighthouse-set-default-provider.png b/docs/images/prowler-app/lighthouse-set-default-provider.png new file mode 100644 index 0000000000..771505e870 Binary files /dev/null and b/docs/images/prowler-app/lighthouse-set-default-provider.png differ diff --git a/docs/images/prowler-app/lighthouse-switch-models.png b/docs/images/prowler-app/lighthouse-switch-models.png new file mode 100644 index 0000000000..a2a3cb9821 Binary files /dev/null and b/docs/images/prowler-app/lighthouse-switch-models.png differ diff --git a/docs/images/prowler-app/rbac/membership.png b/docs/images/prowler-app/rbac/membership.png deleted file mode 100644 index e2b96e40f5..0000000000 Binary files a/docs/images/prowler-app/rbac/membership.png and /dev/null differ diff --git a/docs/images/prowler-app/rbac/organization.png b/docs/images/prowler-app/rbac/organization.png new file mode 100644 index 0000000000..38e8437f76 Binary files /dev/null and b/docs/images/prowler-app/rbac/organization.png differ diff --git a/docs/images/prowler-app/saml/okta-app-assignments.png b/docs/images/prowler-app/saml/okta-app-assignments.png new file mode 100644 index 0000000000..3881e646fc Binary files /dev/null and b/docs/images/prowler-app/saml/okta-app-assignments.png differ diff --git a/docs/images/prowler-app/saml/okta-user-profile-attributes.png b/docs/images/prowler-app/saml/okta-user-profile-attributes.png new file mode 100644 index 0000000000..beb6781b2c Binary files /dev/null and b/docs/images/prowler-app/saml/okta-user-profile-attributes.png differ diff --git a/docs/images/prowler-app/saml/okta-user-profile-name.png b/docs/images/prowler-app/saml/okta-user-profile-name.png new file mode 100644 index 0000000000..2a08d3437b Binary files /dev/null and b/docs/images/prowler-app/saml/okta-user-profile-name.png differ diff --git a/docs/images/prowler_mcp_schema_dark.png b/docs/images/prowler_mcp_schema_dark.png new file mode 100644 index 0000000000..7771557601 Binary files /dev/null and b/docs/images/prowler_mcp_schema_dark.png differ diff --git a/docs/images/prowler_mcp_schema_light.png b/docs/images/prowler_mcp_schema_light.png new file mode 100644 index 0000000000..c542d84ed2 Binary files /dev/null and b/docs/images/prowler_mcp_schema_light.png differ diff --git a/docs/img/prowler-cli-quick.gif b/docs/img/prowler-cli-quick.gif deleted file mode 100644 index 16197a5ba7..0000000000 Binary files a/docs/img/prowler-cli-quick.gif and /dev/null differ diff --git a/docs/img/prowler-cloud.gif b/docs/img/prowler-cloud.gif new file mode 100644 index 0000000000..b42043f1c1 Binary files /dev/null and b/docs/img/prowler-cloud.gif differ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 7c9bb623d0..c354f2802d 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -23,19 +23,23 @@ The supported providers right now are: -| Provider | Support | Interface | -| -------------------------------------------------------------------------------- | ---------- | ------------ | -| [AWS](/user-guide/providers/aws/getting-started-aws) | Official | UI, API, CLI | -| [Azure](/user-guide/providers/azure/getting-started-azure) | Official | UI, API, CLI | -| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | UI, API, CLI | -| [Kubernetes](/user-guide/providers/kubernetes/in-cluster) | Official | UI, API, CLI | -| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | UI, API, CLI | -| [Github](/user-guide/providers/github/getting-started-github) | Official | UI, API, CLI | -| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | UI, API, CLI | -| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | CLI | -| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | CLI | -| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | CLI | -| **NHN** | Unofficial | CLI | +| Provider | Support | Audit Scope/Entities | Interface | +| -------------------------------------------------------------------------------- | ---------- | ---------------------------- | ------------ | +| [AWS](/user-guide/providers/aws/getting-started-aws) | Official | Accounts | UI, API, CLI | +| [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI | +| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI | +| [Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) | Official | Clusters | UI, API, CLI | +| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI | +| [Github](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI | +| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | +| [Alibaba Cloud](/user-guide/providers/alibabacloud/getting-started-alibabacloud) | Official | Accounts | UI, API, CLI | +| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI | +| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | +| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | +| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | CLI | +| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | +| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images | CLI | +| **NHN** | Unofficial | Tenants | CLI | For more information about the checks and compliance of each provider visit [Prowler Hub](https://hub.prowler.com). @@ -48,4 +52,4 @@ For more information about the checks and compliance of each provider visit [Pro Interested in contributing to Prowler? - \ No newline at end of file + diff --git a/docs/security.mdx b/docs/security.mdx deleted file mode 100644 index d8a3f63c73..0000000000 --- a/docs/security.mdx +++ /dev/null @@ -1,89 +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. - -## Reporting Vulnerabilities - -At Prowler, we consider the security of our open source software and systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present. - -If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our users, our clients and our systems. - -When reporting vulnerabilities, please consider (1) attack scenario / exploitability, and (2) the security impact of the bug. The following issues are considered out of scope: - -- Social engineering support or attacks requiring social engineering. -- Clickjacking on pages with no sensitive actions. -- Cross-Site Request Forgery (CSRF) on unauthenticated forms or forms with no sensitive actions. -- Attacks requiring Man-In-The-Middle (MITM) or physical access to a user's device. -- Previously known vulnerable libraries without a working Proof of Concept (PoC). -- Comma Separated Values (CSV) injection without demonstrating a vulnerability. -- Missing best practices in SSL/TLS configuration. -- Any activity that could lead to the disruption of service (DoS). -- Rate limiting or brute force issues on non-authentication endpoints. -- Missing best practices in Content Security Policy (CSP). -- Missing HttpOnly or Secure flags on cookies. -- Configuration of or missing security headers. -- Missing email best practices, such as invalid, incomplete, or missing SPF/DKIM/DMARC records. -- Vulnerabilities only affecting users of outdated or unpatched browsers (less than two stable versions behind). -- Software version disclosure, banner identification issues, or descriptive error messages. -- Tabnabbing. -- Issues that require unlikely user interaction. -- Improper logout functionality and improper session timeout. -- CORS misconfiguration without an exploitation scenario. -- Broken link hijacking. -- Automated scanning results (e.g., sqlmap, Burp active scanner) that have not been manually verified. -- Content spoofing and text injection issues without a clear attack vector. -- Email spoofing without exploiting security flaws. -- Dead links or broken links. -- User enumeration. - -Testing guidelines: - -- Do not run automated scanners on other customer projects. Running automated scanners can run up costs for our users. Aggressively configured scanners might inadvertently disrupt services, exploit vulnerabilities, lead to system instability or breaches and violate Terms of Service from our upstream providers. Our own security systems won't be able to distinguish hostile reconnaissance from whitehat research. If you wish to run an automated scanner, notify us at support@prowler.com and only run it on your own Prowler app project. Do NOT attack Prowler in usage of other customers. -- Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data. - -Reporting guidelines: - -- File a report through our Support Desk at https://support.prowler.com -- If it is about a lack of a security functionality, please file a feature request instead at https://github.com/prowler-cloud/prowler/issues -- Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. -- If you have further questions and want direct interaction with the Prowler team, please contact us at via our Community Slack at goto.prowler.com/slack. - -Disclosure guidelines: - -- In order to protect our users and customers, do not reveal the problem to others until we have researched, addressed and informed our affected customers. -- If you want to publicly share your research about Prowler at a conference, in a blog or any other public forum, you should share a draft with us for review and approval at least 30 days prior to the publication date. Please note that the following should not be included: - - Data regarding any Prowler user or customer projects. - - Prowler customers' data. - - Information about Prowler employees, contractors or partners. - -What we promise: - -- We will respond to your report within 5 business days with our evaluation of the report and an expected resolution date. -- If you have followed the instructions above, we will not take any legal action against you in regard to the report. -- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission. -- We will keep you informed of the progress towards resolving the problem. -- In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise). - -We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved. diff --git a/docs/security/data-regions.mdx b/docs/security/data-regions.mdx new file mode 100644 index 0000000000..0602caf7b9 --- /dev/null +++ b/docs/security/data-regions.mdx @@ -0,0 +1,25 @@ +--- +title: 'Data Regions & Availability' +--- + +Prowler Cloud runs on AWS with high availability built in. + +## Regions + +| Region | URL | Location | +|--------|-----|----------| +| **EU** | [cloud.prowler.com](https://cloud.prowler.com) | Ireland (`eu-west-1`) | + +## Business Continuity + +| Control | Details | +|---------|---------| +| **High Availability** | Multi-AZ databases and load-balanced stateless application layer on AWS | +| **Disaster Recovery** | Encrypted backups, tested regularly | +| **[RPO](https://en.wikipedia.org/wiki/Recovery_point_objective)** | 24 hours | +| **[RTO](https://en.wikipedia.org/wiki/Recovery_time_objective)** | 2 hours | +| **Status** | [status.prowler.com](https://status.prowler.com) — uptime history and incidents | + +## Contact + +For questions about data regions and availability, visit the [Support page](/support). diff --git a/docs/security/encryption.mdx b/docs/security/encryption.mdx new file mode 100644 index 0000000000..3c05643069 --- /dev/null +++ b/docs/security/encryption.mdx @@ -0,0 +1,25 @@ +--- +title: 'Encryption' +--- + +Prowler Cloud uses encryption everywhere possible. All data and communications are encrypted at rest and in transit. + +## Encryption at Rest + +All data stored in Prowler Cloud is encrypted at rest using AES-256 encryption, including: + +- **Database contents:** All scan results, findings, and configuration data. +- **File storage:** Reports, exports, and uploaded files. +- **Backups:** All backup data is encrypted. + +## Encryption in Transit + +All communications with Prowler Cloud are encrypted in transit using TLS 1.2 or higher, including: + +- **API requests:** All REST API communications. +- **Web application traffic:** Browser-to-server connections. +- **Internal service communication:** Service-to-service traffic within the platform. + +## Contact + +For questions regarding encryption, visit the [Support page](/support). diff --git a/docs/security/index.mdx b/docs/security/index.mdx new file mode 100644 index 0000000000..a9034c4cea --- /dev/null +++ b/docs/security/index.mdx @@ -0,0 +1,76 @@ +--- +title: 'Security & Compliance' +--- + +**Prowler secures itself with Prowler.** As an open-source cloud security platform trusted by thousands of organizations, Prowler applies the same rigorous security standards internally that customers achieve externally. + +All security tooling, configurations, and CI/CD pipelines are publicly available in the [Prowler GitHub repository](https://github.com/prowler-cloud/prowler). Transparency is fundamental to open-source security. + +## Software Security + +All Prowler code goes through the same security pipeline, whether running on Prowler Cloud or self-managed infrastructure: DAST, SAST, SCA, container scanning, and secrets detection on every build. + + + Security tools and practices applied to all Prowler code. + + +## Prowler Cloud vs Self-Managed + +| | Prowler Cloud | Self-Managed | +|--|---------------|--------------| +| **Deployment** | Fully managed SaaS | Own infrastructure | +| **Region** | EU (Ireland) | Any region or provider | +| **Compliance** | SOC 2 Type II, AWS FTR | Organization responsibility | +| **Data Control** | Prowler managed | Full control | +| **Encryption** | AES-256 at rest, TLS 1.2+ in transit | Configurable | +| **Backups** | Automated | Organization responsibility | +| **Updates** | Automatic | Manual | + + +Self-Managed includes Prowler App and Prowler CLI. They can run anywhere — any cloud provider, any region, on-premises, or air-gapped environments. Full control over data residency and infrastructure decisions. See the [Prowler App Installation Guide](/getting-started/installation/prowler-app) to get started. + + +--- + +## Prowler Cloud + +This section covers security and compliance for **Prowler Cloud**, the managed infrastructure. + +### Trust & Compliance + +Prowler Cloud holds compliance certifications and undergoes regular audits. + +| Certification | Status | +|---------------|--------| +| **SOC 2 Type II** | [View on Trust Portal](https://trust.prowler.com) | +| **AWS Foundational Technical Review (FTR)** | Passed — [Details](https://aws.amazon.com/partners/foundational-technical-review/) | + +Compliance data and reports: [trust.prowler.com](https://trust.prowler.com) + +### Security + + + + Data encrypted at rest (AES-256) and in transit (TLS 1.2+). + + + EU-hosted infrastructure with high availability and disaster recovery. + + + Static egress IPs for firewall allowlisting. + + + +### Privacy + +Prowler Cloud is GDPR compliant in regard to the ["right to be forgotten"](https://gdpr.eu/right-to-be-forgotten/). When an account is deleted, user information is removed from online and backup systems within 10 calendar days. + +--- + +## Report a Vulnerability + +Found a security issue? Report it through the [responsible disclosure](https://prowler.com/.well-known/security.txt) process. + +## Contact + +For security inquiries or general support, visit the [Support page](/support). diff --git a/docs/security/networking.mdx b/docs/security/networking.mdx new file mode 100644 index 0000000000..767e6ac4bf --- /dev/null +++ b/docs/security/networking.mdx @@ -0,0 +1,21 @@ +--- +title: 'Networking' +--- + +## Egress IP Addresses + +Prowler Cloud makes outbound API calls to scan cloud provider accounts and connect to integrations. Allowlist these IPs in firewalls or security groups to restrict access to Prowler Cloud only. + +| Region | IP Address | +|--------|------------| +| EU (Ireland) | `52.48.254.174` | + +Resolve the egress IP via DNS: + +```bash +dig egress.prowler.com +short +``` + +## Contact + +For questions about networking, visit the [Support page](/support). diff --git a/docs/security/software-security.mdx b/docs/security/software-security.mdx new file mode 100644 index 0000000000..4c690e988b --- /dev/null +++ b/docs/security/software-security.mdx @@ -0,0 +1,97 @@ +--- +title: 'Software Security' +--- + +Prowler follows a **security-by-design approach** throughout the software development lifecycle. All changes go through automated checks at every stage, from local development to production deployment. + +[Pre-commit](https://github.com/prowler-cloud/prowler/blob/master/.pre-commit-config.yaml) validations catch issues early, and [CI/CD pipelines](https://github.com/prowler-cloud/prowler/tree/master/.github) include multiple security gates ensuring code quality, secure configurations, and compliance with internal standards. + +Container registries are continuously scanned for vulnerabilities, with findings automatically reported to the security team for assessment and remediation. This process evolves alongside the stack as new languages, frameworks, and technologies are adopted, ensuring security practices remain comprehensive, proactive, and adaptable. + +## Static Application Security Testing (SAST) + +Multiple SAST tools are employed across the codebase to identify security vulnerabilities, code quality issues, and potential bugs during development. + +### CodeQL Analysis + +- **Scope:** UI (JavaScript/TypeScript), API (Python), and SDK (Python) +- **Frequency:** On every push and pull request, plus daily scheduled scans +- **Integration:** Results uploaded to GitHub Security tab via SARIF format +- **Purpose:** Identifies security vulnerabilities, coding errors, and potential exploits in source code + +### Python Security Scanners + +- **Bandit:** Detects common security issues in Python code (SQL injection, hardcoded passwords, etc.) + - Configured to ignore test files and report only high-severity issues + - Runs on both SDK and API codebases +- **Pylint:** Static code analysis with security-focused checks + - Integrated into pre-commit hooks and CI/CD pipelines + +### Code Quality & Dead Code Detection + +- **Vulture:** Identifies unused code that could indicate incomplete implementations or security gaps +- **Flake8:** Style guide enforcement with security-relevant checks +- **Shellcheck:** Security and correctness checks for shell scripts + +## Software Composition Analysis (SCA) + +Dependencies are continuously monitored for known vulnerabilities with timely updates ensured. + +### Dependency Vulnerability Scanning + +- **Safety:** Scans Python dependencies against known vulnerability databases + - Runs on every commit via pre-commit hooks + - Integrated into CI/CD for SDK and API + - Configured with selective ignores for tracked exceptions +- **Trivy:** Multi-purpose scanner for containers and dependencies + - Scans all container images (UI, API, SDK, MCP Server) + - Checks for vulnerabilities in OS packages and application dependencies + - Reports findings to GitHub Security tab + +### Automated Dependency Updates + +- **Dependabot:** Automated pull requests for dependency updates + - **Python (pip):** Monthly updates for SDK + - **GitHub Actions:** Monthly updates for workflow dependencies + - **Docker:** Monthly updates for base images + - Temporarily paused for API and UI to maintain stability during active development + - **Security-first approach:** Even when paused, Dependabot automatically creates pull requests for security vulnerabilities, ensuring critical security patches are never delayed + +## Container Security + +All container images are scanned before deployment. + +### Trivy Vulnerability Scanning + +- Scans images for vulnerabilities and misconfigurations +- Generates SARIF reports uploaded to GitHub Security tab +- Creates PR comments with scan summaries +- Configurable to fail builds on critical findings +- Reports include CVE counts and remediation guidance + +### Hadolint + +- Validates Dockerfile syntax and structure +- Ensures secure image building practices + +## Secrets Detection + +Prowler protects against accidental exposure of sensitive credentials. + +### TruffleHog + +- Scans entire codebase and Git history for secrets +- Runs on every push and pull request +- Pre-commit hook prevents committing secrets +- Detects high-entropy strings, API keys, tokens, and credentials +- Configured to report verified and unknown findings + +## Security Monitoring + +- **GitHub Security Tab:** Centralized view of all security findings from CodeQL, Trivy, and other SARIF-compatible tools +- **Artifact Retention:** Security scan reports retained for post-deployment analysis +- **PR Comments:** Automated security feedback on pull requests for rapid remediation + +## Contact + +For questions regarding software security, visit the [Support page](/support). diff --git a/docs/support.mdx b/docs/support.mdx new file mode 100644 index 0000000000..6999d5efbf --- /dev/null +++ b/docs/support.mdx @@ -0,0 +1,62 @@ +--- +title: 'Support' +description: 'Get help with Prowler' +--- + +## Lighthouse AI + +Lighthouse AI is a Cloud Security Analyst chatbot powered by [Prowler MCP](/getting-started/products/prowler-mcp), your 24/7 virtual cloud security analyst. It can: + +- **Query your security data**: Findings, compliance status, resources, and remediation guidance +- **Search Prowler Hub**: Over 1,000 security checks and 70+ compliance frameworks +- **Access documentation**: Search and retrieve Prowler docs contextually + +Available in Prowler Cloud and Prowler App. + +[Learn more about Lighthouse AI](/getting-started/products/prowler-lighthouse-ai) + +## Support Desk + +> Available to **Prowler Cloud** customers. + +For Prowler Cloud customers, submit support requests through our support desk. We'll route your request to the right team and respond via email. + + + Contact our support team + + +## GitHub Discussions + +Prowler is Open Source. If you have a question, it's likely someone else has it too. We'd love to answer in the open on GitHub whenever possible. + + + + Get help from the community + + + Found something wrong? Let us know + + + Share your ideas for improvements + + + +## Community Slack + +Join our Slack workspace to connect with the Prowler community, ask questions, and get help from other users and the Prowler team. + + + Connect with the community + + +## Office Hours + +Join our open calls to discuss what you're building, ask questions, and connect with the Prowler team and community. + +Office Hours sessions are announced on [LinkedIn](https://www.linkedin.com/company/prowler-security/). Recordings of previous sessions are available on [YouTube](https://www.youtube.com/playlist?list=PLIwvjRXuMGkE-BDYXmUR2TXYQ7agxtuB1). + +## Security + +To report a vulnerability or for security-related inquiries, contact [security@prowler.com](mailto:security@prowler.com). + +See also: [Responsible Disclosure](https://prowler.com/.well-known/security.txt) diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 80b6590669..8e1cca7b7c 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -2,46 +2,206 @@ title: 'Troubleshooting' --- -- **Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]`**: +## Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]` - That is an error related to file descriptors or opened files allowed by your operating system. +That is an error related to file descriptors or opened files allowed by your operating system. - In macOS Ventura, the default value for the `file descriptors` is `256`. With the following command `ulimit -n 1000` you'll increase that value and solve the issue. +In macOS Ventura, the default value for the `file descriptors` is `256`. With the following command `ulimit -n 1000` you'll increase that value and solve the issue. - If you have a different OS and you are experiencing the same, please increase the value of your `file descriptors`. You can check it running `ulimit -a | grep "file descriptors"`. - - This error is also related with a lack of system requirements. To improve performance, Prowler stores information in memory so it may need to be run in a system with more than 1GB of memory. +If you have a different OS and you are experiencing the same, please increase the value of your `file descriptors`. You can check it running `ulimit -a | grep "file descriptors"`. +This error is also related with a lack of system requirements. To improve performance, Prowler stores information in memory so it may need to be run in a system with more than 1GB of memory. See section [Logging](/user-guide/cli/tutorials/logging) for further information or [contact us](/contact). ## Common Issues with Docker Compose Installation -- **Problem adding AWS Provider using "Connect assuming IAM Role" in Docker (see [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745))**: +### Problem adding AWS Provider using "Connect assuming IAM Role" in Docker - When running Prowler App via Docker, you may encounter errors such as `Provider not set`, `AWS assume role error - Unable to locate credentials`, or `Provider has no secret` when trying to add an AWS Provider using the "Connect assuming IAM Role" option. This typically happens because the container does not have access to the necessary AWS credentials or profiles. +See [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745) for more details. - **Workaround:** +When running Prowler App via Docker, you may encounter errors such as `Provider not set`, `AWS assume role error - Unable to locate credentials`, or `Provider has no secret` when trying to add an AWS Provider using the "Connect assuming IAM Role" option. This typically happens because the container does not have access to the necessary AWS credentials or profiles. - - Ensure your AWS credentials and configuration are available to the Docker container. You can do this by mounting your local `.aws` directory into the container. For example, in your `docker-compose.yaml`, add the following volume to the relevant services: +**Workaround:** - ```yaml - volumes: - - "${HOME}/.aws:/home/prowler/.aws:ro" - ``` - This should be added to the `api`, `worker`, and `worker-beat` services. +- Ensure your AWS credentials and configuration are available to the Docker container. You can do this by mounting your local `.aws` directory into the container. For example, in your `docker-compose.yaml`, add the following volume to the relevant services: - - Create or update your `~/.aws/config` and `~/.aws/credentials` files with the appropriate profiles and roles. For example: +```yaml +volumes: + - "${HOME}/.aws:/home/prowler/.aws:ro" +``` - ```ini - [profile prowler-profile] - role_arn = arn:aws:iam:::role/ProwlerScan - source_profile = default - ``` - And set the environment variable in your `.env` file: +This should be added to the `api`, `worker`, and `worker-beat` services. - ```env - AWS_PROFILE=prowler-profile - ``` +- Create or update your `~/.aws/config` and `~/.aws/credentials` files with the appropriate profiles and roles. For example: - - If you are scanning multiple AWS accounts, you may need to add multiple profiles to your AWS config. Note that this workaround is mainly for local testing; for production or multi-account setups, follow the [CloudFormation Template guide](https://github.com/prowler-cloud/prowler/issues/7745) and ensure the correct IAM roles and permissions are set up in each account. +```ini +[profile prowler-profile] +role_arn = arn:aws:iam:::role/ProwlerScan +source_profile = default +``` + +And set the environment variable in your `.env` file: + +```env +AWS_PROFILE=prowler-profile +``` + +- If you are scanning multiple AWS accounts, you may need to add multiple profiles to your AWS config. Note that this workaround is mainly for local testing; for production or multi-account setups, follow the [CloudFormation Template guide](https://github.com/prowler-cloud/prowler/issues/7745) and ensure the correct IAM roles and permissions are set up in each account. + +### Scans complete but reports are missing or compliance data is empty (`Too many open files` error) + +When running Prowler App via Docker Compose, you may encounter situations where scans complete successfully but reports are not available for download, compliance data shows as empty, or you see 404 errors when trying to access scan reports. Checking the `worker` container logs may reveal errors like `[Errno 24] Too many open files`. + +This issue occurs because the default file descriptor limits in Docker containers are too low for Prowler's operations. + +**Solution:** + +Add `ulimits` configuration to the `worker` and `worker-beat` services in your `docker-compose.yaml`: + +```yaml +services: + worker: + ulimits: + nofile: + soft: 65536 + hard: 65536 + # ... rest of service configuration + + worker-beat: + ulimits: + nofile: + soft: 65536 + hard: 65536 + # ... rest of service configuration +``` + +After making these changes, restart your Docker Compose stack: + +```bash +docker compose down +docker compose up -d +``` + + +We are evaluating adding these values to the default `docker-compose.yml` to avoid this issue in future releases. + + +### API Container Fails to Start with JWT Key Permission Error + +See [GitHub Issue #8897](https://github.com/prowler-cloud/prowler/issues/8897) for more details. + +When deploying Prowler via Docker Compose on a fresh installation, the API container may fail to start with permission errors related to JWT RSA key file generation. This issue is commonly observed on Linux systems (Ubuntu, Debian, cloud VMs) and Windows with Docker Desktop, but not typically on macOS. + +**Error Message:** + +Checking the API container logs reveals: + +```bash +PermissionError: [Errno 13] Permission denied: '/home/prowler/.config/prowler-api/jwt_private.pem' +``` + +Or: + +```bash +Token generation failed due to invalid key configuration. Provide valid DJANGO_TOKEN_SIGNING_KEY and DJANGO_TOKEN_VERIFYING_KEY in the environment. +``` + +**Root Cause:** + +This permission mismatch occurs due to UID (User ID) mapping between the host system and Docker containers: + +* The API container runs as user `prowler` with UID/GID 1000 +* In environments like WSL2, the host user may have a different UID than the container user +* Docker creates the mounted volume directory `./_data/api` on the host, often with the host user's UID or root ownership (UID 0) +* When the application attempts to write JWT key files (`jwt_private.pem` and `jwt_public.pem`), the operation fails because the container's UID 1000 does not have write permissions to the host-owned directory + +**Solutions:** + +There are two approaches to resolve this issue: + +**Option 1: Fix Volume Ownership (Resolve UID Mapping)** + +Change the ownership of the volume directory to match the container user's UID (1000): + +```bash +# The container user 'prowler' has UID 1000 +# This command changes the directory ownership to UID 1000 +sudo chown -R 1000:1000 ./_data/api +``` + +Then start Docker Compose: + +```bash +docker compose up -d +``` + +This solution directly addresses the UID mapping mismatch by ensuring the volume directory is owned by the same UID that the container process uses. + +**Option 2: Use Environment Variables (Skip File Storage)** + +Generate JWT RSA keys manually and provide them via environment variables to bypass file-based key storage entirely: + +```bash +# Generate RSA keys +openssl genrsa -out jwt_private.pem 4096 +openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem + +# Extract key content (removes headers/footers and newlines) +PRIVATE_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' jwt_private.pem) +PUBLIC_KEY=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' jwt_public.pem) +``` + +Add the following to the `.env` file: + +```env +DJANGO_TOKEN_SIGNING_KEY= +DJANGO_TOKEN_VERIFYING_KEY= +``` + +When these environment variables are set, the API will use them directly instead of attempting to write key files to the mounted volume. + + +A fix addressing this permission issue is being evaluated in [PR #9953](https://github.com/prowler-cloud/prowler/pull/9953). + + +### SAML/OAuth ACS URL Incorrect When Running Behind a Proxy or Load Balancer + +See [GitHub Issue #9724](https://github.com/prowler-cloud/prowler/issues/9724) for more details. + +When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balancer, the SAML ACS (Assertion Consumer Service) URL or OAuth callback URLs may be incorrectly generated using the internal container hostname (e.g., `http://prowler-api:8080/...`) instead of your external domain URL (e.g., `https://prowler.example.com/...`). + +**Root Cause:** + +Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle. + +**Solution:** + +You must **rebuild** the UI Docker image with your external URL: + +```bash +# Clone the repository (if you haven't already) +git clone https://github.com/prowler-cloud/prowler.git +cd prowler/ui + +# Build with your external URL as a build argument +docker build \ + --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \ + --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \ + -t prowler-ui-custom:latest \ + --target prod \ + . +``` + +Then update your `docker-compose.yml` to use your custom image instead of the pre-built one: + +```yaml +services: + ui: + image: prowler-ui-custom:latest # Use your custom-built image + # ... rest of configuration +``` + + +The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables. + diff --git a/docs/user-guide/cli/img/add-cloud-provider.png b/docs/user-guide/cli/img/add-cloud-provider.png index dd42e73047..d8f19b2054 100644 Binary files a/docs/user-guide/cli/img/add-cloud-provider.png and b/docs/user-guide/cli/img/add-cloud-provider.png differ diff --git a/docs/user-guide/cli/img/cloud-providers-page.png b/docs/user-guide/cli/img/cloud-providers-page.png index 0581e0132f..dcbce73a10 100644 Binary files a/docs/user-guide/cli/img/cloud-providers-page.png and b/docs/user-guide/cli/img/cloud-providers-page.png differ diff --git a/docs/user-guide/cli/img/lighthouse-architecture.png b/docs/user-guide/cli/img/lighthouse-architecture.png deleted file mode 100644 index 63202ce7c7..0000000000 Binary files a/docs/user-guide/cli/img/lighthouse-architecture.png and /dev/null differ diff --git a/docs/user-guide/cli/tutorials/compliance.mdx b/docs/user-guide/cli/tutorials/compliance.mdx index da4d15e5c7..8754be6237 100644 --- a/docs/user-guide/cli/tutorials/compliance.mdx +++ b/docs/user-guide/cli/tutorials/compliance.mdx @@ -24,7 +24,7 @@ Standard results will be shown and additionally the framework information as the **If Prowler can't find a resource related with a check from a compliance requirement, this requirement won't appear on the output** -## List Available Compliance Frameworks +## List Available Compliance Frameworks To see which compliance frameworks are covered by Prowler, use the `--list-compliance` option: @@ -34,7 +34,7 @@ prowler --list-compliance Or you can visit [Prowler Hub](https://hub.prowler.com/compliance). -## List Requirements of Compliance Frameworks +## List Requirements of Compliance Frameworks To list requirements for a compliance framework, use the `--list-compliance-requirements` option: ```sh diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 819c17e9ca..e5039bff21 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -66,6 +66,11 @@ The following list includes all the AWS checks with configurable variables that | `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer | | `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings | | `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean | +| `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings | +| `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings | +| `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings | +| `s3_bucket_cross_account_access` | `trusted_account_ids` | List of Strings | +| `ssm_documents_set_as_public` | `trusted_account_ids` | List of Strings | | `vpc_endpoint_connections_trust_boundaries` | `trusted_account_ids` | List of Strings | | `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings | @@ -93,8 +98,14 @@ The following list includes all the Azure checks with configurable variables tha ## GCP ### Configurable Checks +The following list includes all the GCP checks with configurable variables that can be changed in the configuration yaml file: -## Kubernetes +| Check Name | Value | Type | +|---------------------------------------------------------------|--------------------------------------------------|-----------------| +| `compute_configuration_changes` | `compute_audit_log_lookback_days` | Integer | +| `compute_instance_group_multiple_zones` | `mig_min_zones` | Integer | + +## Kubernetes ### Configurable Checks The following list includes all the Kubernetes checks with configurable variables that can be changed in the configuration yaml file: @@ -196,7 +207,10 @@ aws: ] # AWS VPC Configuration (vpc_endpoint_connections_trust_boundaries, vpc_endpoint_services_allowed_principals_trust_boundaries) - # AWS SSM Configuration (aws.ssm_documents_set_as_public) + # AWS SSM Configuration (ssm_documents_set_as_public) + # AWS S3 Configuration (s3_bucket_cross_account_access) + # AWS EventBridge Configuration (eventbridge_schema_registry_cross_account_access, eventbridge_bus_cross_account_access) + # AWS DynamoDB Configuration (dynamodb_table_cross_account_access) # Single account environment: No action required. The AWS account number will be automatically added by the checks. # Multi account environment: Any additional trusted account number should be added as a space separated list, e.g. # trusted_account_ids : ["123456789012", "098765432109", "678901234567"] @@ -548,6 +562,12 @@ gcp: # GCP Compute Configuration # gcp.compute_public_address_shodan shodan_api_key: null + # gcp.compute_configuration_changes + # Number of days to look back for Compute Engine configuration changes in audit logs + compute_audit_log_lookback_days: 1 + # gcp.compute_instance_group_multiple_zones + # Minimum number of zones a MIG should span for high availability + mig_min_zones: 2 # Kubernetes Configuration kubernetes: diff --git a/docs/user-guide/cli/tutorials/dashboard.mdx b/docs/user-guide/cli/tutorials/dashboard.mdx index 01c60b29e9..8c819743fe 100644 --- a/docs/user-guide/cli/tutorials/dashboard.mdx +++ b/docs/user-guide/cli/tutorials/dashboard.mdx @@ -1,5 +1,5 @@ --- -title: 'Dashboard' +title: "Dashboard" --- Prowler allows you to run your own local dashboards using the csv outputs provided by Prowler @@ -34,26 +34,30 @@ The overview page provides a full impression of your findings obtained from Prow This page allows for multiple functions: -* Apply filters: +- Apply filters: - * Assesment Date - * Account - * Region - * Severity - * Service - * Status + - Assesment Date + - Account + - Region + - Severity + - Service + - Provider + - Status + - Category -* See which files has been scanned to generate the dashboard by placing your mouse on the `?` icon: +- See which files has been scanned to generate the dashboard by placing your mouse on the `?` icon: - + {" "} + -* Download the `Top Findings by Severity` table using the button `DOWNLOAD THIS TABLE AS CSV` or `DOWNLOAD THIS TABLE AS XLSX` +- Download the `Top Findings by Severity` table using the button `DOWNLOAD THIS TABLE AS CSV` or `DOWNLOAD THIS TABLE AS XLSX` -* Click the provider cards to filter by provider. +- Click the provider cards to filter by provider. -* On the dropdowns under `Top Findings by Severity` you can apply multiple sorts to see the information, also you will get a detailed view of each finding using the dropdowns: +- On the dropdowns under `Top Findings by Severity` you can apply multiple sorts to see the information, also you will get a detailed view of each finding using the dropdowns: - + {" "} + ## Compliance Page @@ -110,17 +114,16 @@ To change the path, modify the values `folder_path_overview` or `folder_path_com If you have any issue related with dashboards, check that the output path where the dashboard is getting the outputs is correct. - ## Output Support Prowler dashboard supports the detailed outputs: -| Provider| V3| V4| COMPLIANCE-V3| COMPLIANCE-V4 -|----------|----------|----------|----------|---------- -| AWS| ✅| ✅| ✅| ✅ -| Azure| ❌| ✅| ❌| ✅ -| Kubernetes| ❌| ✅| ❌| ✅ -| GCP| ❌| ✅| ❌| ✅ -| M365| ❌| ✅| ❌| ✅ -| GitHub| ❌| ✅| ❌| ✅ +| Provider | V3 | V4 | COMPLIANCE-V3 | COMPLIANCE-V4 | +| ---------- | --- | --- | ------------- | ------------- | +| AWS | ✅ | ✅ | ✅ | ✅ | +| Azure | ❌ | ✅ | ❌ | ✅ | +| Kubernetes | ❌ | ✅ | ❌ | ✅ | +| GCP | ❌ | ✅ | ❌ | ✅ | +| M365 | ❌ | ✅ | ❌ | ✅ | +| GitHub | ❌ | ✅ | ❌ | ✅ | diff --git a/docs/user-guide/cli/tutorials/integrations.mdx b/docs/user-guide/cli/tutorials/integrations.mdx index 2667b81c50..f3e698d799 100644 --- a/docs/user-guide/cli/tutorials/integrations.mdx +++ b/docs/user-guide/cli/tutorials/integrations.mdx @@ -2,7 +2,7 @@ title: 'Integrations' --- -## Integration with Slack +## Integration with Slack Prowler can be integrated with [Slack](https://slack.com/) to send a summary of the execution having configured a Slack APP in your channel with the following command: diff --git a/docs/user-guide/cli/tutorials/misc.mdx b/docs/user-guide/cli/tutorials/misc.mdx index 9921f89032..0940508ed7 100644 --- a/docs/user-guide/cli/tutorials/misc.mdx +++ b/docs/user-guide/cli/tutorials/misc.mdx @@ -14,7 +14,7 @@ prowler -V/-v/--version Prowler provides various execution settings. -### Verbose Execution +### Verbose Execution To enable verbose mode in Prowler, similar to Version 2, use: @@ -54,7 +54,7 @@ To run Prowler without color formatting: prowler --no-color ``` -### Checks in Prowler +### Checks in Prowler Prowler provides various security checks per cloud provider. Use the following options to list, execute, or exclude specific checks: @@ -96,7 +96,7 @@ prowler -e/--excluded-checks ec2 rds prowler -C/--checks-file .json ``` -## Custom Checks in Prowler +## Custom Checks in Prowler Prowler supports custom security checks, allowing users to define their own logic. diff --git a/docs/user-guide/cli/tutorials/mutelist.mdx b/docs/user-guide/cli/tutorials/mutelist.mdx index 4bb7524fa6..79292f49b3 100644 --- a/docs/user-guide/cli/tutorials/mutelist.mdx +++ b/docs/user-guide/cli/tutorials/mutelist.mdx @@ -19,15 +19,45 @@ The Mutelist option works in combination with other filtering mechanisms and mod ## How the Mutelist Works -The **Mutelist** uses both "AND" and "OR" logic to determine which resources, checks, regions, and tags should be muted. For each check, the Mutelist evaluates whether the account, region, and resource match the specified criteria using "AND" logic. If tags are specified, the Mutelist can apply either "AND" or "OR" logic. +The **Mutelist** uses **AND logic** to evaluate whether a finding should be muted. For a finding to be muted, **ALL** of the following conditions must match: -If any of the criteria do not match, the check is not muted. +- **Account** matches (exact match or `*`) +- **Check** matches (exact match, regex pattern, or `*`) +- **Region** matches (exact match, regex pattern, or `*`) +- **Resource** matches (exact match, regex pattern, or `*`) +- **Tags** match (if specified) + +If **any** of these criteria do not match, the finding is **not muted**. + +### Tag Matching Logic + +Tags have special matching behavior: + +- **Multiple tags in the list = AND logic**: ALL tags must be present on the resource + ```yaml + Tags: + - "environment=dev" + - "team=backend" # BOTH tags required + ``` + +- **Regex alternation within a single tag = OR logic**: Use the pipe operator `|` for OR + ```yaml + Tags: + - "environment=dev|environment=stg" # Matches EITHER dev OR stg + ``` + +- **Complex tag patterns**: Combine AND and OR using regex + ```yaml + Tags: + - "team=backend" # Required + - "environment=dev|environment=stg" # AND (dev OR stg) + ``` Remember that mutelist can be used with regular expressions. -## Mutelist Specification +## Mutelist Specification - For Azure provider, the Account ID is the Subscription Name and the Region is the Location. @@ -40,9 +70,10 @@ The Mutelist file uses the [YAML](https://en.wikipedia.org/wiki/YAML) format wit ```yaml ### Account, Check and/or Region can be * to apply for all the cases. ### Resources and tags are lists that can have either Regex or Keywords. -### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. -### Use an alternation Regex to match one of multiple tags with "ORed" logic. -### For each check you can except Accounts, Regions, Resources and/or Tags. +### Multiple tags in the list are "ANDed" together (ALL must match). +### Use regex alternation (|) within a single tag for "OR" logic (e.g., "env=dev|env=stg"). +### For each check you can use Exceptions to unmute specific Accounts, Regions, Resources and/or Tags. +### All conditions (Account, Check, Region, Resource, Tags) are ANDed together. ########################### MUTELIST EXAMPLE ########################### Mutelist: Accounts: @@ -148,11 +179,11 @@ Mutelist: | Field| Description| Logic |----------|----------|---------- -| `account_id`| Use `*` to apply the mutelist to all accounts.| `ANDed` -| `check_name`| The name of the Prowler check. Use `*` to apply the mutelist to all checks, or `service_*` to apply it to all service's checks.| `ANDed` -| `region`| The region identifier. Use `*` to apply the mutelist to all regions.| `ANDed` -| `resource`| The resource identifier. Use `*` to apply the mutelist to all resources.| `ANDed` -| `tag`| The tag value.| `ORed` +| `account_id`| Use `*` to apply the mutelist to all accounts. Supports exact match or wildcard.| `AND` (with other fields) +| `check_name`| The name of the Prowler check. Use `*` to apply the mutelist to all checks, or `service_*` to apply it to all service's checks. Supports regex patterns.| `AND` (with other fields) +| `region`| The region identifier. Use `*` to apply the mutelist to all regions. Supports regex patterns.| `AND` (with other fields) +| `resource`| The resource identifier. Use `*` to apply the mutelist to all resources. Supports regex patterns.| `AND` (with other fields) +| `tags`| List of tag patterns in `key=value` format. **Multiple tags = AND** (all must match). **Regex alternation within single tag = OR** (use `tag1\|tag2`).| `AND` between tags, `OR` within regex ### Description @@ -173,6 +204,68 @@ Replace `` with the appropriate provider name. - The Mutelist can be used in combination with other Prowler options, such as the `--service` or `--checks` option, to further customize the scanning process. - Make sure to review and update the Mutelist regularly to ensure it reflects the desired exclusions and remains up to date with your infrastructure. +## Current Limitations and Workarounds + +### Limitation: No OR Logic Between Different Rule Sets + +The current Mutelist schema **does not support OR logic** between different condition sets. Each check can have only **one rule object**, and all conditions are **ANDed** together. + +**Example of unsupported scenario:** +```yaml +# ❌ INVALID: Cannot have multiple rule blocks for the same check +Accounts: + "*": + Checks: + "*": # Rule 1 + Regions: ["eu-west-1", "us-west-2"] + Resources: ["*"] + "*": # Rule 2 - This will OVERWRITE Rule 1 (YAML duplicate key) + Regions: ["us-east-1"] + Tags: ["environment=dev"] +``` + +**Workaround: Use multiple scans with different mutelists** + +For complex scenarios requiring OR logic, run separate scans: + +```bash +# Scan 1: Mute findings in non-critical regions +prowler aws --mutelist-file mutelist_noncritical.yaml + +# Scan 2: Mute dev/stg in critical regions +prowler aws --mutelist-file mutelist_critical.yaml --regions us-east-1,sa-east-1 +``` + +Then merge the outputs in your reporting pipeline. + +### Limitation: Cannot Negate Regions + +You cannot express "all regions **except** X and Y". You must explicitly list all regions you want to mute. + +**Workaround:** +```yaml +# Must enumerate all unwanted regions +Accounts: + "*": + Checks: + "*": + Regions: + - "af-south-1" + - "ap-east-1" + # ... list all regions EXCEPT the ones you want to monitor + Resources: ["*"] +``` + +### Best Practices + +1. **Use regex patterns for flexibility**: Instead of listing multiple resources, use regex patterns like `"dev-.*"` or `"test-instance-[0-9]+"` + +2. **Combine tag OR logic with regex**: Use `"environment=dev|environment=stg|environment=test"` instead of multiple tag entries + +3. **Be specific with exceptions**: Use the `Exceptions` field to unmute specific resources within a broader muting rule + +4. **Test your mutelist**: Run Prowler with `--output-modes json` and verify that the expected findings are muted + ## AWS Mutelist ### Muting specific AWS regions diff --git a/docs/user-guide/cli/tutorials/parallel-execution.mdx b/docs/user-guide/cli/tutorials/parallel-execution.mdx index b364483ace..93b8ef381b 100644 --- a/docs/user-guide/cli/tutorials/parallel-execution.mdx +++ b/docs/user-guide/cli/tutorials/parallel-execution.mdx @@ -10,7 +10,7 @@ This can help for really large accounts, but please be aware of AWS API rate lim 2. **API Rate Limits**: Most of the rate limits in AWS are applied at the API level. Each API call to an AWS service counts towards the rate limit for that service. 3. **Throttling Responses**: When you exceed the rate limit for a service, AWS responds with a throttling error. In AWS SDKs, these are typically represented as `ThrottlingException` or `RateLimitExceeded` errors. -For information on Prowler's retrier configuration please refer to this [page](https://docs.prowler.cloud/en/latest/tutorials/aws/boto3-configuration/). +For information on Prowler's retrier configuration please refer to this [page](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration/). You might need to increase the `--aws-retries-max-attempts` parameter from the default value of 3. The retrier follows an exponential backoff strategy. diff --git a/docs/user-guide/cli/tutorials/quick-inventory.mdx b/docs/user-guide/cli/tutorials/quick-inventory.mdx index 33ce719d19..08519c305c 100644 --- a/docs/user-guide/cli/tutorials/quick-inventory.mdx +++ b/docs/user-guide/cli/tutorials/quick-inventory.mdx @@ -24,6 +24,6 @@ By default, it extracts resources from all the regions, you could use `-f`/`--fi ![Quick Inventory Example](/images/quick-inventory.jpg) -## Objections +## Objections The inventorying process is carried out with `resourcegroupstaggingapi` calls, which means that only resources they have or have had tags will appear (except for the IAM and S3 resources which are done with Boto3 API calls). diff --git a/docs/user-guide/cli/tutorials/reporting.mdx b/docs/user-guide/cli/tutorials/reporting.mdx index 2e2fed88c0..65eec93263 100644 --- a/docs/user-guide/cli/tutorials/reporting.mdx +++ b/docs/user-guide/cli/tutorials/reporting.mdx @@ -22,7 +22,7 @@ prowler --output-formats json-asff All compliance-related reports are automatically generated when Prowler is executed. These outputs are stored in the `/output/compliance` directory. -## Custom Output Flags +## Custom Output Flags By default, Prowler creates a file inside the `output` directory named: `prowler-output-ACCOUNT_NUM-OUTPUT_DATE.format`. @@ -53,13 +53,13 @@ Both flags can be used simultaneously to provide a custom directory and filename By default, the timestamp format of the output files is ISO 8601. This can be changed with the flag `--unix-timestamp` generating the timestamp fields in pure unix timestamp format. -## Supported Output Formats +## Supported Output Formats Prowler natively supports the following reporting output formats: - CSV - JSON-OCSF -- JSON-ASFF +- JSON-ASFF (AWS only) - HTML Hereunder is the structure for each of the supported report formats by Prowler: @@ -285,10 +285,10 @@ The JSON-OCSF output format implements the [Detection Finding](https://schema.oc Each finding is a `json` object within a list. -### JSON-ASFF +### JSON-ASFF (AWS Only) -Only available when using `--security-hub` or `--output-formats json-asff` +Only available when using `--security-hub` or `--output-formats json-asff` with the AWS provider. The following code is an example output of the [JSON-ASFF](https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-findings-format-syntax.html) format: diff --git a/docs/user-guide/cli/tutorials/scan-unused-services.mdx b/docs/user-guide/cli/tutorials/scan-unused-services.mdx index 32756cdce0..6d675e159f 100644 --- a/docs/user-guide/cli/tutorials/scan-unused-services.mdx +++ b/docs/user-guide/cli/tutorials/scan-unused-services.mdx @@ -14,7 +14,7 @@ prowler --scan-unused-services ## Services Ignored -### AWS +### AWS #### ACM (AWS Certificate Manager) @@ -22,21 +22,21 @@ Certificates stored in ACM without active usage in AWS resources are excluded. B - `acm_certificates_expiration_check` -#### Athena +#### Athena Upon AWS account creation, Athena provisions a default primary workgroup for the user. Prowler verifies if this workgroup is enabled and used by checking for queries within the last 45 days. If Athena is unused, findings related to its checks will not appear. - `athena_workgroup_encryption` - `athena_workgroup_enforce_configuration` -#### AWS CloudTrail +#### AWS CloudTrail AWS CloudTrail should have at least one trail with a data event to record all S3 object-level API operations. Before flagging this issue, Prowler verifies if S3 buckets exist in the account. - `cloudtrail_s3_dataevents_read_enabled` - `cloudtrail_s3_dataevents_write_enabled` -#### AWS Elastic Compute Cloud (EC2) +#### AWS Elastic Compute Cloud (EC2) If Amazon Elastic Block Store (EBS) default encyption is not enabled, sensitive data at rest will remain unprotected in EC2. However, Prowler will only generate a finding if EBS volumes exist where default encryption could be enforced. @@ -56,7 +56,7 @@ Prowler scans only attached security groups to report vulnerabilities in activel - `ec2_networkacl_allow_ingress_X_port` -#### AWS Glue +#### AWS Glue AWS Glue best practices recommend encrypting metadata and connection passwords in Data Catalogs. @@ -71,7 +71,7 @@ Amazon Inspector is a vulnerability discovery service that automates continuous - `inspector2_is_enabled` -#### Amazon Macie +#### Amazon Macie Amazon Macie leverages machine learning to automatically discover, classify, and protect sensitive data in S3 buckets. Prowler only generates findings if Macie is disabled and there are S3 buckets in the AWS account. @@ -83,7 +83,7 @@ A network firewall is essential for monitoring and controlling traffic within a - `networkfirewall_in_all_vpc` -#### Amazon S3 +#### Amazon S3 To prevent unintended data exposure: @@ -91,7 +91,7 @@ Public Access Block should be enabled at the account level. Prowler only checks - `s3_account_level_public_access_blocks` -#### Virtual Private Cloud (VPC) +#### Virtual Private Cloud (VPC) VPC settings directly impact network security and availability. diff --git a/docs/user-guide/compliance/tutorials/threatscore.mdx b/docs/user-guide/compliance/tutorials/threatscore.mdx index 0a2f2f77b9..bb5a006342 100644 --- a/docs/user-guide/compliance/tutorials/threatscore.mdx +++ b/docs/user-guide/compliance/tutorials/threatscore.mdx @@ -2,6 +2,9 @@ title: "Prowler ThreatScore Documentation" --- + + + ## Introduction The **Prowler ThreatScore** is a comprehensive compliance scoring system that provides a unified metric for assessing your organization's security posture across compliance frameworks. It aggregates findings from individual security checks into a single, normalized score ranging from 0 to 100. diff --git a/docs/user-guide/img/add-cloud-provider.png b/docs/user-guide/img/add-cloud-provider.png index dd42e73047..d8f19b2054 100644 Binary files a/docs/user-guide/img/add-cloud-provider.png and b/docs/user-guide/img/add-cloud-provider.png differ diff --git a/docs/user-guide/img/cloud-providers-page.png b/docs/user-guide/img/cloud-providers-page.png index 0581e0132f..dcbce73a10 100644 Binary files a/docs/user-guide/img/cloud-providers-page.png and b/docs/user-guide/img/cloud-providers-page.png differ diff --git a/docs/user-guide/img/lighthouse-architecture.png b/docs/user-guide/img/lighthouse-architecture.png deleted file mode 100644 index 63202ce7c7..0000000000 Binary files a/docs/user-guide/img/lighthouse-architecture.png and /dev/null differ diff --git a/docs/user-guide/img/rbac/membership.png b/docs/user-guide/img/rbac/membership.png deleted file mode 100644 index e2b96e40f5..0000000000 Binary files a/docs/user-guide/img/rbac/membership.png and /dev/null differ diff --git a/docs/user-guide/providers/alibabacloud/authentication.mdx b/docs/user-guide/providers/alibabacloud/authentication.mdx new file mode 100644 index 0000000000..0baa160e69 --- /dev/null +++ b/docs/user-guide/providers/alibabacloud/authentication.mdx @@ -0,0 +1,112 @@ +--- +title: 'Alibaba Cloud Authentication in Prowler' +--- + +Prowler requires Alibaba Cloud credentials to perform security checks. Authentication is supported via multiple methods, prioritized as follows: + +1. **Credentials URI** +2. **OIDC Role Authentication** +3. **ECS RAM Role** +4. **RAM Role Assumption** +5. **STS Temporary Credentials** +6. **Permanent Access Keys** +7. **Default Credential Chain** + +## Authentication Methods + +### Credentials URI (Recommended for Centralized Services) + +If `--credentials-uri` is provided (or `ALIBABA_CLOUD_CREDENTIALS_URI` environment variable), Prowler will retrieve credentials from the specified external URI endpoint. The URI must return credentials in the standard JSON format. + +```bash +export ALIBABA_CLOUD_CREDENTIALS_URI="http://localhost:8080/credentials" +prowler alibabacloud +``` + +### OIDC Role Authentication (Recommended for ACK/Kubernetes) + +If OIDC environment variables are set, Prowler will use OIDC authentication to assume the specified role. This is the most secure method for containerized applications running in ACK (Alibaba Container Service for Kubernetes) with RRSA enabled. + +Required environment variables: +- `ALIBABA_CLOUD_ROLE_ARN` +- `ALIBABA_CLOUD_OIDC_PROVIDER_ARN` +- `ALIBABA_CLOUD_OIDC_TOKEN_FILE` + +```bash +export ALIBABA_CLOUD_ROLE_ARN="acs:ram::123456789012:role/YourRole" +export ALIBABA_CLOUD_OIDC_PROVIDER_ARN="acs:ram::123456789012:oidc-provider/ack-rrsa-provider" +export ALIBABA_CLOUD_OIDC_TOKEN_FILE="/var/run/secrets/tokens/oidc-token" +prowler alibabacloud +``` + +### ECS RAM Role (Recommended for ECS Instances) + +When running on an ECS instance with an attached RAM role, Prowler can obtain credentials from the ECS instance metadata service. + +```bash +# Using CLI argument +prowler alibabacloud --ecs-ram-role RoleName + +# Or using environment variable +export ALIBABA_CLOUD_ECS_METADATA="RoleName" +prowler alibabacloud +``` + +### RAM Role Assumption (Recommended for Cross-Account) + +For cross-account access, use RAM role assumption. You must provide the initial credentials (access keys) and the target role ARN. + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id" +export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" +export ALIBABA_CLOUD_ROLE_ARN="acs:ram::123456789012:role/ProwlerAuditRole" +prowler alibabacloud +``` + +### STS Temporary Credentials + +If you already have temporary STS credentials, you can provide them via environment variables. + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID="your-sts-access-key-id" +export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-sts-access-key-secret" +export ALIBABA_CLOUD_SECURITY_TOKEN="your-sts-security-token" +prowler alibabacloud +``` + +### Permanent Access Keys + +You can use standard permanent access keys via environment variables. + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id" +export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" +prowler alibabacloud +``` + +## Required Permissions + +The credentials used by Prowler should have the minimum required permissions to audit the resources. At a minimum, the following permissions are recommended: + +- `ram:GetUser` +- `ram:ListUsers` +- `ram:GetPasswordPolicy` +- `ram:GetAccountSummary` +- `ram:ListVirtualMFADevices` +- `ram:ListGroups` +- `ram:ListPolicies` +- `ram:ListAccessKeys` +- `ram:GetLoginProfile` +- `ram:ListPoliciesForUser` +- `ram:ListGroupsForUser` +- `actiontrail:DescribeTrails` +- `oss:GetBucketLogging` +- `oss:GetBucketAcl` +- `rds:DescribeDBInstances` +- `rds:DescribeDBInstanceAttribute` +- `ecs:DescribeInstances` +- `vpc:DescribeVpcs` +- `sls:ListProject` +- `sls:ListAlerts` +- `sls:ListLogStores` +- `sls:GetLogStore` diff --git a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx new file mode 100644 index 0000000000..c38cc42e37 --- /dev/null +++ b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx @@ -0,0 +1,162 @@ +--- +title: 'Getting Started With Alibaba Cloud on Prowler' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + +Prowler supports Alibaba Cloud both from the CLI and from Prowler Cloud. This guide walks you through the requirements, how to connect the provider in the UI, and how to run scans from the command line. + +## Prerequisites + +Before you begin, make sure you have: + +1. An **Alibaba Cloud Account ID** (visible in the Alibaba Cloud Console under your profile). +2. **Credentials** with appropriate permissions: + - **RAM User with Access Keys**: For static credential authentication. + - **RAM Role**: For cross-account access using role assumption (recommended). +3. The required permissions for Prowler to audit your resources. See the [Alibaba Cloud Authentication](/user-guide/providers/alibabacloud/authentication) guide for the full list of required permissions. + + + + Onboard Alibaba Cloud using Prowler Cloud + + + Onboard Alibaba Cloud using Prowler CLI + + + +## Prowler Cloud + + + +### Step 1: Get Your Alibaba Cloud Account ID + +1. Log in to the [Alibaba Cloud Console](https://home.console.alibabacloud.com/) +2. Click on your profile avatar in the top-right corner +3. Locate and copy your Account ID + +![Get Account ID](/images/providers/alibaba-account-id.png) + +### Step 2: Access Prowler Cloud or Prowler App + +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +2. Go to "Configuration" > "Cloud Providers" + + ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Cloud Provider" + + ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Alibaba Cloud" + + ![Select Alibaba Cloud](/images/providers/select-alibaba-cloud.png) + +5. Enter your Alibaba Cloud Account ID and optionally provide a friendly alias + + ![Add Account ID](/images/providers/add-alibaba-account-id.png) + +### Step 3: Choose and Provide Authentication + +After the Account ID is in place, select the authentication method that matches your Alibaba Cloud setup: + +![Select Auth Method](/images/providers/select-auth-method-alibaba.png) + +#### RAM Role Assumption (Recommended) + +Use this method for secure cross-account access. For detailed instructions on how to create the RAM role, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#ram-role-assumption-recommended-for-cross-account). + +1. Enter the **Role ARN** (format: `acs:ram:::role/`) +2. Enter the **Access Key ID** and **Access Key Secret** of the RAM user that will assume the role + + ![Input the Role ARN](/images/providers/alibaba-get-role-arn.png) + + +The RAM user whose credentials you provide must have permission to assume the target role. For more details, see the [Alibaba Cloud AssumeRole API documentation](https://www.alibabacloud.com/help/en/ram/developer-reference/api-sts-2015-04-01-assumerole). + + +#### Credentials (Static Access Keys) + +Use static credentials for quick scans (not recommended for production). For detailed setup, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#permanent-access-keys). + +1. Enter the **Access Key ID** and **Access Key Secret** + + ![Filled Credentials Page](/images/providers/alibaba-credentials-form.png) + + +Static access keys are long-lived credentials. For production environments, consider using RAM Role Assumption instead. + + +### Step 4: Launch the Scan + +1. Click "Next" to review your configuration +2. Click "Launch Scan" to start auditing your Alibaba Cloud account + + ![Launch Scan](/images/providers/launch-scan-alibaba.png) + +--- + +## Prowler CLI + + + +You can also run Alibaba Cloud assessments directly from the CLI. Both command-line flags and environment variables are supported. + +### Step 1: Select an Authentication Method + +Choose one of the following authentication methods. For the complete list and detailed configuration, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication). + +#### Environment Variables + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id" +export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" +prowler alibabacloud +``` + +#### RAM Role Assumption + +```bash +export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id" +export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret" +export ALIBABA_CLOUD_ROLE_ARN="acs:ram::123456789012:role/ProwlerAuditRole" +prowler alibabacloud +``` + +#### ECS RAM Role (for ECS instances) + +```bash +prowler alibabacloud --ecs-ram-role RoleName +``` + +### Step 2: Run the First Scan + +#### Scan all regions + +```bash +prowler alibabacloud +``` + +#### Scan specific regions + +```bash +prowler alibabacloud --regions cn-hangzhou cn-shanghai +``` + +#### Run specific checks + +```bash +prowler alibabacloud --checks ram_no_root_access_key ram_user_mfa_enabled_console_access +``` + +#### Run a compliance framework + +```bash +prowler alibabacloud --compliance cis_2.0_alibabacloud +``` + +### Additional Tips + +- Combine flags (for example, `--checks` or `--services`) just like with other providers. +- Use `--output-modes` to export findings in JSON, CSV, ASFF, etc. +- For more authentication options (OIDC, Credentials URI, STS), see the [Authentication guide](/user-guide/providers/alibabacloud/authentication). diff --git a/docs/user-guide/providers/aws/authentication.mdx b/docs/user-guide/providers/aws/authentication.mdx index 8814cb213d..b919cde8ec 100644 --- a/docs/user-guide/providers/aws/authentication.mdx +++ b/docs/user-guide/providers/aws/authentication.mdx @@ -49,8 +49,9 @@ This method grants permanent access and is the recommended setup for production ![External ID](/images/providers/prowler-cloud-external-id.png) ![Stack Data](/images/providers/fill-stack-data.png) - !!! info - An **External ID** is required when assuming the *ProwlerScan* role to comply with AWS [confused deputy prevention](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + + An **External ID** is required when assuming the *ProwlerScan* role to prevent the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + 6. Acknowledge the IAM resource creation warning and proceed diff --git a/docs/user-guide/providers/aws/getting-started-aws.mdx b/docs/user-guide/providers/aws/getting-started-aws.mdx index e6cc636a42..7d003d9821 100644 --- a/docs/user-guide/providers/aws/getting-started-aws.mdx +++ b/docs/user-guide/providers/aws/getting-started-aws.mdx @@ -37,7 +37,7 @@ title: 'Getting Started With AWS on Prowler' 6. Choose the preferred authentication method (next step) - ![Select auth method](/images/providers/select-auth-method.png) + ![Select auth method](./img/select-auth-method.png) ### Step 3: Set Up AWS Authentication @@ -84,6 +84,17 @@ For detailed instructions on how to create the role, see [Authentication > Assum ![Next button in Prowler Cloud](/images/providers/next-button-prowler-cloud.png) ![Launch Scan](/images/providers/launch-scan-button-prowler-cloud.png) + +Check if your AWS Security Token Service (STS) has the EU (Ireland) endpoint active. If not, we will not be able to connect to your AWS account. + +If that is the case your STS configuration may look like this: + +AWS Role + +To solve this issue, please activate the EU (Ireland) STS endpoint. + + + --- #### Credentials (Static Access Keys) diff --git a/docs/user-guide/providers/aws/regions-and-partitions.mdx b/docs/user-guide/providers/aws/regions-and-partitions.mdx index 2676a02ef2..02ae8fe6ab 100644 --- a/docs/user-guide/providers/aws/regions-and-partitions.mdx +++ b/docs/user-guide/providers/aws/regions-and-partitions.mdx @@ -6,15 +6,16 @@ By default Prowler is able to scan the following AWS partitions: - Commercial: `aws` - China: `aws-cn` +- European Sovereign Cloud: `aws-eusc` - GovCloud (US): `aws-us-gov` To check the available regions for each partition and service, refer to: [aws\_regions\_by\_service.json](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/aws_regions_by_service.json) -## Scanning AWS China and GovCloud Partitions in Prowler +## Scanning AWS China, European Sovereign Cloud and GovCloud Partitions in Prowler -When scanning the China (`aws-cn`) or GovCloud (`aws-us-gov`), ensure one of the following: +When scanning the China (`aws-cn`), European Sovereign Cloud (`aws-eusc`) or GovCloud (`aws-us-gov`) partitions, ensure one of the following: - Your AWS credentials include a valid region within the desired partition. @@ -83,6 +84,29 @@ To scan an account in the AWS GovCloud (US) partition (`aws-us-gov`): With this configuration, all partition regions will be scanned without needing the `-f/--region` flag + +### AWS European Sovereign Cloud + +To scan an account in the AWS European Sovereign Cloud partition (`aws-eusc`): + +- By using the `-f/--region` flag: + + ``` + prowler aws --region eusc-de-east-1 + ``` + +- By using the region configured in your AWS profile at `~/.aws/credentials` or `~/.aws/config`: + + ``` + [default] + aws_access_key_id = XXXXXXXXXXXXXXXXXXX + aws_secret_access_key = XXXXXXXXXXXXXXXXXXX + region = eusc-de-east-1 + ``` + + +With this configuration, all partition regions will be scanned without needing the `-f/--region` flag + ### AWS ISO (US \& Europe) @@ -99,6 +123,9 @@ The AWS ISO partitions—commonly referred to as "secret partitions"—are air-g "cn-north-1", "cn-northwest-1" ], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" diff --git a/docs/user-guide/providers/aws/role-assumption.mdx b/docs/user-guide/providers/aws/role-assumption.mdx index 3a2461b50b..b714beafbd 100644 --- a/docs/user-guide/providers/aws/role-assumption.mdx +++ b/docs/user-guide/providers/aws/role-assumption.mdx @@ -69,7 +69,19 @@ If your IAM Role is configured with Multi-Factor Authentication (MFA), use `--mf ## Creating a Role for One or Multiple Accounts -To create an IAM role that can be assumed in one or multiple AWS accounts, use either a CloudFormation Stack or StackSet and adapt the provided [template](https://github.com/prowler-cloud/prowler/blob/master/permissions/create_role_to_assume_cfn.yaml). +To create an IAM role that can be assumed in one or multiple AWS accounts, use either a CloudFormation Stack or StackSet with the provided [template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml). + +The template requires the following parameters: + +- **ExternalId:** A unique identifier to prevent the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) +- **AccountId:** *(Optional)* AWS Account ID that will assume the role (default: Prowler Cloud account) +- **IAMPrincipal:** *(Optional)* The IAM principal allowed to assume the role (default: `role/prowler*`) + +When running Prowler CLI, include the External ID using the `-I/--external-id` flag: + +```sh +prowler aws -R arn:aws:iam:::role/ProwlerScan -I +``` **Session Duration Considerations**: Depending on the number of checks performed and the size of your infrastructure, Prowler may require more than 1 hour to complete. Use the `-T ` option to allow up to 12 hours (43,200 seconds). If you need more than 1 hour, modify the _“Maximum CLI/API session duration”_ setting for the role. Learn more [here](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use.html#id_roles_use_view-role-max-session). diff --git a/docs/user-guide/providers/azure/authentication.mdx b/docs/user-guide/providers/azure/authentication.mdx index 4e33da0775..852e79d115 100644 --- a/docs/user-guide/providers/azure/authentication.mdx +++ b/docs/user-guide/providers/azure/authentication.mdx @@ -27,9 +27,9 @@ These permissions allow Prowler to retrieve metadata from the assumed identity a Assign the following Microsoft Graph permissions: +- `AuditLog.Read.All` - `Directory.Read.All` - `Policy.Read.All` -- `UserAuthenticationMethod.Read.All` (optional, for multifactor authentication (MFA) checks) Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permissions. Note that Entra checks related to DirectoryRoles and GetUsers will not run with this permission. @@ -48,21 +48,22 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss 3. Search and select: + - `AuditLog.Read.All` - `Directory.Read.All` - `Policy.Read.All` - - `UserAuthenticationMethod.Read.All` ![Permission Screenshots](/images/providers/domain-permission.png) 4. Click "Add permissions", then grant admin consent ![Grant Admin Consent](/images/providers/grant-admin-consent.png) + ![Granted Admin Consent](/images/providers/granted-admin-consent.png) 1. To grant permissions to a Service Principal, execute the following command in a terminal: ```console - az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role 38d9df27-64da-44fd-b7c5-a6fbac20248f=Role + az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role b0afded3-3588-46d8-8b3d-9842eff778da=Role ``` @@ -82,17 +83,17 @@ By default, Prowler scans all accessible subscriptions. If you need to audit spe 1. To grant Prowler access to scan a specific Azure subscription, follow these steps in Azure Portal: Navigate to the subscription you want to audit with Prowler. - 1. In the left menu, select "Access control (IAM)". + 2. In the left menu, select "Access control (IAM)". - 2. Click "+ Add" and select "Add role assignment". + 3. Click "+ Add" and select "Add role assignment". - 3. In the search bar, enter `Reader`, select it and click "Next". + 4. In the search bar, enter `Reader`, select it and click "Next". - 4. In the "Members" tab, click "+ Select members", then add the accounts to assign this role. + 5. In the "Members" tab, click "+ Select members", then add the accounts to assign this role. - 5. Click "Review + assign" to finalize and apply the role assignment. + 6. Click "Review + assign" to finalize and apply the role assignment. - ![Adding the Reader Role to a Subscription](/images/providers/add-reader-role.gif) + ![Adding the Reader Role to a Subscription](/images/providers/add-reader-role.png) 1. Open a terminal and execute the following command to assign the `Reader` role to the identity that is going to be assumed by Prowler: @@ -246,12 +247,223 @@ prowler azure --az-cli-auth *Available only for Prowler CLI* -Authenticate via Azure Managed Identity (when running on Azure resources): +Authenticate via Azure Managed Identity when running Prowler on Azure resources (VMs, Container Instances, Azure Functions, etc.): ```console prowler azure --managed-identity-auth ``` +### Prerequisites + +Before using Managed Identity authentication, the following steps are required: + +1. **Enable Managed Identity** on the Azure resource (e.g., VM, Container Instance) +2. **Assign the required permissions** to the Managed Identity on the target subscription(s) to scan + + +A common misconception is that enabling a Managed Identity on a resource automatically grants it permissions. **This is not the case.** Without explicit role assignments, Prowler will be unable to scan subscriptions and will return authorization errors, resulting in incomplete security assessments. The Managed Identity itself is a service principal that must be explicitly granted Reader and ProwlerRole permissions on each subscription to scan. + + +### Step-by-Step Setup Guide + +#### Step 1: Enable Managed Identity on the Azure Resource + + + + **Via Azure Portal:** + 1. Navigate to the VM in Azure Portal + 2. Select "Identity" from the left menu under "Security" + 3. Under "System assigned" tab, set Status to "On" + 4. Click "Save" + 5. Note the "Object (principal) ID" - this value is required for permission assignment + + **Via Azure CLI:** + ```console + # Enable system-assigned managed identity + az vm identity assign --name --resource-group + + # Get the principal ID + az vm identity show --name --resource-group --query principalId -o tsv + ``` + + + **Via Azure CLI:** + ```console + # Enable system-assigned managed identity + az container create \ + --resource-group \ + --name \ + --image \ + --assign-identity + + # Get the principal ID + az container show --resource-group --name --query identity.principalId -o tsv + ``` + + + +#### Step 2: Assign Reader Role to the Managed Identity + +The Managed Identity needs the **Reader** role on each subscription to scan. This role must be assigned to the **Managed Identity's principal ID**, not the VM or resource itself. + + + + 1. Navigate to the **target subscription** to scan (not the VM's resource group) + 2. Select "Access control (IAM)" from the left menu + 3. Click "+ Add" > "Add role assignment" + 4. Select "Reader" role, click "Next" + 5. Click "+ Select members" + 6. Search for the VM name or paste the Managed Identity's Object/Principal ID + 7. Select it and click "Select" + 8. Click "Review + assign" + + + When scanning a subscription different from where the VM is located, ensure the role is assigned on the **target subscription**, not the VM's subscription. + + + + ```console + # Get the principal ID of the resource's managed identity + PRINCIPAL_ID=$(az vm identity show --name --resource-group --query principalId -o tsv) + + # Assign Reader role on the target subscription + az role assignment create \ + --role "Reader" \ + --assignee-object-id $PRINCIPAL_ID \ + --assignee-principal-type ServicePrincipal \ + --scope /subscriptions/ + ``` + + + +#### Step 3: Create and Assign ProwlerRole to the Managed Identity + +The ProwlerRole is a custom role required for specific security checks. First, create the role if it does not exist, then assign it to the Managed Identity. + + + + **Create the ProwlerRole:** + ```console + az role definition create --role-definition '{ + "Name": "ProwlerRole", + "IsCustom": true, + "Description": "Role used for checks that require read-only access to Azure resources and are not covered by the Reader role.", + "AssignableScopes": ["/subscriptions/"], + "Actions": [ + "Microsoft.Web/sites/host/listkeys/action", + "Microsoft.Web/sites/config/list/Action" + ] + }' + ``` + + **Assign ProwlerRole to the Managed Identity:** + ```console + # Get the principal ID if not already available + PRINCIPAL_ID=$(az vm identity show --name --resource-group --query principalId -o tsv) + + # Assign ProwlerRole on the target subscription + az role assignment create \ + --role "ProwlerRole" \ + --assignee-object-id $PRINCIPAL_ID \ + --assignee-principal-type ServicePrincipal \ + --scope /subscriptions/ + ``` + + + Follow the same process as creating the ProwlerRole in the [Assigning ProwlerRole Permissions](/user-guide/providers/azure/authentication#assigning-prowlerrole-permissions-at-the-subscription-level) section, then assign it to the Managed Identity using the same steps as the Reader role assignment. + + + +#### Step 4: (Optional) Assign Microsoft Graph Permissions + +For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and `AuditLog.Read.All`. + + +Assigning Microsoft Graph API permissions to a Managed Identity requires Azure CLI or PowerShell - it cannot be done through the Azure Portal's standard role assignment interface. + + +```console +# Get the Managed Identity's principal ID +PRINCIPAL_ID=$(az vm identity show --name --resource-group --query principalId -o tsv) + +# Get Microsoft Graph's service principal ID +GRAPH_SP_ID=$(az ad sp list --display-name "Microsoft Graph" --query [0].id -o tsv) + +# Assign Directory.Read.All permission (App Role ID: 7ab1d382-f21e-4acd-a863-ba3e13f7da61) +az rest --method POST \ + --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$PRINCIPAL_ID/appRoleAssignments" \ + --headers "Content-Type=application/json" \ + --body "{\"principalId\": \"$PRINCIPAL_ID\", \"resourceId\": \"$GRAPH_SP_ID\", \"appRoleId\": \"7ab1d382-f21e-4acd-a863-ba3e13f7da61\"}" + +# Assign Policy.Read.All permission (App Role ID: 246dd0d5-5bd0-4def-940b-0421030a5b68) +az rest --method POST \ + --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$PRINCIPAL_ID/appRoleAssignments" \ + --headers "Content-Type=application/json" \ + --body "{\"principalId\": \"$PRINCIPAL_ID\", \"resourceId\": \"$GRAPH_SP_ID\", \"appRoleId\": \"246dd0d5-5bd0-4def-940b-0421030a5b68\"}" +``` + +#### Step 5: Run Prowler + +SSH or connect to the Azure resource and run Prowler: + +```console +# Scan all accessible subscriptions +prowler azure --managed-identity-auth + +# Scan specific subscription(s) +prowler azure --managed-identity-auth --subscription-ids +``` + + +Wait a few minutes after assigning roles for Azure to propagate permissions. Role assignments are not always immediately effective. + + +### Troubleshooting + +#### Error: "No subscriptions were found, please check your permission assignments" + +**Cause:** The Managed Identity does not have the Reader role assigned on any subscription. + +**Solution:** +- Verify the Managed Identity has the Reader role assigned on at least one subscription. +- Wait a few minutes after role assignment for Azure to propagate permissions. +- Verify role assignments: + ```console + az role assignment list --assignee --all + ``` + +#### Error: "does not have authorization to perform action 'Microsoft.Resources/subscriptions/read'" + +**Cause:** The Managed Identity lacks the Reader role on the target subscription. + +**Solution:** +- Ensure the Reader role is assigned to the **Managed Identity's principal ID**, not the VM resource. +- Verify the role is assigned on the **target subscription** to scan, not just the VM's resource group. +- Check role assignments: + ```console + az role assignment list --assignee --scope /subscriptions/ + ``` + +#### Error: "CredentialUnavailableError: ManagedIdentityCredential authentication unavailable" + +**Cause:** Managed Identity is not enabled on the resource, or Prowler is running outside of Azure. + +**Solution:** +- Verify Managed Identity is enabled on the Azure resource. +- Ensure Prowler is running from within the Azure resource (not a local machine). +- Check Managed Identity status: + ```console + az vm identity show --name --resource-group + ``` + +#### Error: Access token validation failure for Entra ID checks + +**Cause:** The Managed Identity lacks Microsoft Graph API permissions. + +**Solution:** +- Assign the required Graph API permissions as shown in Step 4. +- These permissions are optional for basic resource scanning but required for Entra ID security checks. + ## Browser Authentication *Available only for Prowler CLI* diff --git a/docs/user-guide/providers/azure/getting-started-azure.mdx b/docs/user-guide/providers/azure/getting-started-azure.mdx index f9f37b9f9b..a5299c614b 100644 --- a/docs/user-guide/providers/azure/getting-started-azure.mdx +++ b/docs/user-guide/providers/azure/getting-started-azure.mdx @@ -53,7 +53,8 @@ For detailed instructions on how to create the Service Principal and configure p ### Step 3: Add Credentials to Prowler App -Having completed the [Service Principal setup from the Authentication guide](/user-guide/providers/azure/authentication#service-principal-application-authentication-recommended): +For Azure, Prowler App uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application. + 1. Go to your App Registration overview and copy the `Client ID` and `Tenant ID` diff --git a/docs/user-guide/providers/cloudflare/authentication.mdx b/docs/user-guide/providers/cloudflare/authentication.mdx new file mode 100644 index 0000000000..8ee8b518cf --- /dev/null +++ b/docs/user-guide/providers/cloudflare/authentication.mdx @@ -0,0 +1,146 @@ +--- +title: 'Cloudflare Authentication in Prowler' +--- + +Prowler for Cloudflare supports the following authentication methods: + +- [**API Token**](#api-token-recommended) (**Recommended**) +- [**API Key and Email (Legacy)**](#api-key-and-email-legacy) + +## Required Permissions + +Prowler requires read-only access to your Cloudflare zones and their settings. The following permissions are needed: + +| Permission | Description | +|------------|-------------| +| `Zone:Read` | Read access to zone settings and configurations | +| `Zone Settings:Read` | Read access to zone security settings (SSL/TLS, HSTS, etc.) | +| `DNS:Read` | Read access to DNS records (for DNSSEC checks) | + + +Ensure your API Token or API Key has access to all zones you want to scan. If permissions are missing, some checks may fail or return incomplete results. + + +## API Token (Recommended) + +API Tokens are the recommended authentication method because they: +- Can be scoped to specific permissions and zones +- Are more secure than global API keys +- Can be easily rotated without affecting other integrations + +### Step 1: Create an API Token + +1. **Log into Cloudflare Dashboard** + - Go to [https://dash.cloudflare.com](https://dash.cloudflare.com) and sign in + +2. **Navigate to API Tokens** + - Click on your profile icon in the top right corner + - Select **My Profile** + - Click on the **API Tokens** tab + +3. **Create a Custom Token** + - Click **Create Token** + - Select **Create Custom Token** (at the bottom) + +4. **Configure Token Permissions** + + Give your token a descriptive name (e.g., "Prowler Security Scanner") and add the [required permissions](#required-permissions) listed above. + +5. **Set Zone Resources** + - Under **Zone Resources**, select either: + - **Include → All zones** (to scan all zones in your account) + - **Include → Specific zone** (to limit access to specific zones) + +6. **Create and Copy Token** + - Click **Continue to summary** + - Review the permissions and click **Create Token** + - **Copy the token immediately** - Cloudflare will only show it once + +### Step 2: Store the Token Securely + +Store your API token as an environment variable: + +```bash +export CLOUDFLARE_API_TOKEN="your-api-token-here" +``` + + +Never commit API tokens to version control or share them in plain text. Use environment variables or a secrets manager. + + +## API Key and Email (Legacy) + +API Keys provide full access to your Cloudflare account. While supported, this method is less secure than API Tokens because it grants broader permissions. + +### Step 1: Get Your API Key + +1. **Log into Cloudflare Dashboard** + - Go to [https://dash.cloudflare.com](https://dash.cloudflare.com) and sign in + +2. **Navigate to API Tokens** + - Click on your profile icon in the top right corner + - Select **My Profile** + - Click on the **API Tokens** tab + +3. **View Global API Key** + - Scroll down to the **API Keys** section + - Click **View** next to **Global API Key** + - Enter your password to reveal the key + - Copy the API key + +### Step 2: Store Credentials Securely + +Store both your API key and email as environment variables: + +```bash +export CLOUDFLARE_API_KEY="your-api-key-here" +export CLOUDFLARE_API_EMAIL="your-email@example.com" +``` + + +The email must be the same email address used to log into your Cloudflare account. + + +## Best Practices + +### Security Recommendations + +- **Use API Tokens instead of API Keys** - Tokens can be scoped to specific permissions +- **Use environment variables** - Never hardcode credentials in scripts or commands +- **Rotate credentials regularly** - Create new tokens periodically and revoke old ones +- **Use least privilege** - Only grant the minimum permissions needed +- **Monitor token usage** - Review the Cloudflare audit log for suspicious activity + + +**Use only one authentication method at a time.** If both API Token and API Key + Email are set, Prowler will use the API Token and log an error message. + + +## Troubleshooting + +### "Missing X-Auth-Email header" Error + +This error occurs when using API Key authentication without providing the email address. Ensure both `CLOUDFLARE_API_KEY` and `CLOUDFLARE_API_EMAIL` are set. + +### "Authentication error" or "Permission denied" + +- Verify your API Token or API Key is correct and not expired +- Check that your token has the [required permissions](#required-permissions) +- Ensure your token has access to the zones you're trying to scan + +### "Both API Token and API Key and Email credentials are set" + +This warning appears when all three environment variables are set: +- `CLOUDFLARE_API_TOKEN` +- `CLOUDFLARE_API_KEY` +- `CLOUDFLARE_API_EMAIL` + +To resolve, unset the credentials you don't want to use: + +```bash +# To use API Token only (recommended) +unset CLOUDFLARE_API_KEY +unset CLOUDFLARE_API_EMAIL + +# Or to use API Key and Email only +unset CLOUDFLARE_API_TOKEN +``` diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx new file mode 100644 index 0000000000..d3c916750e --- /dev/null +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -0,0 +1,132 @@ +--- +title: 'Getting Started with Cloudflare' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx"; + + + +Prowler for Cloudflare allows you to scan your Cloudflare zones for security misconfigurations, including SSL/TLS settings, DNSSEC, HSTS, and more. + +## Prerequisites + +Before running Prowler with the Cloudflare provider, ensure you have: + +1. A Cloudflare account with at least one zone +2. One of the following authentication methods configured (see [Authentication](/user-guide/providers/cloudflare/authentication)): + - An **API Token** (recommended) + - An **API Key + Email** (legacy) + +## Quick Start + +### Step 1: Set Up Authentication + +The recommended method is using an API Token via environment variable: + +```bash +export CLOUDFLARE_API_TOKEN="your-api-token-here" +``` + +Alternatively, use API Key + Email: + +```bash +export CLOUDFLARE_API_KEY="your-api-key-here" +export CLOUDFLARE_API_EMAIL="your-email@example.com" +``` + +### Step 2: Run Prowler + +Run a scan across all your Cloudflare zones: + +```bash +prowler cloudflare +``` + +That's it! Prowler will automatically discover all zones in your account and run security checks against them. + +## Authentication + +Prowler reads Cloudflare credentials from environment variables. Set your credentials before running Prowler: + +**API Token (Recommended):** +```bash +export CLOUDFLARE_API_TOKEN="your-api-token-here" +prowler cloudflare +``` + +**API Key + Email (Legacy):** +```bash +export CLOUDFLARE_API_KEY="your-api-key-here" +export CLOUDFLARE_API_EMAIL="your-email@example.com" +prowler cloudflare +``` + +## Filtering Zones + +By default, Prowler scans all zones accessible with your credentials: + +```bash +prowler cloudflare +``` + +To scan only specific zones, use the `-f`, `--region`, or `--filter-region` argument: + +```bash +prowler cloudflare -f example.com +``` + +You can specify multiple zones: + +```bash +prowler cloudflare -f example.com example.org +``` + +You can also use zone IDs instead of domain names: + +```bash +prowler cloudflare -f 023e105f4ecef8ad9ca31a8372d0c353 +``` + +## Filtering Accounts + +By default, Prowler scans all accounts accessible with your credentials. If your API Token or API Key has access to multiple Cloudflare accounts, you can restrict the scan to specific accounts using the `--account-id` argument: + +```bash +prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 +``` + +You can specify multiple account IDs: + +```bash +prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 9a7806061c88ada191ed06f989cc3dac +``` + + +If any of the provided account IDs are not found among the accounts accessible with your credentials, Prowler will raise an error and stop execution. + + +You can combine account and zone filtering to narrow the scan scope further: + +```bash +prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 -f example.com +``` + +## Configuration + +Prowler uses a configuration file to customize provider behavior. The Cloudflare configuration includes: + +```yaml +cloudflare: + # Maximum number of retries for API requests (default is 2) + max_retries: 2 +``` + +To use a custom configuration: + +```bash +prowler cloudflare --config-file /path/to/config.yaml +``` + +## Next Steps + +- [Authentication](/user-guide/providers/cloudflare/authentication) - Detailed guide on creating API tokens and keys diff --git a/docs/user-guide/providers/gcp/authentication.mdx b/docs/user-guide/providers/gcp/authentication.mdx index 7eea8ac5dd..9447d73a20 100644 --- a/docs/user-guide/providers/gcp/authentication.mdx +++ b/docs/user-guide/providers/gcp/authentication.mdx @@ -14,7 +14,10 @@ Prowler for Google Cloud supports multiple authentication methods. To use a spec Prowler for Google Cloud requires the following permissions: ### IAM Roles -- **Reader (`roles/reader`)** – Must be granted at the **project, folder, or organization** level to allow scanning of target projects. +- **Viewer (`roles/viewer`)** – Must be granted at the **project, folder, or organization** level to allow scanning of target projects. +- **Service Usage Consumer (`roles/serviceusage.serviceUsageConsumer`)** IAM Role – Required for resource scanning. +- **Custom `ProwlerRole`** – Include granular permissions that are not included in the Viewer role: + - `storage.buckets.getIamPolicy` ### Project-Level Settings @@ -106,18 +109,46 @@ prowler gcp --project-ids This method uses a service account with a downloaded key file for authentication. -### Create Service Account and Key +### Step 1: Create ProwlerRole -1. Go to the [Service Accounts page](https://console.cloud.google.com/iam-admin/serviceaccounts) in the GCP Console -2. Click "Create Service Account" -3. Fill in the service account details and click "Create and Continue" -4. Grant the service account the "Reader" role -5. Click "Done" -6. Find your service account in the list and click on it -7. Go to the "Keys" tab -8. Click "Add Key" > "Create new key" -9. Select "JSON" and click "Create" -10. Save the downloaded key file securely +To keep permissions focused: +1. Create a custom role named **ProwlerRole** that explicitly includes the permissions your compliance team approves. Click **Create role**, set the title to *ProwlerRole*, keep the ID readable (for example, `prowler_role`) +2. Add the required permission `storage.buckets.getIamPolicy` (the permission highlighted in the screenshots). To make it easier, filter the permissions by `Storage Admin` role. + +![Create a custom Prowler role](/user-guide/providers/gcp/img/roles-section.png) + +![Sample permissions for a custom Prowler role](/user-guide/providers/gcp/img/prowler-role.png) + +### Step 2: Create the Service Account + +1. Navigate to **IAM & Admin > Service Accounts** and make sure the correct project is selected. + + ![Service accounts landing page](/user-guide/providers/gcp/img/service-account-page.png) + +2. Select **Create service account**, provide a name, ID, and a short description that states the purpose (for example, “Service account to execute Prowler”), then click **Create and continue**. + + ![Create service account wizard](/user-guide/providers/gcp/img/create-service-account.png) + +3. Assign the roles you prepared earlier: + - **ProwlerRole** for `cloudstorage` service checks. + - **Viewer** for broad read-only visibility. + - **Service Usage Consumer** so Prowler can inspect API states. + + ![Assign roles to the service account](/user-guide/providers/gcp/img/service-account-permissions.png) + +4. Continue through the wizard and finish. No principals need to be granted access in step 3 unless you want other identities to impersonate this account. + +### Step 3: Generate a JSON Key + +1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**. + + ![Add a new key to the service account](/user-guide/providers/gcp/img/create-new-key.png) + +2. Select **JSON** as the key type and click **Create**. The browser downloads the file exactly once. + + ![Select JSON as the key type](/user-guide/providers/gcp/img/json-key.png) + +3. Once created, make sure to store the Key securely. ### Using with Prowler CLI diff --git a/docs/user-guide/providers/gcp/getting-started-gcp.mdx b/docs/user-guide/providers/gcp/getting-started-gcp.mdx index 218272793a..112f3f7de8 100644 --- a/docs/user-guide/providers/gcp/getting-started-gcp.mdx +++ b/docs/user-guide/providers/gcp/getting-started-gcp.mdx @@ -32,39 +32,51 @@ title: 'Getting Started With GCP on Prowler' ### Step 3: Set Up GCP Authentication -Choose the preferred authentication mode before proceeding: +For Google Cloud, first enter your `GCP Project ID` and then select the authentication method you want to use: -**User Credentials (Application Default Credentials)** +- **Service Account Authentication** (**Recommended**) + * Authenticates as a service identity + * Stable and auditable + * Recommended for production +- **Application Default Credentials** + * Quick scan as current user + * Uses Google Cloud CLI authentication + * Credentials may time out -* Quick scan as current user -* Uses Google Cloud CLI authentication -* Credentials may time out +**Service Account Authentication** is the recommended authentication method for automated systems and machine-to-machine interactions, like Prowler. For detailed information about this, refer to the [Google Cloud documentation](https://cloud.google.com/iam/docs/service-account-overview). -**Service Account Key File** +GCP Authentication Methods -* Authenticates as a service identity -* Stable and auditable -* Recommended for production + + + First of all, in the same project that you selected in the previous step, you need to create a service account and then generate a key in JSON format for it. For more information about this, you can follow the next Google Cloud documentation tutorials: -For detailed instructions on how to set up authentication, see [Authentication](/user-guide/providers/gcp/authentication). + - [Create a service account](https://cloud.google.com/iam/docs/creating-managing-service-accounts) + - [Generate a key for a service account](https://cloud.google.com/iam/docs/creating-managing-service-account-keys) -6. Once credentials are configured, return to Prowler App and enter the required values: + GCP Service Account Credentials + For detailed instructions on how to setup Service Account authentication, see the [Authentication](/user-guide/providers/gcp/authentication#service-account-authentication) page. + + + 1. Run the following command in your terminal to authenticate with GCP: - For "Service Account Key": + ```bash + gcloud auth application-default login + ``` - - `Service Account Key JSON` + 2. Once authenticated, get the `Client ID`, `Client Secret` and `Refresh Token` from `~/.config/gcloud/application_default_credentials`. - For "Application Default Credentials": + 3. Paste the `Client ID`, `Client Secret` and `Refresh Token` into Prowler App. - - `client_id` - - `client_secret` - - `refresh_token` + GCP Credentials + + + - ![Enter the Credentials](/images/providers/enter-credentials-prowler-cloud.png) 7. Click "Next", then "Launch Scan" - ![Launch Scan GCP](/images/providers/launch-scan.png) + ![Launch Scan GCP](./img/launch-scan.png) --- diff --git a/docs/user-guide/providers/gcp/img/create-new-key.png b/docs/user-guide/providers/gcp/img/create-new-key.png new file mode 100644 index 0000000000..db925004d5 Binary files /dev/null and b/docs/user-guide/providers/gcp/img/create-new-key.png differ diff --git a/docs/user-guide/providers/gcp/img/create-service-account.png b/docs/user-guide/providers/gcp/img/create-service-account.png new file mode 100644 index 0000000000..5c6bc516ca Binary files /dev/null and b/docs/user-guide/providers/gcp/img/create-service-account.png differ diff --git a/docs/user-guide/providers/gcp/img/json-key.png b/docs/user-guide/providers/gcp/img/json-key.png new file mode 100644 index 0000000000..1466000b7e Binary files /dev/null and b/docs/user-guide/providers/gcp/img/json-key.png differ diff --git a/docs/user-guide/providers/gcp/img/prowler-role.png b/docs/user-guide/providers/gcp/img/prowler-role.png new file mode 100644 index 0000000000..99da2f6edb Binary files /dev/null and b/docs/user-guide/providers/gcp/img/prowler-role.png differ diff --git a/docs/user-guide/providers/gcp/img/roles-section.png b/docs/user-guide/providers/gcp/img/roles-section.png new file mode 100644 index 0000000000..dcf068d967 Binary files /dev/null and b/docs/user-guide/providers/gcp/img/roles-section.png differ diff --git a/docs/user-guide/providers/gcp/img/service-account-page.png b/docs/user-guide/providers/gcp/img/service-account-page.png new file mode 100644 index 0000000000..588bb54816 Binary files /dev/null and b/docs/user-guide/providers/gcp/img/service-account-page.png differ diff --git a/docs/user-guide/providers/gcp/img/service-account-permissions.png b/docs/user-guide/providers/gcp/img/service-account-permissions.png new file mode 100644 index 0000000000..a4fce5e408 Binary files /dev/null and b/docs/user-guide/providers/gcp/img/service-account-permissions.png differ diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index d76a0a1cb5..de7ed163d6 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -38,6 +38,7 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained 4. **Configure Token Settings** - **Token name**: Give your token a descriptive name (e.g., "Prowler Security Scanner") + - **Resource owner**: Select the account that owns the resources to scan — either a personal account or a specific organization - **Expiration**: Set an appropriate expiration date (recommended: 90 days or less) - **Repository access**: Choose "All repositories" or "Only select repositories" based on your needs @@ -56,11 +57,11 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained - **Metadata**: Read-only access - **Pull requests**: Read-only access - - **Organization permissions:** + - **Organization permissions** (available when an organization is selected as Resource Owner): - **Administration**: Read-only access - **Members**: Read-only access - - **Account permissions:** + - **Account permissions** (available when a personal account is selected as Resource Owner): - **Email addresses**: Read-only access 6. **Copy and Store the Token** diff --git a/docs/user-guide/providers/github/getting-started-github.mdx b/docs/user-guide/providers/github/getting-started-github.mdx index 41d472df61..5a73c8355f 100644 --- a/docs/user-guide/providers/github/getting-started-github.mdx +++ b/docs/user-guide/providers/github/getting-started-github.mdx @@ -54,7 +54,7 @@ title: 'Getting Started with GitHub' ## Prowler CLI -### Automatic Login Method Detection +### Authentication If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence: @@ -68,15 +68,15 @@ Ensure the corresponding environment variables are set up before running Prowler For more details on how to set up authentication with GitHub, see [Authentication > GitHub](/user-guide/providers/github/authentication). -### Personal Access Token (PAT) +#### Personal Access Token (PAT) -Use this method by providing your personal access token directly. +Use this method by providing a personal access token directly. ```console prowler github --personal-access-token pat ``` -### OAuth App Token +#### OAuth App Token Authenticate using an OAuth app token. @@ -84,9 +84,62 @@ Authenticate using an OAuth app token. prowler github --oauth-app-token oauth_token ``` -### GitHub App Credentials +#### GitHub App Credentials + Use GitHub App credentials by specifying the App ID and the private key path. ```console prowler github --github-app-id app_id --github-app-key-path app_key_path ``` + +### Scan Scoping + +Scan scoping controls which repositories and organizations Prowler includes in a security assessment. By default, Prowler scans all repositories accessible to the authenticated user or organization. To limit the scan to specific repositories or organizations, use the following flags. + +#### Scanning Specific Repositories + +To restrict the scan to one or more repositories, use the `--repository` flag followed by the repository name(s) in `owner/repo-name` format: + +```console +prowler github --repository owner/repo-name +``` + +To scan multiple repositories, specify them as space-separated arguments: + +```console +prowler github --repository owner/repo-name-1 owner/repo-name-2 +``` + +#### Scanning Specific Organizations + +To restrict the scan to one or more organizations or user accounts, use the `--organization` flag: + +```console +prowler github --organization my-organization +``` + +To scan multiple organizations, specify them as space-separated arguments: + +```console +prowler github --organization org-1 org-2 +``` + +#### Scanning Specific Repositories Within an Organization + +To scan specific repositories within an organization, combine the `--organization` and `--repository` flags. The `--organization` flag qualifies unqualified repository names automatically: + +```console +prowler github --organization my-organization --repository my-repo +``` + +This scans only `my-organization/my-repo`. Fully qualified repository names (`owner/repo-name`) are also supported alongside `--organization`: + +```console +prowler github --organization my-org --repository my-repo other-owner/other-repo +``` + +In this case, `my-repo` is qualified as `my-org/my-repo`, while `other-owner/other-repo` is used as-is. + + +The `--repository` and `--organization` flags can be combined with any authentication method. + diff --git a/docs/user-guide/providers/iac/getting-started-iac.mdx b/docs/user-guide/providers/iac/getting-started-iac.mdx index a3efb0f2df..1a3bb20371 100644 --- a/docs/user-guide/providers/iac/getting-started-iac.mdx +++ b/docs/user-guide/providers/iac/getting-started-iac.mdx @@ -1,29 +1,91 @@ --- title: "Getting Started with the IaC Provider" --- +import { VersionBadge } from "/snippets/version-badge.mdx" Prowler's Infrastructure as Code (IaC) provider enables scanning of local or remote infrastructure code for security and compliance issues using [Trivy](https://trivy.dev/). This provider supports a wide range of IaC frameworks, allowing assessment of code before deployment. -## Supported Scanners +## Supported IaC Formats -The IaC provider leverages [Trivy](https://trivy.dev/latest/docs/scanner/vulnerability/) to support multiple scanners, including: +Prowler IaC provider scans the following Infrastructure as Code configurations for misconfigurations and secrets: -- Vulnerability -- Misconfiguration -- Secret -- License +| Configuration Type | File Patterns | +|--------------------|----------------------------------------------| +| Kubernetes | `*.yml`, `*.yaml`, `*.json` | +| Docker | `Dockerfile`, `Containerfile` | +| Terraform | `*.tf`, `*.tf.json`, `*.tfvars` | +| Terraform Plan | `tfplan`, `*.tfplan`, `*.json` | +| CloudFormation | `*.yml`, `*.yaml`, `*.json` | +| Azure ARM Template | `*.json` | +| Helm | `*.yml`, `*.yaml`, `*.tpl`, `*.tar.gz`, etc. | +| YAML | `*.yaml`, `*.yml` | +| JSON | `*.json` | +| Ansible | `*.yml`, `*.yaml`, `*.json`, `*.ini`, without extension | ## How It Works -- The IaC provider scans local directories (or specified paths) for supported IaC files, or scans remote repositories. +- Prowler App leverages [Trivy](https://trivy.dev/docs/latest/guide/coverage/iac/#scanner) to scan local directories (or specified paths) for supported IaC files, or scans remote repositories. - No cloud credentials or authentication are required for local scans. - For remote repository scans, authentication can be provided via [git URL](https://git-scm.com/docs/git-clone#_git_urls), CLI flags or environment variables. - Check the [IaC Authentication](/user-guide/providers/iac/authentication) page for more details. - Mutelist logic ([filtering](https://trivy.dev/latest/docs/configuration/filtering/)) is handled by Trivy, not Prowler. - Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.). +## Prowler App + + + +### Supported Scanners + +Scanner selection is not configurable in Prowler App. Default scanners, misconfig and secret, run automatically during each scan. + +### Step 1: Access Prowler Cloud/App + +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +2. Go to "Configuration" > "Cloud Providers" + + ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Cloud Provider" + + ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Infrastructure as Code" + + ![Select Infrastructure as Code](/images/providers/select-iac.png) + +5. Add the Repository URL and an optional alias, then click "Next" + + ![Add IaC Repository URL](/images/providers/add-iac-repo.png) + +### Step 2: Enter Authentication Details + +6. Optionally provide the [authentication](/user-guide/providers/iac/authentication) details for private repositories, then click "Next" + + ![IaC Authentication](/images/providers/iac-authentication.png) + +### Step 3: Verify Connection & Start Scan + +7. Review the provider configuration and click "Launch scan" to initiate the scan + + ![Verify Connection & Start Scan](/images/providers/iac-verify-connection.png) + + ## Prowler CLI + + +### Supported Scanners + +Prowler CLI supports the following scanners: + +- [Vulnerability](https://trivy.dev/docs/latest/guide/scanner/vulnerability/) +- [Misconfiguration](https://trivy.dev/docs/latest/guide/scanner/misconfiguration/) +- [Secret](https://trivy.dev/docs/latest/guide/scanner/secret/) +- [License](https://trivy.dev/docs/latest/guide/scanner/license/) + +By default, only misconfiguration and secret scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below. + ### Usage Use the `iac` argument to run Prowler with the IaC provider. Specify the directory or repository to scan, frameworks to include, and paths to exclude. @@ -64,7 +126,7 @@ Authentication for private repositories can be provided using one of the followi #### Specify Scanners -Scan only vulnerability and misconfiguration scanners: +To run only specific scanners, use the `--scanners` flag. For example, to scan only for vulnerabilities and misconfigurations: ```sh prowler iac --scan-path ./my-iac-directory --scanners vuln misconfig diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx new file mode 100644 index 0000000000..4fe509c2af --- /dev/null +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -0,0 +1,197 @@ +--- +title: "Getting Started with the Image Provider" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + +Prowler's Image provider enables comprehensive container image security scanning by integrating with [Trivy](https://trivy.dev/). This provider detects vulnerabilities, exposed secrets, and misconfigurations in container images, converting Trivy findings into Prowler's standard reporting format for unified security assessment. + +## How It Works + +* **Trivy integration:** Prowler leverages [Trivy](https://trivy.dev/) to scan container images for vulnerabilities, secrets, misconfigurations, and license issues. +* **Trivy required:** Trivy must be installed and available in the system PATH before running any scan. +* **Authentication:** No registry authentication is required for public images. For private registries, configure Docker credentials via `docker login` before scanning. +* **Output formats:** Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.). + +## Prowler CLI + + + + +The Image provider is currently available in Prowler CLI only. + + +### Install Trivy + +Install Trivy using one of the following methods: + + + + ```bash + brew install trivy + ``` + + + ```bash + sudo apt-get install trivy + ``` + + + ```bash + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + ``` + + + +For additional installation methods, see the [Trivy installation guide](https://trivy.dev/latest/getting-started/installation/). + + +### Supported Scanners + +Prowler CLI supports the following scanners: + +* [Vulnerability](https://trivy.dev/docs/latest/guide/scanner/vulnerability/) +* [Secret](https://trivy.dev/docs/latest/guide/scanner/secret/) +* [Misconfiguration](https://trivy.dev/docs/latest/guide/scanner/misconfiguration/) +* [License](https://trivy.dev/docs/latest/guide/scanner/license/) + +By default, only vulnerability and secret scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below. + +### Scan Container Images + +Use the `image` argument to run Prowler with the Image provider. Specify the images to scan using the `-I` flag or an image list file. + +#### Scan a Single Image + +To scan a single container image: + +```bash +prowler image -I alpine:3.18 +``` + +#### Scan Multiple Images + +To scan multiple images, repeat the `-I` flag: + +```bash +prowler image -I nginx:latest -I redis:7 -I python:3.12-slim +``` + +#### Scan From an Image List File + +For large-scale scanning, provide a file containing one image per line: + +```bash +prowler image --image-list images.txt +``` + +The file supports comments (lines starting with `#`) and blank lines: + +```text +# Production images +nginx:1.25 +redis:7-alpine + +# Development images +python:3.12-slim +node:20-bookworm +``` + + +Image list files are limited to a maximum of 10,000 lines. Individual image names exceeding 500 characters are automatically skipped with a warning. + + + +Image names must follow the Open Container Initiative (OCI) reference format. Valid names start with an alphanumeric character and contain only letters, digits, periods, hyphens, underscores, slashes, colons, and `@` symbols. Names containing shell metacharacters (`;`, `|`, `&`, `$`, `` ` ``) are rejected to prevent command injection. + + +Valid examples: +* **Standard tag:** `alpine:3.18` +* **Custom registry:** `myregistry.io/myapp:v1.0` +* **SHA digest:** `ghcr.io/org/image@sha256:abc123...` + +#### Specify Scanners + +To select which scanners Trivy runs, use the `--scanners` option. By default, Prowler enables `vuln` and `secret` scanners: + +```bash +# Vulnerability scanning only +prowler image -I alpine:3.18 --scanners vuln + +# All available scanners +prowler image -I alpine:3.18 --scanners vuln secret misconfig license +``` + + +#### Image Config Scanners + +To scan Dockerfile-level metadata for misconfigurations or embedded secrets, use the `--image-config-scanners` option: + +```bash +# Scan Dockerfile for misconfigurations +prowler image -I alpine:3.18 --image-config-scanners misconfig + +# Scan Dockerfile for both misconfigurations and secrets +prowler image -I alpine:3.18 --image-config-scanners misconfig secret +``` + +Available image config scanners: + +* **misconfig**: Detects Dockerfile misconfigurations (e.g., running as root, missing health checks) +* **secret**: Identifies secrets embedded in Dockerfile instructions + + +Image config scanners are disabled by default. This option is independent from `--scanners` and specifically targets the image configuration (Dockerfile) rather than the image filesystem. + + +#### Filter by Severity + +To filter findings by severity level, use the `--trivy-severity` option: + +```bash +# Only critical and high severity findings +prowler image -I alpine:3.18 --trivy-severity CRITICAL HIGH +``` + +Available severity levels: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `UNKNOWN`. + +#### Ignore Unfixed Vulnerabilities + +To exclude vulnerabilities without available fixes: + +```bash +prowler image -I alpine:3.18 --ignore-unfixed +``` + +#### Configure Scan Timeout + +To adjust the scan timeout for large images or slow network conditions, use the `--timeout` option: + +```bash +prowler image -I large-image:latest --timeout 10m +``` + +The timeout accepts values in seconds (`s`), minutes (`m`), or hours (`h`). Default: `5m`. + +### Authentication for Private Registries + +The Image provider relies on Trivy for registry authentication. To scan images from private registries, configure Docker credentials before running the scan: + +```bash +# Log in to a private registry +docker login myregistry.io + +# Then scan the image +prowler image -I myregistry.io/myapp:v1.0 +``` + +Trivy automatically uses credentials from Docker's credential store (`~/.docker/config.json`). + +### Troubleshooting Common Scan Errors + +The Image provider categorizes common Trivy errors with actionable guidance: + +* **Authentication failure (401/403):** Registry credentials are missing or invalid. Run `docker login` for the target registry and retry the scan. +* **Image not found (404):** The specified image name, tag, or registry is incorrect. Verify the image reference exists and is accessible. +* **Rate limited (429):** The container registry is throttling requests. Wait before retrying, or authenticate to increase rate limits. +* **Network issue:** Trivy cannot reach the registry due to connectivity problems. Check network access, DNS resolution, and firewall rules. diff --git a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx new file mode 100644 index 0000000000..0ec8215776 --- /dev/null +++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx @@ -0,0 +1,166 @@ +--- +title: 'Getting Started with Kubernetes' +--- + +## Prowler App + +### Step 1: Access Prowler Cloud/App + +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +2. Go to "Configuration" > "Cloud Providers" + + ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Cloud Provider" + + ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Kubernetes" + +5. Enter your Kubernetes Cluster context from your kubeconfig file and optionally provide a friendly alias + +### Step 2: Configure Kubernetes Authentication + +For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field. + +By default, the `kubeconfig` file is located at `~/.kube/config`. + +![Kubernetes Credentials](/images/kubernetes-credentials.png) + +### Step 3: Additional Setup for EKS, GKE, AKS, or External Clusters + +If you are adding an **EKS**, **GKE**, **AKS** or external cluster, follow these additional steps to ensure proper authentication: + +**Make sure your cluster allows traffic from the Prowler Cloud IP address `52.48.254.174/32`** + +1. Apply the necessary Kubernetes resources to your EKS, GKE, AKS or external cluster (you can find the files in the [`kubernetes` directory of the Prowler repository](https://github.com/prowler-cloud/prowler/tree/master/kubernetes)): + + ```console + kubectl apply -f kubernetes/prowler-sa.yaml + kubectl apply -f kubernetes/prowler-role.yaml + kubectl apply -f kubernetes/prowler-rolebinding.yaml + ``` + +2. Generate a long-lived token for authentication: + + ```console + kubectl create token prowler-sa -n prowler-ns --duration=0 + ``` + + - **Security Note:** The `--duration=0` option generates a non-expiring token, which may pose a security risk if not managed properly. Choose an appropriate expiration time based on security policies. For a limited-time token, set `--duration= + ### Browser Authentication Permissions When using browser authentication, permissions are delegated to the user, so the user must have the appropriate permissions rather than the application. @@ -52,37 +66,38 @@ When using browser authentication, permissions are delegated to the user, so the Browser and Azure CLI authentication methods limit scanning capabilities to checks that operate through Microsoft Graph API. Checks requiring PowerShell modules will not execute, as they need application-level permissions that cannot be delegated through browser authentication. + ### Step-by-Step Permission Assignment #### Create Application Registration -1. Access **Microsoft Entra ID** +1. Access **Microsoft Entra ID**. ![Overview of Microsoft Entra ID](/images/providers/microsoft-entra-id.png) -2. Navigate to "Applications" > "App registrations" +2. Navigate to "Applications" > "App registrations". ![App Registration nav](/images/providers/app-registration-menu.png) -3. Click "+ New registration", complete the form, and click "Register" +3. Click "+ New registration", complete the form, and click "Register". ![New Registration](/images/providers/new-registration.png) -4. Go to "Certificates & secrets" > "Client secrets" > "+ New client secret" +4. Go to "Certificates & secrets" > "Client secrets" > "+ New client secret". ![Certificate & Secrets nav](/images/providers/certificates-and-secrets.png) -5. Fill in the required fields and click "Add", then copy the generated value (this will be `AZURE_CLIENT_SECRET`) +5. Fill in the required fields and click "Add", then copy the generated value (this will be `AZURE_CLIENT_SECRET`). ![New Client Secret](/images/providers/new-client-secret.png) #### Grant Microsoft Graph API Permissions -1. Go to App Registration > Select your Prowler App > click on "API permissions" +1. Open **API permissions** for the Prowler application registration. ![API Permission Page](/images/providers/api-permissions-page.png) -2. Click "+ Add a permission" > "Microsoft Graph" > "Application permissions" +2. Click "+ Add a permission" > "Microsoft Graph" > "Application permissions". ![Add API Permission](/images/providers/add-app-api-permission.png) @@ -97,38 +112,39 @@ Browser and Azure CLI authentication methods limit scanning capabilities to chec ![Application Permissions](/images/providers/app-permissions.png) -4. Click "Add permissions", then click "Grant admin consent for ``" +4. Click "Add permissions", then click "Grant admin consent for ``". + #### Grant PowerShell Module Permissions 1. **Add Exchange API:** - - Search and select "Office 365 Exchange Online" API in **APIs my organization uses** + - Search and select "Office 365 Exchange Online" API in **APIs my organization uses**. ![Office 365 Exchange Online API](/images/providers/search-exchange-api.png) - - Select "Exchange.ManageAsApp" permission and click "Add permissions" + - Select "Exchange.ManageAsApp" permission and click "Add permissions". ![Exchange.ManageAsApp Permission](/images/providers/exchange-permission.png) - - Assign `Global Reader` role to the app: Go to `Roles and administrators` > click `here` for directory level assignment + - Assign `Global Reader` role to the app: Go to `Roles and administrators` > click `here` for directory level assignment. ![Roles and administrators](/images/providers/here.png) - - Search for `Global Reader` and assign it to your application + - Search for `Global Reader` and assign it to the application. ![Global Reader Role](/images/providers/global-reader-role.png) 2. **Add Teams API:** - - Search and select "Skype and Teams Tenant Admin API" in **APIs my organization uses** + - Search and select "Skype and Teams Tenant Admin API" in **APIs my organization uses**. ![Skype and Teams Tenant Admin API](/images/providers/search-skype-teams-tenant-admin-api.png) - - Select "application_access" permission and click "Add permissions" + - Select "application_access" permission and click "Add permissions". ![application_access Permission](/images/providers/teams-permission.png) -3. Click "Grant admin consent for ``" to grant admin consent +3. Click "Grant admin consent for ``" to grant admin consent. ![Grant Admin Consent](/images/providers/grant-external-api-permissions.png) @@ -136,11 +152,13 @@ Final permissions should look like this: ![Final Permissions](/images/providers/final-permissions.png) +Use the same application registration for both Prowler Cloud and Prowler CLI while switching authentication methods as needed. + ## Application Certificate Authentication (Recommended) -_Available for both Prowler App and Prowler CLI_ +_Available for both Prowler Cloud and Prowler CLI_ **Authentication flag for CLI:** `--certificate-auth` @@ -173,11 +191,11 @@ Guard `prowlerm365.key` and `prowlerm365.pfx`. Only upload the `.cer` file to th -If your organization uses a certificate authority, you can replace step 2 with a CSR workflow and import the signed certificate instead. +If an internal certificate authority is preferred, replace step 2 with a CSR workflow and import the signed certificate instead. ### Upload the Certificate to Microsoft Entra ID -1. Open **Microsoft Entra ID** > **App registrations** > your application. +1. Open **Microsoft Entra ID** > **App registrations** > the Prowler application. 2. Go to **Certificates & secrets** > **Certificates**. 3. Select **Upload certificate** and choose `prowlerm365.cer`. 4. Confirm the certificate appears with the expected expiration date. @@ -189,45 +207,37 @@ base64 -i prowlerm365.pfx -o prowlerm365.pfx.b64 cat prowlerm365.pfx.b64 | tr -d '\n' ``` -Copy the resulting single-line Base64 string (or the contents of `prowlerm365.pfx.b64`)—you will use it in the next step. +Copy the resulting single-line Base64 string (or the contents of `prowlerm365.pfx.b64`) for the next step. ### Provide the Certificate to Prowler -You can supply the private certificate to Prowler in two ways: +- **Prowler Cloud:** Paste the Base64-encoded PFX in the `certificate_content` field when configuring the Microsoft 365 provider in Prowler Cloud. +- **Prowler CLI:** Export credential variables or pass the local file path when running Prowler. -- **Environment variables (recommended for headless execution)** +```console +export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" +export AZURE_TENANT_ID="11111111-1111-1111-1111-111111111111" +export M365_CERTIFICATE_CONTENT="$(base64 < prowlerm365.pfx | tr -d '\n')" +``` - ```console - export AZURE_CLIENT_ID="00000000-0000-0000-0000-000000000000" - export AZURE_TENANT_ID="11111111-1111-1111-1111-111111111111" - export M365_CERTIFICATE_CONTENT="$(base64 < prowlerm365.pfx | tr -d '\n')" - ``` +Store the PFX securely and reference it when running the CLI: - The `M365_CERTIFICATE_CONTENT` variable must contain a single-line Base64 string. Remove any line breaks or spaces before exporting. +```console +python3 prowler-cli.py m365 --certificate-auth --certificate-path /secure/path/prowlerm365.pfx +``` -- **Local file path** - - Store the PFX securely and reference it when you run the CLI: - - ```console - python3 prowler-cli.py m365 --certificate-auth --certificate-path /secure/path/prowlerm365.pfx - ``` - - The CLI still needs `AZURE_CLIENT_ID` and `AZURE_TENANT_ID` in the environment when you use `--certificate-path`. - -For the **Prowler App**, paste the Base64-encoded PFX in the `certificate_content` field when you configure the provider secrets. The platform persists the encrypted certificate and supplies it during scans. +The CLI still needs `AZURE_CLIENT_ID` and `AZURE_TENANT_ID` in the environment when `--certificate-path` is used. Do not mix certificate authentication with a client secret. Provide either a certificate **or** a secret to the application registration and Prowler configuration. - ## Application Client Secret Authentication -_Available for both Prowler App and Prowler CLI_ +_Available for both Prowler Cloud and Prowler CLI_ **Authentication flag for CLI:** `--sp-env-auth` @@ -239,35 +249,59 @@ export AZURE_CLIENT_SECRET="XXXXXXXXX" export AZURE_TENANT_ID="XXXXXXXXX" ``` -If these variables are not set or exported, execution using `--sp-env-auth` will fail. - -Refer to the [Step-by-Step Permission Assignment](#step-by-step-permission-assignment) section below for setup instructions. - -If the external API permissions described in the mentioned section above are not added only checks that work through MS Graph will be executed. This means that the full provider will not be executed. - -This workflow is helpful for initial validation or temporary access. Plan to transition to certificate-based authentication to remove long-lived secrets and keep full provider coverage in unattended environments. +If these variables are not set or exported, execution using `--sp-env-auth` will fail. This workflow is helpful for initial validation or temporary access. Plan to transition to certificate-based authentication to remove long-lived secrets and keep full provider coverage in unattended environments. To scan every M365 check, ensure the required permissions are added to the application registration. Refer to the [PowerShell Module Permissions](#grant-powershell-module-permissions-for-app-only-authentication) section for more information. -### Run Prowler with Certificate Authentication +If the external API permissions described above are not added, only checks that work through Microsoft Graph will be executed. This means that the full provider will not be executed. -After the variables or path are in place, run the Microsoft 365 provider as usual: +## Prowler Cloud Authentication + +Use the shared permissions and credentials above, then complete the Microsoft 365 provider form in Prowler Cloud. The platform persists the encrypted credentials and supplies them during scans. + +### Application Certificate Authentication (Recommended) + +1. Select **Application Certificate Authentication**. +2. Enter the **tenant ID** and **application (client) ID**. +3. Paste the Base64-encoded certificate content. + +This method keeps all Microsoft 365 checks available, including PowerShell-based checks. + +### Application Client Secret Authentication + +1. Select **Application Client Secret Authentication**. +2. Enter the **tenant ID** and **application (client) ID**. +3. Enter the **client secret**. + +## Prowler CLI Authentication + +### Certificate Authentication + +**Authentication flag for CLI:** `--certificate-auth` + +After credentials are exported, launch the Microsoft 365 provider with certificate authentication: ```console python3 prowler-cli.py m365 --certificate-auth --init-modules --log-level ERROR ``` -The command above initializes PowerShell modules if needed. You can combine other standard flags (for example, `--region M365USGovernment` or custom outputs) with `--certificate-auth`. +Prowler prints the certificate thumbprint during execution so the correct credential can be verified. -Prowler prints the certificate thumbprint during execution so you can confirm the correct credential is in use. +### Client Secret Authentication + +**Authentication flag for CLI:** `--sp-env-auth` + +After exporting the secret-based variables, run: + +```console +python3 prowler-cli.py m365 --sp-env-auth --init-modules --log-level ERROR +``` -## Azure CLI Authentication - -_Available only for Prowler CLI_ +### Azure CLI Authentication **Authentication flag for CLI:** `--az-cli-auth` @@ -279,7 +313,7 @@ az login --tenant az account set --tenant ``` -If you prefer to reuse the same service principal that powers certificate-based authentication, authenticate it through Azure CLI instead of exporting environment variables. Azure CLI expects the certificate in PEM format; convert the PFX produced earlier and sign in: +If reusing the same service principal that powers certificate-based authentication, authenticate it through Azure CLI instead of exporting environment variables. Azure CLI expects the certificate in PEM format; convert the PFX produced earlier and sign in: ```console openssl pkcs12 -in prowlerm365.pfx -out prowlerm365.pem -nodes @@ -297,11 +331,9 @@ python3 prowler-cli.py m365 --az-cli-auth The Azure CLI identity must hold the same Microsoft Graph and external API permissions required for the full provider. Signing in with a user account limits the scan to delegated Microsoft Graph endpoints and skips PowerShell-based checks. Use a service principal with the necessary application permissions to keep complete coverage. -## Interactive Browser Authentication +### Interactive Browser Authentication -_Available only for Prowler CLI_ - -**Authentication flag:** `--browser-auth` +**Authentication flag for CLI:** `--browser-auth` Authenticate against Azure using the default browser to start the scan. The `--tenant-id` flag is also required. diff --git a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx index 19844b9347..ee8e5253b6 100644 --- a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx +++ b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx @@ -8,70 +8,81 @@ title: 'Getting Started With Microsoft 365 on Prowler' Government cloud accounts or tenants (Microsoft 365 Government) are currently unsupported, but we expect to add support for them in the near future. + ## Prerequisites -Configure authentication for Microsoft 365 by following the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide. This includes: +Set up authentication for Microsoft 365 with the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide before starting either path: -- Registering an application in Microsoft Entra ID -- Granting all required Microsoft Graph and external API permissions -- Generating the application certificate (recommended) or client secret -- Setting up PowerShell module permissions (for full security coverage) +- Register an application in Microsoft Entra ID +- Grant the Microsoft Graph and external API permissions listed for the provider +- Generate an application certificate (recommended) or client secret +- Prepare PowerShell module permissions to enable every check -## Prowler App + + + Onboard Microsoft 365 using Prowler Cloud + + + Onboard Microsoft 365 using Prowler CLI + + -### Step 1: Obtain Domain ID +## Prowler Cloud -1. Go to the Entra ID portal, then search for "Domain" or go to Identity > Settings > Domain Names +### Step 1: Locate the Domain ID + +1. Open the Entra ID portal, then search for "Domain" or go to Identity > Settings > Domain Names. ![Search Domain Names](/images/providers/search-domain-names.png) ![Custom Domain Names](/images/providers/custom-domain-names.png) -2. Select the domain to use as unique identifier for the Microsoft 365 account in Prowler App +2. Select the domain that acts as the unique identifier for the Microsoft 365 account in Prowler Cloud. -### Step 2: Access Prowler App +### Step 2: Open Prowler Cloud -1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Navigate to "Configuration" > "Cloud Providers" +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +2. Navigate to "Configuration" > "Cloud Providers". ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click on "Add Cloud Provider" +3. Click "Add Cloud Provider". ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) -4. Select "Microsoft 365" +4. Select "Microsoft 365". ![Select Microsoft 365](/images/providers/select-m365-prowler-cloud.png) -5. Add the Domain ID and an optional alias, then click "Next" +5. Add the Domain ID and an optional alias, then click "Next". ![Add Domain ID](/images/providers/add-domain-id.png) -### Step 3: Select Authentication Method and Provide Credentials +### Step 3: Choose and Provide Authentication -Prowler App now separates Microsoft 365 authentication into two app-only options. After adding the Domain ID, choose the method that matches your setup: +After the Domain ID is in place, select the app-only authentication option that matches the Microsoft Entra ID setup: M365 authentication method selection #### Application Certificate Authentication (Recommended) -1. Copy the Application (client) ID and Tenant ID from the app registration overview page. -2. Paste both values into the Prowler App form. -3. Upload the PFX bundle or paste the Base64-encoded certificate (`M365_CERTIFICATE_CONTENT`), then click **Test Connection**. +1. Enter the **tenant ID**, the unique identifier for the Microsoft Entra ID directory. +2. Enter the **application (client) ID**, the identifier for the Entra application registration. +3. Upload the **certificate file content** (Base64-encoded PFX). M365 certificate authentication form -Use this method whenever possible to avoid managing client secrets and to unlock every Microsoft 365 check, including those that require PowerShell modules. +Use this method to avoid managing secrets and to unlock all Microsoft 365 checks, including the PowerShell-based ones. Full setup steps are in the [Authentication guide](/user-guide/providers/microsoft365/authentication#application-certificate-authentication-recommended). #### Application Client Secret Authentication -1. From the app registration, copy the Application (client) ID and Tenant ID. -2. Paste both values plus the client secret into the Prowler App form. -3. Click **Test Connection** to validate the credentials. +1. Enter the **tenant ID**. +2. Enter the **application (client) ID**. +3. Enter the **client secret**. M365 client secret authentication form +For the complete setup workflow, follow the [Authentication guide](/user-guide/providers/microsoft365/authentication#application-client-secret-authentication). ### Step 4: Launch the Scan @@ -87,30 +98,30 @@ Use this method whenever possible to avoid managing client secrets and to unlock ## Prowler CLI -Use Prowler CLI to scan Microsoft 365 environments. +### Step 1: Confirm PowerShell Coverage -### PowerShell Requirements +PowerShell 7.4+ keeps the full Microsoft 365 coverage. Installation options are listed in the [Authentication guide](/user-guide/providers/microsoft365/authentication#supported-powershell-versions). -PowerShell 7.4+ is required for comprehensive Microsoft 365 security coverage. Installation instructions are available in the [Authentication guide](/user-guide/providers/microsoft365/authentication#supported-powershell-versions). +### Step 2: Select an Authentication Method -### Authentication Options - -Select an authentication method from the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide: +Choose the matching flag from the [Microsoft 365 Authentication](/user-guide/providers/microsoft365/authentication) guide: - **Application Certificate Authentication** (recommended): `--certificate-auth` - **Application Client Secret Authentication**: `--sp-env-auth` - **Azure CLI Authentication**: `--az-cli-auth` - **Interactive Browser Authentication**: `--browser-auth` -### Basic Usage +### Step 3: Run the First Scan -After configuring authentication, run a basic scan: +Run a baseline scan after credentials are configured: ```console prowler m365 --sp-env-auth ``` -For comprehensive scans including PowerShell checks: +### Step 4: Enable Full Coverage + +Include PowerShell module initialization to run every check: ```console prowler m365 --sp-env-auth --init-modules diff --git a/docs/user-guide/providers/mongodbatlas/authentication.mdx b/docs/user-guide/providers/mongodbatlas/authentication.mdx index f0a6d6408e..115c695673 100644 --- a/docs/user-guide/providers/mongodbatlas/authentication.mdx +++ b/docs/user-guide/providers/mongodbatlas/authentication.mdx @@ -1,5 +1,5 @@ --- -title: 'MongoDB Atlas Authentication' +title: 'MongoDB Atlas Authentication in Prowler' --- MongoDB Atlas provider uses [HTTP Digest Authentication with API key pairs consisting of a public key and private key](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-service). diff --git a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx index 1610187a0b..c10c6aac30 100644 --- a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx +++ b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx @@ -2,18 +2,83 @@ title: 'Getting Started with MongoDB Atlas' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + +Prowler supports MongoDB Atlas both from the CLI and from Prowler Cloud. This guide walks you through the requirements, how to connect the provider in the UI, and how to run scans from the command line. + +## Prerequisites + +Before you begin, make sure you have: + +1. A MongoDB Atlas organization with **API Access** enabled. +2. An **Organization ID** (24-character hex string). +3. An **API Key pair** (public and private keys) with appropriate permissions: + - **Organization Read Only**: Provides read-only access to everything in the organization, including all projects in the organization. This permission is sufficient for most security checks. + - **Organization Owner**: Required to audit the [Auditing configuration](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/group/endpoint-auditing) for projects. Database auditing tracks database operations and security events, including authentication attempts, data definition language (DDL) changes, user and role modifications, and privilege grants. This configuration is essential for security monitoring, forensics, and compliance. Without **Organization Owner** permission, the `projects_auditing_enabled` check cannot retrieve the audit configuration status. +4. Prowler App access (cloud or self-hosted) or the Prowler CLI (`pip install prowler`). + +For detailed instructions on creating API keys, see the [MongoDB Atlas authentication guide](./authentication.mdx). + + +If **Require IP Access List for the Atlas Administration API** is enabled in your organization settings, you **must** add the IP address of the host running Prowler (or the public IP of Prowler Cloud) to the organization IP Access List or Atlas will reject every API call. You can manage this under **Settings → Organization Settings → Security**. See step 7 of the [authentication guide](./authentication.mdx) for detailed instructions, and refer to the [Prowler Cloud public IP list](../../tutorials/prowler-cloud-public-ips) when using Prowler Cloud. + + + + + Onboard MongoDB Atlas using Prowler Cloud + + + Onboard MongoDB Atlas using Prowler CLI + + + +## Prowler Cloud + + + +### Step 1: Add the provider + +1. Navigate to **Cloud Providers** and click **Add Cloud Provider**. + ![Add provider list](./img/add-provider-list.png) +2. Select **MongoDB Atlas** from the provider list. +3. Enter your **Organization ID** (24 hex characters). This value is visible in the Atlas UI under **Organization Settings**. + ![Add organization ID](./img/add-org-id.png) +4. (Optional) Add a friendly alias to identify this organization in dashboards. + +### Step 2: Provide API credentials + +1. Click **Next** to open the credentials form. +2. Paste the **Atlas Public Key** and **Atlas Private Key** generated in the Atlas console. + ![Add credentials](./img/add-credentials.png) + +### Step 3: Test the connection and start scanning + +1. Click **Test connection** to ensure Prowler App can reach the Atlas API. +2. Save the credentials. The provider will appear in the list with its current connection status. +3. Launch a scan from the provider row or from the **Scans** page. + ![Launch scan](./img/launch-scan.png) + +--- + ## Prowler CLI -### Authentication Methods + -#### Command-Line Arguments +You can also run MongoDB Atlas assessments directly from the CLI. Both command-line flags and environment variables are supported. +### Step 1: Select an authentication method + +Choose one of the following authentication methods: + +#### Command-line arguments ```bash -prowler mongodbatlas --atlas-public-key --atlas-private-key +prowler mongodbatlas \ + --atlas-public-key \ + --atlas-private-key ``` -#### Environment Variables +#### Environment variables ```bash export ATLAS_PUBLIC_KEY= @@ -21,33 +86,28 @@ export ATLAS_PRIVATE_KEY= prowler mongodbatlas ``` +### Step 2: Run the first scan - -### Scan All Projects and Clusters - -After storing API keys, run Prowler with the following command: - -```bash -prowler mongodbatlas --atlas-public-key --atlas-private-key -``` - -Alternatively, set API keys as environment variables: - -```bash -export ATLAS_PUBLIC_KEY= -export ATLAS_PRIVATE_KEY= -``` - -Then run Prowler with the following command: +#### Scan all projects and clusters ```bash prowler mongodbatlas ``` -### Scanning a Specific Project +This command enumerates all projects accessible to the API key and scans every cluster. -To scan a specific project, add the following argument to the command above: +#### Scan a specific project + +Add the `--atlas-project-id` flag when you only want to assess one project: ```bash prowler mongodbatlas --atlas-project-id ``` + +### Additional tips + +- Combine flags (for example, `--checks` or `--services`) just like with other providers. +- Use `--output-modes` to export findings in JSON, CSV, ASFF, etc. +- Rotate API keys regularly and update the stored credentials in Prowler App to maintain connectivity. + +For more examples (filters, outputs, scheduling), refer back to the [MongoDB Atlas documentation hub](./authentication.mdx) and the main Prowler CLI usage guide. diff --git a/docs/user-guide/providers/mongodbatlas/img/add-credentials.png b/docs/user-guide/providers/mongodbatlas/img/add-credentials.png new file mode 100644 index 0000000000..646a8e9422 Binary files /dev/null and b/docs/user-guide/providers/mongodbatlas/img/add-credentials.png differ diff --git a/docs/user-guide/providers/mongodbatlas/img/add-org-id.png b/docs/user-guide/providers/mongodbatlas/img/add-org-id.png new file mode 100644 index 0000000000..b8306e95b0 Binary files /dev/null and b/docs/user-guide/providers/mongodbatlas/img/add-org-id.png differ diff --git a/docs/user-guide/providers/mongodbatlas/img/add-provider-list.png b/docs/user-guide/providers/mongodbatlas/img/add-provider-list.png new file mode 100644 index 0000000000..72e8341227 Binary files /dev/null and b/docs/user-guide/providers/mongodbatlas/img/add-provider-list.png differ diff --git a/docs/user-guide/providers/mongodbatlas/img/launch-scan.png b/docs/user-guide/providers/mongodbatlas/img/launch-scan.png new file mode 100644 index 0000000000..86684dc619 Binary files /dev/null and b/docs/user-guide/providers/mongodbatlas/img/launch-scan.png differ diff --git a/docs/user-guide/providers/oci/authentication.mdx b/docs/user-guide/providers/oci/authentication.mdx index 2ae73fb3af..f977a80eb2 100644 --- a/docs/user-guide/providers/oci/authentication.mdx +++ b/docs/user-guide/providers/oci/authentication.mdx @@ -1,5 +1,5 @@ --- -title: 'Oracle Cloud Infrastructure (OCI) Authentication' +title: 'Oracle Cloud Infrastructure (OCI) Authentication in Prowler' --- This guide covers all authentication methods supported by Prowler for Oracle Cloud Infrastructure (OCI). @@ -164,7 +164,7 @@ prowler oci --profile PRODUCTION Use a config file from a custom location: ```bash -prowler oci --config-file /path/to/custom/config +prowler oci --oci-config-file /path/to/custom/config ``` ### Setting Up API Keys @@ -377,7 +377,7 @@ ls -la ~/.oci/config mkdir -p ~/.oci # Specify custom location -prowler oci --config-file /path/to/config +prowler oci --oci-config-file /path/to/config ``` #### Error: "InvalidKeyOrSignature" diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 5d5d824ed2..8affa2f992 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -4,7 +4,41 @@ title: 'Getting Started with Oracle Cloud Infrastructure (OCI)' Prowler supports security scanning of Oracle Cloud Infrastructure (OCI) environments. This guide will help you get started with using Prowler to audit your OCI tenancy. -## Prerequisites +## Prowler Cloud + +The following steps apply to Prowler Cloud and the self-hosted Prowler App. + +### Step 1: Collect OCI Identifiers +1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID. +2. Go to **Identity & Security** → **Users**, select the principal that owns the API key, and copy the **User OCID**. +3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint. +4. Note the **Region** identifier to scan (for example, `us-ashburn-1`). + +### Step 2: Access Prowler Cloud or Prowler App +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +2. Go to **Configuration** → **Cloud Providers** and click **Add Cloud Provider**. +![Add OCI Cloud Provider](./images/oci-add-cloud-provider.png) +3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then choose **Next**. +![Add OCI Cloud Tenancy](./images/oci-add-tenancy.png) + +### Step 3: Add OCI API Key Credentials +Prowler App connects to OCI with API key credentials. Provide: + +- **User OCID** for the API key owner +- **Fingerprint** of the API key +- **Region** (for example, `us-ashburn-1`) +- **Private Key Content** (paste the full PEM value) +- **Passphrase (Optional)** if the private key is encrypted + +Select **Next**, then **Launch Scan** to validate the connection and start the first OCI scan. The private key content is encoded for secure transmission. + +![Add OCI API Key Credentials](./images/oci-add-api-key-credentials.png) + +--- + +## Prowler CLI + +### Prerequisites Before you begin, ensure you have: @@ -22,13 +56,13 @@ Before you begin, ensure you have: 3. **OCI Account Access** with appropriate permissions to read resources in your tenancy. -## Authentication +### Authentication -Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](./authentication.mdx). +Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](./authentication). **Note:** OCI Session Authentication and Config File Authentication both use the same `~/.oci/config` file. The difference is how the config file is generated - automatically via browser (session auth) or manually with API keys. -### Quick Start: OCI Session Authentication (Recommended) +#### Quick Start: OCI Session Authentication (Recommended) The easiest and most secure method is using OCI session authentication, which automatically generates your config file via browser login. @@ -71,13 +105,13 @@ The easiest and most secure method is using OCI session authentication, which au prowler oci ``` -### Alternative: Manual API Key Setup +#### Alternative: Manual API Key Setup -If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](./authentication.mdx#config-file-authentication-manual-api-key-setup). +If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](./authentication#config-file-authentication-manual-api-key-setup). **Note:** Both methods use the same `~/.oci/config` file - the difference is that manual setup uses static API keys while session authentication uses temporary session tokens. -#### Using a Specific Profile +##### Using a Specific Profile If you have multiple profiles in your OCI config: @@ -85,13 +119,13 @@ If you have multiple profiles in your OCI config: prowler oci --profile production ``` -#### Using a Custom Config File +##### Using a Custom Config File ```bash -prowler oci --config-file /path/to/custom/config +prowler oci --oci-config-file /path/to/custom/config ``` -### 2. Instance Principal Authentication +#### Instance Principal Authentication **IMPORTANT:** This authentication method **only works when Prowler is running inside an OCI compute instance**. If you're running Prowler from your local machine, use [OCI Session Authentication](#quick-start-oci-session-authentication-recommended) instead. @@ -110,39 +144,39 @@ prowler oci --use-instance-principal Allow dynamic-group prowler-instances to read all-resources in tenancy ``` -## Basic Usage +### Basic Usage -### Scan Entire Tenancy +#### Scan Entire Tenancy ```bash prowler oci ``` -### Scan Specific Region +#### Scan Specific Region ```bash prowler oci --region us-phoenix-1 ``` -### Scan Specific Compartments +#### Scan Specific Compartments ```bash prowler oci --compartment-id ocid1.compartment.oc1..example1 ocid1.compartment.oc1..example2 ``` -### Run Specific Checks +#### Run Specific Checks ```bash prowler oci --check identity_password_policy_minimum_length_14 ``` -### Run Specific Services +#### Run Specific Services ```bash prowler oci --service identity network ``` -### Compliance Frameworks +#### Compliance Frameworks Run CIS OCI Foundations Benchmark v3.0: @@ -150,11 +184,11 @@ Run CIS OCI Foundations Benchmark v3.0: prowler oci --compliance cis_3.0_oci ``` -## Required Permissions +### Required Permissions Prowler requires **read-only** permissions to audit your OCI tenancy. Below are the minimum required permissions: -### Tenancy-Level Policy +#### Tenancy-Level Policy Create a group `prowler-users` and add your user to it, then create this policy: @@ -167,7 +201,7 @@ Allow group prowler-users to read cloud-guard-problems in tenancy Allow group prowler-users to read cloud-guard-targets in tenancy ``` -### Service-Specific Permissions +#### Service-Specific Permissions For more granular control, you can grant specific permissions: @@ -217,33 +251,33 @@ Allow group prowler-users to inspect ons-subscriptions in tenancy Allow group prowler-users to inspect rules in tenancy ``` -## Output Formats +### Output Formats Prowler supports multiple output formats for OCI: -### JSON +#### JSON ```bash prowler oci --output-formats json ``` -### CSV +#### CSV ```bash prowler oci --output-formats csv ``` -### HTML +#### HTML ```bash prowler oci --output-formats html ``` -### Multiple Formats +#### Multiple Formats ```bash prowler oci --output-formats json csv html ``` -## Common Scenarios +### Common Scenarios -### Security Assessment +#### Security Assessment Full security assessment with CIS compliance: @@ -254,7 +288,7 @@ prowler oci \ --output-directory ./oci-assessment-$(date +%Y%m%d) ``` -### Continuous Monitoring +#### Continuous Monitoring Run specific security-critical checks: @@ -266,7 +300,7 @@ prowler oci \ --output-formats json ``` -### Compartment-Specific Audit +#### Compartment-Specific Audit Audit a specific project compartment: @@ -277,9 +311,9 @@ prowler oci \ --region us-ashburn-1 ``` -## Troubleshooting +### Troubleshooting -### Authentication Issues +#### Authentication Issues **Error: "Could not find a valid config file"** - Ensure `~/.oci/config` exists and is properly formatted @@ -291,23 +325,23 @@ prowler oci \ - Ensure the public key is uploaded to your OCI user account - Check that the private key file is accessible -### Permission Issues +#### Permission Issues **Error: "Authorization failed or requested resource not found"** - Verify your user has the required policies (see [Required Permissions](#required-permissions)) - Check that policies apply to the correct compartments - Ensure policies are not restricted by conditions that exclude your user -### Region Issues +#### Region Issues **Error: "Invalid region"** - Check available regions: `prowler oci --list-regions` - Verify your tenancy is subscribed to the region - Use the region identifier (e.g., `us-ashburn-1`), not the display name -## Advanced Usage +### Advanced Usage -### Using Mutelist +#### Using Mutelist Create a mutelist file to suppress specific findings: @@ -329,7 +363,7 @@ Run with mutelist: prowler oci --mutelist-file oci-mutelist.yaml ``` -### Custom Checks Metadata +#### Custom Checks Metadata Override check metadata: @@ -346,7 +380,7 @@ Run with custom metadata: prowler oci --custom-checks-metadata-file custom-metadata.yaml ``` -### Filtering by Status +#### Filtering by Status Only show failed checks: @@ -354,7 +388,7 @@ Only show failed checks: prowler oci --status FAIL ``` -### Filtering by Severity +#### Filtering by Severity Only show critical and high severity findings: @@ -362,13 +396,13 @@ Only show critical and high severity findings: prowler oci --severity critical high ``` -## Next Steps +### Next Steps - Learn about [Compliance Frameworks](/user-guide/cli/tutorials/compliance) in Prowler - Review [Prowler Output Formats](/user-guide/cli/tutorials/reporting) - Explore [Integrations](/user-guide/cli/tutorials/integrations) with SIEM and ticketing systems -## Additional Resources +### Additional Resources - [OCI Documentation](https://docs.oracle.com/en-us/iaas/Content/home.htm) - [CIS OCI Foundations Benchmark](https://www.cisecurity.org/benchmark/oracle_cloud) diff --git a/docs/user-guide/providers/oci/images/oci-add-api-key-credentials.png b/docs/user-guide/providers/oci/images/oci-add-api-key-credentials.png new file mode 100644 index 0000000000..886b6ee876 Binary files /dev/null and b/docs/user-guide/providers/oci/images/oci-add-api-key-credentials.png differ diff --git a/docs/user-guide/providers/oci/images/oci-add-cloud-provider.png b/docs/user-guide/providers/oci/images/oci-add-cloud-provider.png new file mode 100644 index 0000000000..f4757afad6 Binary files /dev/null and b/docs/user-guide/providers/oci/images/oci-add-cloud-provider.png differ diff --git a/docs/user-guide/providers/oci/images/oci-add-tenancy.png b/docs/user-guide/providers/oci/images/oci-add-tenancy.png new file mode 100644 index 0000000000..a671272034 Binary files /dev/null and b/docs/user-guide/providers/oci/images/oci-add-tenancy.png differ diff --git a/docs/user-guide/providers/openstack/authentication.mdx b/docs/user-guide/providers/openstack/authentication.mdx new file mode 100644 index 0000000000..88db4e221e --- /dev/null +++ b/docs/user-guide/providers/openstack/authentication.mdx @@ -0,0 +1,536 @@ +--- +title: 'OpenStack Authentication in Prowler' +--- + + +Prowler currently supports **public cloud OpenStack providers** (OVH, Infomaniak, Vexxhost, etc.). Support for self-deployed OpenStack environments is not yet available and will be added in future releases. + + +This guide shows how to obtain OpenStack credentials and configure Prowler to scan your OpenStack infrastructure using the recommended `clouds.yaml` authentication method. + +## Quick Start: Getting Your OpenStack Credentials + + + + ### Step 1: Create an OpenStack User with Reader Role + + Before using Prowler, create a dedicated user in your OVH Public Cloud account: + + 1. Log into the [OVH Control Panel](https://www.ovh.com/manager/) + 2. Navigate to "Public Cloud" → Select your project + 3. Click "Users & Roles" in the left sidebar + + ![OVH Users & Roles](./images/users.png) + + 4. Click "Add User" + 5. Enter a user description (e.g., `Prowler Audit User`) + 6. Assign the "Infrastructure Supervisor" role (this is the reader role) or specific read-only operator roles (if needed to audit only specific services) + + ![OVH Select Roles](./images/roles.png) + + 7. Click "Generate" to create the user + 8. Copy the password and store it securely + + + Avoid using administrator or member roles for security auditing. Reader or operator roles provide sufficient access for Prowler while maintaining security best practices. + + + ### Step 2: Access the Horizon Dashboard + + 1. From the OVH Control Panel, go to "Public Cloud" → Your project + 2. Click "Horizon" in the left sidebar (or access the Horizon URL provided by OVH) + + ![OVH Horizon](./images/horizon.png) + + 3. Log in with the user credentials created in Step 1. Ensure the correct user is selected; logging in with the root user will download root user credentials. If the wrong user is logged in, log out and log in again with the correct user. + + ### Step 3: Navigate to API Access + + Once logged into Horizon: + + 1. In the left sidebar, click "Project" + 2. Navigate to "API Access" + + ![OVH API Access](./images/api-access.png) + + 3. You'll see the API Access page with information about your OpenStack endpoints + + ### Step 4: Download the clouds.yaml File + + The `clouds.yaml` file contains all necessary credentials in the correct format for Prowler: + + 1. On the API Access page, look for the "Download OpenStack RC File" dropdown button + 2. Click the dropdown and select "OpenStack clouds.yaml File" + + ![OVH Download RC File](./images/download-yaml.png) + + 3. The file will be downloaded to your computer + + + The clouds.yaml file contains your password in plain text. Ensure you store it securely with appropriate file permissions (see [Security Best Practices](#security-best-practices) below). + + + ### Step 5: Configure clouds.yaml for Prowler + + Save the file to the default OpenStack configuration directory: + + ```bash + # Create the directory if it doesn't exist + mkdir -p ~/.config/openstack + + # Move or copy the downloaded clouds.yaml file + mv ~/Downloads/clouds.yaml ~/.config/openstack/clouds.yaml + + # Set secure file permissions + chmod 600 ~/.config/openstack/clouds.yaml + ``` + + The downloaded file will look similar to this: + + ```yaml + clouds: + openstack: + auth: + auth_url: https://auth.cloud.ovh.net/v3 + username: user-xxxxxxxxxx + password: your-password-here + project_id: your-project-id + project_name: your-project-name + user_domain_name: Default + project_domain_name: Default + region_name: GRA7 + interface: public + identity_api_version: 3 + ``` + + You can customize the cloud name (e.g., change `openstack` to `ovh-production`): + + ```yaml + clouds: + ovh-production: + auth: + auth_url: https://auth.cloud.ovh.net/v3 + username: user-xxxxxxxxxx + password: your-password-here + project_id: your-project-id + user_domain_name: Default + project_domain_name: Default + region_name: GRA7 + identity_api_version: "3" + ``` + + Alternatively, save the file to a custom location and specify the path when running Prowler: + + ```bash + # Save the clouds.yaml file to a custom location + mv ~/Downloads/clouds.yaml /path/to/my/clouds.yaml + + # Set secure file permissions + chmod 600 /path/to/my/clouds.yaml + ``` + + ### Step 6: Run Prowler + + Now you can scan your OVH OpenStack infrastructure: + + **Using the default location:** + ```bash + prowler openstack --clouds-yaml-cloud openstack + ``` + + Or if you customized the cloud name: + ```bash + prowler openstack --clouds-yaml-cloud ovh-production + ``` + + **Using a custom location:** + ```bash + prowler openstack --clouds-yaml-file /path/to/my/clouds.yaml --clouds-yaml-cloud openstack + ``` + + Prowler will authenticate with your OVH OpenStack cloud and begin scanning. + + + + ### Step 1: Create an OpenStack User with Reader Role + + Before using Prowler, create a dedicated user in your OpenStack public cloud account. The exact steps vary by provider (Infomaniak, Vexxhost, Fuga Cloud, etc.), but the general process is: + + 1. Log into your provider's control panel or management interface + 2. Navigate to your OpenStack project or account settings + 3. Find the user management section (typically named "Users", "Users & Roles", or "Access Management") + 4. Create a new user (e.g., `prowler-audit`) + 5. Assign the **Reader** role or equivalent read-only role to the user: + - **Reader**: Standard read-only access to all resources + - **Viewer**: Alternative read-only role (in some deployments) + - Avoid **Member** or **Admin** roles for security auditing + 6. Save the credentials (username and password) securely + + + Avoid using administrator or member roles for security auditing. Reader or Viewer roles provide sufficient access for Prowler while maintaining security best practices. + + + + Consult the provider's documentation for specific instructions on creating users and assigning roles. Consider contributing by opening an issue or pull request with instructions for additional providers. + + + ### Step 2: Access the Horizon Dashboard + + Horizon is the standard OpenStack web interface available across all OpenStack providers: + + 1. Find the Horizon dashboard link in your provider's control panel + - Look for "OpenStack Dashboard", "Horizon", "Web Console", or similar + 2. Access the Horizon URL (typically `https://your-provider-domain/horizon` or similar) + 3. Log in with the user credentials created in Step 1 + + + The Horizon dashboard interface is standardized across OpenStack providers, though branding and colors may vary. The navigation and functionality remain consistent. + + + ### Step 3: Navigate to API Access + + Once logged into Horizon: + + 1. In the left sidebar, click "Project" + 2. Navigate to "API Access" + 3. You'll see the API Access page with information about your OpenStack endpoints + + ### Step 4: Download the clouds.yaml File + + The `clouds.yaml` file contains all necessary credentials in the correct format for Prowler: + + 1. On the API Access page, look for the "Download OpenStack RC File" dropdown button + 2. Click the dropdown and select "OpenStack clouds.yaml File" + 3. The file will be downloaded to your computer + + + The clouds.yaml file contains your password in plain text. Ensure you store it securely with appropriate file permissions (see [Security Best Practices](#security-best-practices) below). + + + ### Step 5: Configure clouds.yaml for Prowler + + Save the file to the default OpenStack configuration directory: + + ```bash + # Create the directory if it doesn't exist + mkdir -p ~/.config/openstack + + # Move or copy the downloaded clouds.yaml file + mv ~/Downloads/clouds.yaml ~/.config/openstack/clouds.yaml + + # Set secure file permissions + chmod 600 ~/.config/openstack/clouds.yaml + ``` + + The downloaded file will look similar to this (values will vary by provider): + + ```yaml + clouds: + openstack: + auth: + auth_url: https://auth.example-cloud.com:5000/v3 + username: user-xxxxxxxxxx + password: your-password-here + project_id: your-project-id + project_name: your-project-name + user_domain_name: Default + project_domain_name: Default + region_name: RegionOne + interface: public + identity_api_version: 3 + ``` + + You can customize the cloud name (e.g., change `openstack` to `infomaniak-production`): + + ```yaml + clouds: + infomaniak-production: + auth: + auth_url: https://api.pub1.infomaniak.cloud/identity/v3 + username: user-xxxxxxxxxx + password: your-password-here + project_id: your-project-id + user_domain_name: Default + project_domain_name: Default + region_name: dc3-a + identity_api_version: "3" + ``` + + Alternatively, save the file to a custom location and specify the path when running Prowler: + + ```bash + # Save the clouds.yaml file to a custom location + mv ~/Downloads/clouds.yaml /path/to/my/clouds.yaml + + # Set secure file permissions + chmod 600 /path/to/my/clouds.yaml + ``` + + ### Step 6: Run Prowler + + Now you can scan your OpenStack infrastructure: + + **Using the default location:** + ```bash + prowler openstack --clouds-yaml-cloud openstack + ``` + + Or if you customized the cloud name: + ```bash + prowler openstack --clouds-yaml-cloud infomaniak-production + ``` + + **Using a custom location:** + ```bash + prowler openstack --clouds-yaml-file /path/to/my/clouds.yaml --clouds-yaml-cloud openstack + ``` + + Prowler will authenticate with your OpenStack cloud and begin scanning. + + + +## Managing Multiple OpenStack Environments + +To scan multiple OpenStack projects or providers, add multiple cloud configurations to your `clouds.yaml`: + +```yaml +clouds: + ovh-production: + auth: + auth_url: https://auth.cloud.ovh.net/v3 + username: user-prod + password: prod-password + project_id: prod-project-id + user_domain_name: Default + project_domain_name: Default + region_name: GRA7 + identity_api_version: "3" + + ovh-staging: + auth: + auth_url: https://auth.cloud.ovh.net/v3 + username: user-staging + password: staging-password + project_id: staging-project-id + user_domain_name: Default + project_domain_name: Default + region_name: SBG5 + identity_api_version: "3" + + infomaniak-production: + auth: + auth_url: https://api.pub1.infomaniak.cloud/identity/v3 + username: infomaniak-user + password: infomaniak-password + project_id: infomaniak-project-id + user_domain_name: Default + project_domain_name: Default + region_name: dc3-a + identity_api_version: "3" +``` + +Then scan each environment separately: + +```bash +prowler openstack --clouds-yaml-cloud ovh-production --output-directory ./reports/ovh-prod/ +prowler openstack --clouds-yaml-cloud ovh-staging --output-directory ./reports/ovh-staging/ +prowler openstack --clouds-yaml-cloud infomaniak-production --output-directory ./reports/infomaniak/ +``` + +## Creating a User With Reader Role + +For security auditing, Prowler only needs **read-only access** to your OpenStack resources. + +### Understanding OpenStack Roles + +OpenStack uses a role-based access control (RBAC) system. Common read-only roles include: + +| Role | Access Level | Recommended for Prowler | +|------|--------------|------------------------| +| **Reader** | Read-only access to all resources | ✅ **Recommended** | +| **Viewer** | Read-only access (older deployments) | ✅ **Recommended** | +| **Compute/Network/ObjectStore Operator** | Service-specific read-only access | ✅ **Recommended** (OVH) | +| **Member** | Read and limited write access | ⚠️ Too permissive | +| **Admin** | Full administrative access | ❌ **Not recommended** | + + +Avoid using administrator or member roles for security auditing. Reader or Viewer roles provide sufficient access for Prowler while maintaining security best practices. + + +### How to Assign the Reader Role + +The process for creating a user with the Reader role is covered in the [Quick Start](#quick-start-getting-your-openstack-credentials) section above. Select your provider's tab (OVH or Generic Public Cloud) for detailed instructions. + +### Verifying Read-Only Access + +After assigning read-only roles, verify the user cannot make changes: + +1. Log into Horizon with the Prowler user credentials +2. Attempt to create or modify a resource (e.g., create an instance) +3. The action should be denied or the UI should show read-only mode + + +Some OpenStack deployments may use custom role names. Consult your OpenStack administrator to identify the appropriate read-only role for your environment. + + +## Alternative Authentication Methods + +While `clouds.yaml` is the recommended method, Prowler also supports these alternatives: + +### Environment Variables + +Set OpenStack credentials as environment variables: + +```bash +export OS_AUTH_URL="https://openstack.example.com:5000/v3" +export OS_USERNAME="prowler-audit" +export OS_PASSWORD="your-secure-password" +export OS_PROJECT_ID="your-project-id" +export OS_REGION_NAME="RegionOne" +export OS_IDENTITY_API_VERSION="3" +export OS_USER_DOMAIN_NAME="Default" +export OS_PROJECT_DOMAIN_NAME="Default" +``` + +Then run Prowler: + +```bash +prowler openstack +``` + +### Command-Line Arguments (Flags) + +Pass credentials directly via CLI flags: + +```bash +prowler openstack \ + --os-auth-url https://openstack.example.com:5000/v3 \ + --os-username prowler-audit \ + --os-password your-secure-password \ + --os-project-id your-project-id \ + --os-user-domain-name Default \ + --os-project-domain-name Default \ + --os-identity-api-version 3 +``` + + +Avoid passing passwords via command-line arguments in production environments. Commands may appear in shell history, process listings, or logs. Use `clouds.yaml` or environment variables instead. + + +## Authentication Priority + +When multiple authentication methods are configured, Prowler uses this priority order: + +1. **clouds.yaml** (if `--clouds-yaml-file` or `--clouds-yaml-cloud` is provided) +2. **Command-line arguments + Environment variables** (CLI arguments override environment variables) + +## Security Best Practices + +### File Permissions + +Protect your `clouds.yaml` file from unauthorized access: + +```bash +# Set read/write for owner only +chmod 600 ~/.config/openstack/clouds.yaml + +# Verify permissions +ls -la ~/.config/openstack/clouds.yaml +# Should show: -rw------- (600) +``` + +### Credential Management + +- **Use dedicated audit users**: Create separate OpenStack users specifically for Prowler audits +- **Use read-only roles**: Assign only Reader or Viewer roles to limit access +- **Rotate credentials regularly**: Change passwords and regenerate credentials periodically +- **Use Application Credentials**: For advanced setups, use OpenStack Application Credentials with scoped permissions and expiration dates +- **Avoid hardcoding passwords**: Never commit `clouds.yaml` files with passwords to version control +- **Use secrets managers**: For production environments, consider using tools like HashiCorp Vault or AWS Secrets Manager to store credentials + +### Network Security + +- **Use HTTPS**: Always connect to OpenStack endpoints via HTTPS +- **Verify SSL certificates**: Avoid using `--insecure` flag in production +- **Restrict network access**: Use firewall rules to limit access to OpenStack APIs +- **Use VPN or private networks**: When possible, run Prowler from within your private network + +## Troubleshooting + +### "Missing mandatory OpenStack environment variables" Error + +This error occurs when required credentials are not configured: + +```bash +# Check current environment variables +env | grep OS_ + +# Verify clouds.yaml exists and is readable +cat ~/.config/openstack/clouds.yaml +``` + +**Solution**: Ensure all required credentials are configured using one of the authentication methods above. + +### "Failed to create OpenStack connection" Error + +This error indicates authentication failure. Verify: + +- ✅ Auth URL is correct and accessible: `curl -k https://auth-url/v3` +- ✅ Username and password are correct +- ✅ Project ID exists and you have access +- ✅ Network connectivity to the OpenStack endpoint +- ✅ SSL/TLS certificates are valid + +**Solution**: Test authentication using the OpenStack CLI: + +```bash +openstack --os-cloud openstack server list +``` + +If this fails, your credentials or network connectivity need attention. + +### "Cloud 'name' not found in clouds.yaml" Error + +This error occurs when the specified cloud name doesn't exist in `clouds.yaml`: + +**Solution**: +- Verify the cloud name matches exactly (case-sensitive) +- Check your `clouds.yaml` file for the correct cloud name: + ```bash + cat ~/.config/openstack/clouds.yaml + ``` +- Ensure proper YAML syntax (use a YAML validator if needed) + +### Permission Denied Errors + +If specific checks fail due to insufficient permissions: + +1. Verify role assignments: + ```bash + openstack role assignment list --user prowler-audit --project your-project + ``` + +2. Ensure the user has Reader or Viewer roles + +3. Check if specific services require additional permissions (consult your OpenStack administrator) + + +Using Public Cloud credentials can limit Keystone API access, so the command above may not work. Verify permissions in the provider's control panel instead. + + +## Next Steps + +- [Getting Started with OpenStack](/user-guide/providers/openstack/getting-started-openstack) - Run your first scan +- [Mutelist](/user-guide/cli/tutorials/mutelist) - Suppress known findings and false positives + +## Additional Resources + +### Provider-Specific Documentation + +- **OVH Public Cloud**: [OpenStack Documentation](https://help.ovhcloud.com/csm/en-gb-documentation-public-cloud-cross-functional?id=kb_browse_cat&kb_id=574a8325551974502d4c6e78b7421938&kb_category=32a89dbc81ef5a581e11e4879ea7a52b&spa=1) + +### OpenStack References + +- [OpenStack Documentation](https://docs.openstack.org/) +- [OpenStack Security Guide](https://docs.openstack.org/security-guide/) +- [clouds.yaml Format](https://docs.openstack.org/python-openstackclient/latest/configuration/index.html) diff --git a/docs/user-guide/providers/openstack/getting-started-openstack.mdx b/docs/user-guide/providers/openstack/getting-started-openstack.mdx new file mode 100644 index 0000000000..bfb840d303 --- /dev/null +++ b/docs/user-guide/providers/openstack/getting-started-openstack.mdx @@ -0,0 +1,285 @@ +--- +title: 'Getting Started With OpenStack' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for OpenStack allows you to audit your OpenStack cloud infrastructure for security misconfigurations, including compute instances, networking, identity and access management, storage, and more. + + +Prowler currently supports **public cloud OpenStack providers** (OVH, Infomaniak, Vexxhost, etc.). Support for self-deployed OpenStack environments is not yet available, if you are interested in this feature, please [open an issue](https://github.com/prowler-cloud/prowler/issues/new) or [contact us](https://prowler.com/contact). + + +## Prerequisites + +Before running Prowler with the OpenStack provider, ensure you have: + +1. An OpenStack public cloud account with at least one project +2. Access to the Horizon dashboard or provider control panel +3. An OpenStack user with the **Reader** role assigned to your project (see detailed instructions in the [Authentication guide](/user-guide/providers/openstack/authentication#creating-a-user-with-reader-role)) +4. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli)) or an account created in [Prowler Cloud](https://cloud.prowler.com) + + + + Run OpenStack security audits with Prowler CLI + + + Learn about OpenStack authentication options + + + +## Prowler CLI + +### Step 1: Set Up Authentication + +Download the `clouds.yaml` file from your OpenStack provider (see [Authentication guide](/user-guide/providers/openstack/authentication) for detailed instructions) and save it to `~/.config/openstack/clouds.yaml`: + +```bash +# Create the directory +mkdir -p ~/.config/openstack + +# Move the downloaded file +mv ~/Downloads/clouds.yaml ~/.config/openstack/clouds.yaml + +# Set secure permissions +chmod 600 ~/.config/openstack/clouds.yaml +``` + +Prowler supports multiple authentication methods: + +**Option 1: Using clouds.yaml (Recommended)** + +```bash +# Default location (~/.config/openstack/clouds.yaml) +prowler openstack --clouds-yaml-cloud openstack + +# Custom location +prowler openstack --clouds-yaml-file /path/to/clouds.yaml --clouds-yaml-cloud openstack +``` + +**Option 2: Using Environment Variables** + +```bash +export OS_AUTH_URL=https://auth.example.com:5000/v3 +export OS_USERNAME=user-xxxxxxxxxx +export OS_PASSWORD=your-password +export OS_PROJECT_ID=your-project-id +export OS_USER_DOMAIN_NAME=Default +export OS_PROJECT_DOMAIN_NAME=Default +export OS_IDENTITY_API_VERSION=3 + +prowler openstack +``` + +**Option 3: Using Flags (CLI Arguments)** + +```bash +prowler openstack \ + --os-auth-url https://auth.example.com:5000/v3 \ + --os-username user-xxxxxxxxxx \ + --os-password your-password \ + --os-project-id your-project-id \ + --os-user-domain-name Default \ + --os-project-domain-name Default \ + --os-identity-api-version 3 +``` + + +For detailed step-by-step instructions with screenshots, see the [OpenStack Authentication guide](/user-guide/providers/openstack/authentication). + + +### Step 2: Run Your First Scan + +Run a baseline scan of your OpenStack cloud: + +```bash +prowler openstack --clouds-yaml-cloud openstack +``` + +Replace `openstack` with your cloud name if you customized it in the `clouds.yaml` file (e.g., `ovh-production`). + +Prowler will automatically discover and audit all supported OpenStack services in your project. + +**Scan a specific OpenStack service:** + +```bash +# Audit only compute (Nova) resources +prowler openstack --services compute + +# Audit only networking (Neutron) resources +prowler openstack --services network + +# Audit only identity (Keystone) resources +prowler openstack --services identity +``` + +**Run specific security checks:** + +```bash +# Execute specific checks by name +prowler openstack --checks compute_instance_public_ip_associated + +# List all available checks +prowler openstack --list-checks +``` + +**Filter by check severity:** + +```bash +# Run only high or critical severity checks +prowler openstack --severity critical high +``` + +**Generate specific output formats:** + +```bash +# JSON only +prowler openstack --output-modes json + +# CSV and HTML +prowler openstack --output-modes csv html + +# All formats +prowler openstack --output-modes csv json html json-asff + +# Custom output directory +prowler openstack --output-directory /path/to/reports/ +``` + +**Scan multiple OpenStack clouds:** + +Configure `clouds.yaml` with multiple cloud configurations: + +```yaml +clouds: + production: + auth: + auth_url: https://prod.example.com:5000/v3 + username: prod-user + password: prod-password + project_id: prod-project-id + region_name: RegionOne + identity_api_version: "3" + + staging: + auth: + auth_url: https://staging.example.com:5000/v3 + username: staging-user + password: staging-password + project_id: staging-project-id + region_name: RegionOne + identity_api_version: "3" +``` + +Run audits against each environment: + +```bash +prowler openstack --clouds-yaml-cloud production --output-directory ./reports/production/ +prowler openstack --clouds-yaml-cloud staging --output-directory ./reports/staging/ +``` + +**Use mutelist to suppress findings:** + +Create a mutelist file to suppress known findings: + +```yaml +# mutelist.yaml +Mutelist: + Accounts: + "*": + Checks: + compute_instance_public_ip_associated: + Resources: + - "instance-id-1" + - "instance-id-2" + Reason: "Public IPs required for web servers" +``` + +Run with mutelist: + +```bash +prowler openstack --mutelist-file mutelist.yaml +``` + +### Step 3: Review the Results + +Prowler outputs findings to the console and generates reports in multiple formats. + +By default, Prowler generates reports in the `output/` directory: +- CSV format: `output/prowler-output-{timestamp}.csv` +- JSON format: `output/prowler-output-{timestamp}.json` +- HTML dashboard: `output/prowler-output-{timestamp}.html` + +## Supported OpenStack Services + +Prowler currently supports security checks for the following OpenStack services: + +| Common Name | OpenStack Service | Description | Example Checks | +|-------------|-------------------|-------------|----------------| +| **Compute** | Nova | Virtual machine instances | Public IP associations, security group usage | +| **Networking** | Neutron | Virtual networks and security | Security group rules, network isolation | +| **Identity** | Keystone | Authentication and authorization | Password policies, MFA configuration | +| **Image** | Glance | Virtual machine images | Image visibility, image encryption | +| **Block Storage** | Cinder | Persistent block storage | Volume encryption, backup policies | +| **Object Storage** | Swift | Object storage service | Container ACLs, public access | + + +Support for additional OpenStack services will be added in future releases. Check the [release notes](https://github.com/prowler-cloud/prowler/releases) for updates. + + +## Troubleshooting + +### Authentication Errors + +If encountering authentication errors: + +1. Verify credentials are correct: + ```bash + # Test OpenStack CLI with the same credentials + openstack --os-cloud openstack server list + ``` + +2. Check network connectivity to the authentication endpoint: + ```bash + curl https://openstack.example.com:5000/v3 + ``` + +3. Verify the Identity API version is v3: + ```bash + echo $OS_IDENTITY_API_VERSION + # Should output: 3 + ``` + +For detailed troubleshooting, see the [Authentication guide](/user-guide/providers/openstack/authentication#troubleshooting). + +### Permission Errors + +If checks are failing due to insufficient permissions: + +- Ensure your OpenStack user has the **Reader** role assigned to the project +- Check role assignments in your provider's control panel or Horizon dashboard +- Verify that your user has access to all required services (Compute, Networking, Identity, etc.) +- Contact your OpenStack provider support if you need additional permissions + +### Keystone/Identity Service Limitations + + +Public cloud OpenStack providers (OVH, Infomaniak, Vexxhost, etc.) typically **do not expose** the Keystone/Identity service API to customers for security reasons. This means that Identity-related security checks may not be available or may return limited information. + +This is expected behavior, not an error. This limitation explains why those checks are not currently available in Prowler. + + +If you see errors related to the Identity service: + +- This is expected behavior for public cloud providers +- Identity-related checks will be added for self-deployed OpenStack environments in future releases +- Focus on other available services (Compute, Networking, Storage, etc.) + +## OpenStack Additional Resources + +- **Supported OpenStack versions**: Stein (2019.1) and later +- **Minimum Identity API version**: v3 +- **Tested providers**: OVH Public Cloud, OpenStack-Ansible, DevStack +- **Cloud compatibility**: Fully compatible with standard OpenStack APIs diff --git a/docs/user-guide/providers/openstack/images/api-access.png b/docs/user-guide/providers/openstack/images/api-access.png new file mode 100644 index 0000000000..a59c2f7f5f Binary files /dev/null and b/docs/user-guide/providers/openstack/images/api-access.png differ diff --git a/docs/user-guide/providers/openstack/images/download-yaml.png b/docs/user-guide/providers/openstack/images/download-yaml.png new file mode 100644 index 0000000000..90ad0a58f7 Binary files /dev/null and b/docs/user-guide/providers/openstack/images/download-yaml.png differ diff --git a/docs/user-guide/providers/openstack/images/horizon.png b/docs/user-guide/providers/openstack/images/horizon.png new file mode 100644 index 0000000000..a2c9beac5f Binary files /dev/null and b/docs/user-guide/providers/openstack/images/horizon.png differ diff --git a/docs/user-guide/providers/openstack/images/roles.png b/docs/user-guide/providers/openstack/images/roles.png new file mode 100644 index 0000000000..04b1570ed6 Binary files /dev/null and b/docs/user-guide/providers/openstack/images/roles.png differ diff --git a/docs/user-guide/providers/openstack/images/users.png b/docs/user-guide/providers/openstack/images/users.png new file mode 100644 index 0000000000..e64808ae78 Binary files /dev/null and b/docs/user-guide/providers/openstack/images/users.png differ diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx new file mode 100644 index 0000000000..0a0179ee79 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx @@ -0,0 +1,169 @@ +--- +title: 'Using Multiple LLM Providers with Lighthouse' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler Lighthouse AI supports multiple Large Language Model (LLM) providers, offering flexibility to choose the provider that best fits infrastructure, compliance requirements, and cost considerations. This guide explains how to configure and use different LLM providers with Lighthouse AI. + +## Supported Providers + +Lighthouse AI supports the following LLM providers: + +- **OpenAI**: Provides access to GPT models (GPT-4o, GPT-4, etc.) +- **Amazon Bedrock**: Offers AWS-hosted access to Claude, Llama, Titan, and other models +- **OpenAI Compatible**: Supports custom endpoints like OpenRouter, Ollama, or any OpenAI-compatible service + +## Model Requirements + +For Lighthouse AI to work properly, models **must** support all of the following capabilities: + +- **Text input**: Ability to receive text prompts. +- **Text output**: Ability to generate text responses. +- **Tool calling**: Ability to invoke tools and functions to retrieve data from Prowler. + +If any of these capabilities are missing, the model will not be compatible with Lighthouse AI. + +## How Default Providers Work + +All three providers can be configured for a tenant, but only one can be set as the default provider. The first configured provider automatically becomes the default. + +When visiting Lighthouse AI chat, the default provider's default model loads automatically. Users can switch to any available LLM model (including those from non-default providers) using the dropdown in chat. + +Switch models in Lighthouse AI chat interface + +## Configuring Providers + +Navigate to **Configuration** → **Lighthouse AI** to see all three provider options with a **Connect** button under each. + +Prowler Lighthouse Configuration + +### Connecting a Provider + +To connect a provider: + +1. Click **Connect** under the desired provider +2. Enter the required credentials +3. Select a default model for that provider +4. Click **Connect** to save + + + + ### Required Information + + - **API Key**: OpenAI API key (starts with `sk-` or `sk-proj-`). API keys can be created from the [OpenAI platform](https://platform.openai.com/api-keys). + + ### Before Connecting + + - Ensure the OpenAI account has sufficient credits. + - Verify that the `gpt-5` model (recommended for Lighthouse AI) is not blocked in the OpenAI organization settings. + + + + Prowler connects to Amazon Bedrock using either [Amazon Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started-api-keys.html) or IAM credentials. + + + Amazon Bedrock models depend on AWS region and account entitlements. Lighthouse AI displays only accessible models that support tool calling and text input/output. + + + ### Amazon Bedrock Long-Term API Key + + + + + Amazon Bedrock Long-Term API keys are recommended only for exploration purposes. For production environments, use AWS IAM Access Keys with properly scoped permissions. + + + Amazon Bedrock API keys provide simpler authentication with automatically assigned permissions. + + #### Required Information + + - **Bedrock Long-Term API Key**: The API key generated from Amazon Bedrock. + - **AWS Region**: Region where Bedrock is available. + + + Amazon Bedrock Long-Term API keys are automatically assigned the necessary permissions (`AmazonBedrockLimitedAccess` policy). + + Learn more: [Getting Started with Amazon Bedrock API Keys](https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started-api-keys.html) + + + ### AWS IAM Access Keys + + Standard AWS IAM credentials can be used as an alternative authentication method. + + #### Required Information + + - **AWS Access Key ID**: The access key ID for the IAM user. + - **AWS Secret Access Key**: The secret access key for the IAM user. + - **AWS Region**: Region where Bedrock is available. + + #### Required Permissions + + The AWS IAM user must have the `AmazonBedrockLimitedAccess` managed policy attached: + + ```text + arn:aws:iam::aws:policy/AmazonBedrockLimitedAccess + ``` + + + Access to all Amazon Bedrock foundation models is enabled by default. When you select a model or invoke it for the first time (using Prowler or otherwise), you agree to Amazon's EULA. More info: [Amazon Bedrock Model Access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) + + + + + + Use this option to connect to any LLM provider exposing an OpenAI compatible API endpoint (OpenRouter, Ollama, etc.). + + ### Required Information + + - **API Key**: API key from the compatible service. + - **Base URL**: API endpoint URL including the API version (e.g., `https://openrouter.ai/api/v1`). + + ### Example: OpenRouter + + 1. Create an account at [OpenRouter](https://openrouter.ai/) + 2. [Generate an API key](https://openrouter.ai/docs/guides/overview/auth/provisioning-api-keys) from the OpenRouter dashboard + 3. Configure in Lighthouse AI: + - **API Key**: OpenRouter API key + - **Base URL**: `https://openrouter.ai/api/v1` + + + +## Changing the Default Provider + +To set a different provider as default: + +1. Navigate to **Configuration** → **Lighthouse AI** +2. Click **Configure** under the desired provider to set as default +3. Click **Set as Default** + +Set default LLM provider + +## Updating Provider Credentials + +To update credentials for a connected provider: + +1. Navigate to **Configuration** → **Lighthouse AI** +2. Click **Configure** under the provider +3. Enter the new credentials +4. Click **Update** + +## Deleting a Provider + +To remove a configured provider: + +1. Navigate to **Configuration** → **Lighthouse AI** +2. Click **Configure** under the provider +3. Click **Delete** + +## Model Recommendations + +For best results with Lighthouse AI, the recommended model is `gpt-5` from OpenAI. + +Models from other providers such as Amazon Bedrock and OpenAI Compatible endpoints can be connected and used, but performance is not guaranteed. Ensure that any selected model supports text input, text output, and tool calling capabilities. + +## Getting Help + +For issues or suggestions, [reach out through our Slack channel](https://goto.prowler.com/slack). diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx index abff053dc8..cf4886d2ee 100644 --- a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx +++ b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx @@ -1,213 +1,62 @@ --- -title: 'Prowler Lighthouse AI' +title: 'How It Works' --- import { VersionBadge } from "/snippets/version-badge.mdx" -Prowler Lighthouse AI is a Cloud Security Analyst chatbot that helps you understand, prioritize, and remediate security findings in your cloud environments. It's designed to provide security expertise for teams without dedicated resources, acting as your 24/7 virtual cloud security analyst. +Prowler Lighthouse AI integrates Large Language Models (LLMs) with Prowler security findings data. -Prowler Lighthouse +Behind the scenes, Lighthouse AI works as follows: -## How It Works - -Prowler Lighthouse AI uses OpenAI's language models and integrates with your Prowler security findings data. - -Here's what's happening behind the scenes: - -- The system uses a multi-agent architecture built with [LanggraphJS](https://github.com/langchain-ai/langgraphjs) for LLM logic and [Vercel AI SDK UI](https://sdk.vercel.ai/docs/ai-sdk-ui/overview) for frontend chatbot. -- It uses a ["supervisor" architecture](https://langchain-ai.lang.chat/langgraphjs/tutorials/multi_agent/agent_supervisor/) that interacts with different agents for specialized tasks. For example, `findings_agent` can analyze detected security findings, while `overview_agent` provides a summary of connected cloud accounts. -- The system connects to OpenAI models to understand, fetch the right data, and respond to the user's query. - -Lighthouse AI is tested against `gpt-4o` and `gpt-4o-mini` OpenAI models. - -- The supervisor agent is the main contact point. It is what users interact with directly from the chat interface. It coordinates with other agents to answer users' questions comprehensively. - -Lighthouse AI Architecture +- Lighthouse AI runs as a [Langchain agent](https://docs.langchain.com/oss/javascript/langchain/agents) in NextJS +- The agent connects to the configured LLM provider to understand the prompt and decide what data is needed +- The agent accesses Prowler data through [Prowler MCP](https://docs.prowler.com/getting-started/products/prowler-mcp), which exposes tools from multiple sources, including: + - Prowler Hub + - Prowler Docs + - Prowler App +- Instead of calling every tool directly, the agent uses two meta-tools: + - `describe_tool` to retrieve a tool schema and parameter requirements. + - `execute_tool` to run the selected tool with the required input. +- Based on the user's query and the data necessary to answer it, Lighthouse agent will invoke necessary Prowler MCP tools using `discover_tool` and `execute_tool` -All agents can only read relevant security data. They cannot modify your data or access sensitive information like configured secrets or tenant details. +Lighthouse AI supports multiple LLM providers including OpenAI, Amazon Bedrock, and OpenAI-compatible services. For configuration details, see [Using Multiple LLM Providers with Lighthouse](/user-guide/tutorials/prowler-app-lighthouse-multi-llm). + + +Prowler Lighthouse Architecture +Prowler Lighthouse Architecture + + + +Lighthouse AI can only read relevant security data. It cannot modify data or access sensitive information such as configured secrets or tenant details. -## Set up + +## Set Up Getting started with Prowler Lighthouse AI is easy: -1. Go to the configuration page in your Prowler dashboard. -2. Enter your OpenAI API key. -3. Select your preferred model. The recommended one for best results is `gpt-4o`. -4. (Optional) Add business context to improve response quality and prioritization. +1. Navigate to **Configuration** → **Lighthouse AI** +2. Click **Connect** under the desired provider (OpenAI, Amazon Bedrock, or OpenAI Compatible) +3. Enter the required credentials +4. Select a default model +5. Click **Connect** to save -Lighthouse AI Configuration + +For detailed configuration instructions for each provider, see [Using Multiple LLM Providers with Lighthouse](/user-guide/tutorials/prowler-app-lighthouse-multi-llm). + + +Lighthouse AI Configuration ### Adding Business Context -The optional business context field lets you provide additional information to help Lighthouse AI understand your environment and priorities, including: +The optional business context field lets teams provide additional information to help Lighthouse AI understand environment priorities, including: -- Your organization's cloud security goals +- Organization cloud security goals - Information about account owners or responsible teams -- Compliance requirements for your organization +- Compliance requirements - Current security initiatives or focus areas Better context leads to more relevant responses and prioritization that aligns with your needs. - -## Capabilities - -Prowler Lighthouse AI is designed to be your AI security team member, with capabilities including: - -### Natural Language Querying - -Ask questions in plain English about your security findings. Examples: - -- "What are my highest risk findings?" -- "Show me all S3 buckets with public access." -- "What security issues were found in my production accounts?" - -Natural language querying - -### Detailed Remediation Guidance - -Get tailored step-by-step instructions for fixing security issues: - -- Clear explanations of the problem and its impact -- Commands or console steps to implement fixes -- Alternative approaches with different solutions - -Detailed Remediation - -### Enhanced Context and Analysis - -Lighthouse AI can provide additional context to help you understand the findings: - -- Explain security concepts related to findings in simple terms -- Provide risk assessments based on your environment and context -- Connect related findings to show broader security patterns - -Business Context - -Contextual Responses - -## Important Notes - -Prowler Lighthouse AI is powerful, but there are limitations: - -- **Continuous improvement**: Please report any issues, as the feature may make mistakes or encounter errors, despite extensive testing. -- **Access limitations**: Lighthouse AI can only access data the logged-in user can view. If you can't see certain information, Lighthouse AI can't see it either. -- **NextJS session dependence**: If your Prowler application session expires or logs out, Lighthouse AI will error out. Refresh and log back in to continue. -- **Response quality**: The response quality depends on the selected OpenAI model. For best results, use gpt-4o. - -### Getting Help - -If you encounter issues with Prowler Lighthouse AI or have suggestions for improvements, please [reach out through our Slack channel](https://goto.prowler.com/slack). - -### What Data Is Shared to OpenAI? - -The following API endpoints are accessible to Prowler Lighthouse AI. Data from the following API endpoints could be shared with OpenAI depending on the scope of user's query: - -#### Accessible API Endpoints - -**User Management:** - -- List all users - `/api/v1/users` -- Retrieve the current user's information - `/api/v1/users/me` - -**Provider Management:** - -- List all providers - `/api/v1/providers` -- Retrieve data from a provider - `/api/v1/providers/{id}` - -**Scan Management:** - -- List all scans - `/api/v1/scans` -- Retrieve data from a specific scan - `/api/v1/scans/{id}` - -**Resource Management:** - -- List all resources - `/api/v1/resources` -- Retrieve data for a resource - `/api/v1/resources/{id}` - -**Findings Management:** - -- List all findings - `/api/v1/findings` -- Retrieve data from a specific finding - `/api/v1/findings/{id}` -- Retrieve metadata values from findings - `/api/v1/findings/metadata` - -**Overview Data:** - -- Get aggregated findings data - `/api/v1/overviews/findings` -- Get findings data by severity - `/api/v1/overviews/findings_severity` -- Get aggregated provider data - `/api/v1/overviews/providers` -- Get findings data by service - `/api/v1/overviews/services` - -**Compliance Management:** - -- List compliance overviews for a scan - `/api/v1/compliance-overviews` -- Retrieve data from a specific compliance overview - `/api/v1/compliance-overviews/{id}` - -#### Excluded API Endpoints - -Not all Prowler API endpoints are integrated with Lighthouse AI. They are intentionally excluded for the following reasons: - -- OpenAI/other LLM providers shouldn't have access to sensitive data (like fetching provider secrets and other sensitive config) -- Users queries don't need responses from those API endpoints (ex: tasks, tenant details, downloading zip file, etc.) - -**Excluded Endpoints:** - -**User Management:** - -- List specific users information - `/api/v1/users/{id}` -- List user memberships - `/api/v1/users/{user_pk}/memberships` -- Retrieve membership data from the user - `/api/v1/users/{user_pk}/memberships/{id}` - -**Tenant Management:** - -- List all tenants - `/api/v1/tenants` -- Retrieve data from a tenant - `/api/v1/tenants/{id}` -- List tenant memberships - `/api/v1/tenants/{tenant_pk}/memberships` -- List all invitations - `/api/v1/tenants/invitations` -- Retrieve data from tenant invitation - `/api/v1/tenants/invitations/{id}` - -**Security and Configuration:** - -- List all secrets - `/api/v1/providers/secrets` -- Retrieve data from a secret - `/api/v1/providers/secrets/{id}` -- List all provider groups - `/api/v1/provider-groups` -- Retrieve data from a provider group - `/api/v1/provider-groups/{id}` - -**Reports and Tasks:** - -- Download zip report - `/api/v1/scans/{v1}/report` -- List all tasks - `/api/v1/tasks` -- Retrieve data from a specific task - `/api/v1/tasks/{id}` - -**Lighthouse AI Configuration:** - -- List OpenAI configuration - `/api/v1/lighthouse-config` -- Retrieve OpenAI key and configuration - `/api/v1/lighthouse-config/{id}` - - -Agents only have access to hit GET endpoints. They don't have access to other HTTP methods. - - -## FAQs - -**1. Why only OpenAI models?** - -During feature development, we evaluated other LLM models. - -- **Claude AI** - Claude models have [tier-based ratelimits](https://docs.anthropic.com/en/api/rate-limits#requirements-to-advance-tier). For Lighthouse AI to answer slightly complex questions, there are a handful of API calls to the LLM provider within few seconds. With Claude's tiering system, users must purchase $400 credits or convert their subscription to monthly invoicing after talking to their sales team. This pricing may not suit all Prowler users. -- **Gemini Models** - Gemini lacks a solid tool calling feature like OpenAI. It calls functions recursively until exceeding limits. Gemini-2.5-Pro-Experimental is better than previous models regarding tool calling and responding, but it's still experimental. -- **Deepseek V3** - Doesn't support system prompt messages. - -**2. Why a multi-agent supervisor model?** - -Context windows are limited. While demo data fits inside the context window, querying real-world data often exceeds it. A multi-agent architecture is used so different agents fetch different sizes of data and respond with the minimum required data to the supervisor. This spreads the context window usage across agents. - -**3. Is my security data shared with OpenAI?** - -Minimal data is shared to generate useful responses. Agents can access security findings and remediation details when needed. Provider secrets are protected by design and cannot be read. The OpenAI key configured with Lighthouse AI is only accessible to our NextJS server and is never sent to LLMs. Resource metadata (names, tags, account/project IDs, etc) may be shared with OpenAI based on your query requirements. - -**4. Can the Lighthouse AI change my cloud environment?** - -No. The agent doesn't have the tools to make the changes, even if the configured cloud provider API keys contain permissions to modify resources. diff --git a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx index 9f3bdad640..64e9a0c8f4 100644 --- a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx +++ b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx @@ -1,20 +1,26 @@ --- -title: 'Mute Findings (Mutelist)' +title: 'Advanced Mutelist (YAML)' --- import { VersionBadge } from "/snippets/version-badge.mdx" -Prowler App allows users to mute specific findings to focus on the most critical security issues. This comprehensive guide demonstrates how to effectively use the Mutelist feature to manage and prioritize security findings. +Prowler App allows users to mute specific findings to focus on the most critical security issues. This guide demonstrates how to use the Advanced Mutelist feature with YAML configuration for complex, pattern-based muting rules. -## What Is the Mutelist Feature? + +For muting individual findings without YAML configuration, use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to mute findings directly from the Findings table. -The Mutelist feature enables users to: + -- **Suppress specific findings** from appearing in future scans -- **Focus on critical issues** by hiding resolved or accepted risks +## What Is Advanced Mutelist? + +Advanced Mutelist enables users to create powerful, pattern-based muting rules using YAML configuration: + +- **Define complex muting patterns** using regular expressions +- **Mute findings by check, region, resource, or tag** across multiple accounts +- **Apply wildcards** to mute entire categories of findings +- **Create exceptions** within broad muting rules - **Maintain audit trails** of muted findings for compliance purposes -- **Streamline security workflows** by reducing noise from non-critical findings ## Prerequisites @@ -28,46 +34,51 @@ Before muting findings, ensure: Muting findings does not resolve underlying security issues. Review each finding carefully before muting to ensure it represents an acceptable risk or has been properly addressed. -## Step 1: Add a provider +## Step 1: Connect a Provider -To configure Mutelist: +To configure Advanced Mutelist: 1. Log into Prowler App -2. Navigate to the providers page +2. Navigate to the Providers page ![Add provider](/images/mutelist-ui-1.png) -3. Add a provider, then "Configure Muted Findings" button will be enabled in providers page and scans page +3. Connect a provider to enable Mutelist configuration ![Button enabled in providers page](/images/mutelist-ui-2.png) ![Button enabled in scans pages](/images/mutelist-ui-3.png) -## Step 2: Configure Mutelist +## Step 2: Configure Advanced Mutelist -1. Open the modal by clicking "Configure Muted Findings" button -![Open modal](/images/mutelist-ui-4.png) -1. Provide a valid Mutelist in `YAML` format. More details about Mutelist [here](/user-guide/cli/tutorials/mutelist) +1. Navigate to the Mutelist page from the left navigation menu +2. Select the "Advanced" tab +3. Provide a valid Mutelist configuration in `YAML` format + + +The YAML format follows the same specification as Prowler CLI. See [CLI Mutelist documentation](/user-guide/cli/tutorials/mutelist) for detailed syntax reference. + + ![Valid YAML configuration](/images/mutelist-ui-5.png) If the YAML configuration is invalid, an error message will be displayed ![Wrong YAML configuration](/images/mutelist-ui-7.png) ![Wrong YAML configuration 2](/images/mutelist-ui-8.png) -## Step 3: Review the Mutelist +## Step 3: Review and Update the Configuration -1. Once added, the configuration can be removed or updated +1. Once added, the configuration can be updated or removed from the Advanced tab ![Remove or update configuration](/images/mutelist-ui-6.png) -## Step 4: Check muted findings in the scan results +## Step 4: Verify Muted Findings in Scan Results 1. Run a new scan -2. Check the muted findings in the scan results -![Check muted fidings](/images/mutelist-ui-9.png) +2. Navigate to the Findings page to verify muted findings +![Check muted findings](/images/mutelist-ui-9.png) -The Mutelist configuration takes effect on the next scans. +The Advanced Mutelist configuration takes effect on subsequent scans. Existing findings are not retroactively muted. -## Mutelist Ready To Use Examples +## YAML Configuration Examples -Below are examples for different cloud providers supported by Prowler App. Check how the mutelist works [here](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/mutelist/#how-the-mutelist-works). +Below are ready-to-use examples for different cloud providers. For detailed syntax and logic explanation, see [CLI Mutelist documentation](/user-guide/cli/tutorials/mutelist#how-the-mutelist-works). ### AWS Provider diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index 72059436f5..e23b5bc332 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -14,15 +14,15 @@ import { VersionBadge } from "/snippets/version-badge.mdx" If the account is created without an invitation, a new tenant will be provisioned for it. However, if the account is created through an invitation, the user will join the inviter’s tenant. -## Membership +## Organization -To get to User-Invitation Management we will focus on the Membership section. +To get to User-Invitation Management we will focus on the Organization section. **Only users that have the _Invite and Manage Users_ or _admin_ permission can access this section.** -Membership tab +Organization tab ### Users diff --git a/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx new file mode 100644 index 0000000000..d6226f7c5f --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx @@ -0,0 +1,180 @@ +--- +title: "Simple Mutelist" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx"; + + + +Prowler App provides Simple Mutelist, an intuitive way to mute findings directly from the Findings page without writing YAML configuration. This feature streamlines the muting workflow by allowing individual or bulk muting with just a few clicks. + +## What Is Simple Mutelist? + +Simple Mutelist enables users to: + +- **Mute findings directly from the Findings table** using checkbox selection +- **Perform bulk muting** of multiple findings at once +- **Manage mute rules** through a dedicated interface +- **Toggle mute rules on and off** without deleting them +- **Edit mute rule justifications** after creation + + +Simple Mutelist creates rules based on the finding's unique identifier (UID). For complex muting patterns based on checks, regions, tags, or regular expressions, use [Advanced Mutelist](/user-guide/tutorials/prowler-app-mute-findings) with YAML configuration. + + + +## Accessing the Mutelist Page + +To access the Mutelist page: + +1. Click "Mutelist" in the left navigation menu + +The Mutelist page contains two tabs: + +- **Simple:** Displays a table of mute rules created through Simple Mutelist +- **Advanced:** Provides YAML-based configuration for complex muting patterns + +## Muting Findings from the Findings Page + +### Muting Individual Findings + +To mute a single finding: + +1. Navigate to the Findings page +2. Locate the finding to mute +3. Click the actions menu (three dots) on the finding row +4. Select "Mute" +5. Enter a justification for muting this finding +6. Click "Confirm" to create the mute rule + +### Muting Multiple Findings (Bulk Muting) + +To mute multiple findings at once: + +1. Navigate to the Findings page +2. Select findings using the checkboxes in the leftmost column +3. Click the floating "Mute" button that appears at the bottom of the screen +4. Enter a justification that applies to all selected findings +5. Click "Confirm" to create mute rules for all selected findings + + +Findings that are already muted display a muted icon instead of a checkbox. These findings cannot be selected for bulk operations. + + + +## Managing Mute Rules + +### Viewing Mute Rules + +To view all mute rules: + +1. Navigate to the Mutelist page +2. Select the "Simple" tab +3. The table displays all mute rules with the following information: + - **Finding UID:** The unique identifier of the muted finding + - **Justification:** The reason provided for muting + - **Enabled:** Whether the rule is currently active + - **Created:** When the rule was created + +### Enabling and Disabling Mute Rules + +To toggle a mute rule without deleting it: + +1. Navigate to the Mutelist page +2. Select the "Simple" tab +3. Locate the mute rule +4. Use the toggle switch in the "Enabled" column to enable or disable the rule + + +Disabled mute rules remain in the system but do not affect findings. Findings associated with disabled rules will appear as unmuted in subsequent scans. + + + +### Editing Mute Rules + +To edit a mute rule's justification: + +1. Navigate to the Mutelist page +2. Select the "Simple" tab +3. Click the actions menu (three dots) on the mute rule row +4. Select "Edit" +5. Update the justification +6. Click "Save" to apply changes + +### Deleting Mute Rules + +To permanently remove a mute rule: + +1. Navigate to the Mutelist page +2. Select the "Simple" tab +3. Click the actions menu (three dots) on the mute rule row +4. Select "Delete" +5. Confirm the deletion + + +Deleting a mute rule is permanent. The finding will appear as unmuted in subsequent scans. To temporarily unmute a finding without losing the rule, disable the rule instead of deleting it. + + + +## How Simple Mutelist Works + +Simple Mutelist creates mute rules based on a finding's unique identifier (UID). When a mute rule is created: + +- **Existing findings** matching the UID are immediately marked as muted +- **Historical findings** with the same UID are also muted +- **Future findings** from subsequent scans are automatically muted if they match the UID + +### Uniqueness Constraint + +Each finding UID can only have one mute rule. Attempting to create a duplicate mute rule for the same finding displays an error message indicating the rule already exists. + +## Simple Mutelist vs. Advanced Mutelist + +| Feature | Simple Mutelist | Advanced Mutelist | +| ------------------------ | ----------------------------------------- | ------------------------------------------------------ | +| **Configuration method** | Point-and-click interface | YAML configuration file | +| **Muting scope** | Individual finding UIDs | Patterns based on checks, regions, resources, and tags | +| **Regular expressions** | Not supported | Fully supported | +| **Bulk operations** | Checkbox selection in Findings table | YAML wildcards and patterns | +| **Best for** | Quick, ad-hoc muting of specific findings | Complex, policy-driven muting rules | + +### When to Use Simple Mutelist + +- Muting specific findings identified during review +- Quick suppression of known false positives +- Ad-hoc muting without YAML knowledge + +### When to Use Advanced Mutelist + +- Muting all findings for a specific check across regions +- Pattern-based muting using regular expressions +- Tag-based muting for environment-specific resources +- Complex rules with exceptions + +## Best Practices + +1. **Provide meaningful justifications:** Document why each finding is muted for audit trails and team communication +2. **Review muted findings regularly:** Periodically audit mute rules to ensure they remain valid +3. **Use disable instead of delete:** When temporarily unmuting findings, disable rules rather than deleting them +4. **Combine with Advanced Mutelist:** Use Simple Mutelist for specific findings and Advanced Mutelist for broad patterns +5. **Limit bulk muting:** Review findings individually when possible to ensure appropriate justification for each + +## Troubleshooting + +### Duplicate Rule Error + +If an error indicates a mute rule already exists for a finding: + +1. Navigate to the Mutelist page +2. Search for the existing rule in the Simple tab +3. Edit the existing rule's justification if needed, or +4. Delete the existing rule and create a new one + +### Finding Still Appears Unmuted + +If a muted finding still appears unmuted: + +1. Verify the mute rule exists in the Mutelist page +2. Ensure the mute rule is enabled (toggle is on) +3. Check that the finding UID matches the mute rule +4. Wait for the next scan to see updated muting status on historical findings diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index f56336f415..f823fae3df 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -10,9 +10,9 @@ This guide provides comprehensive instructions to configure SAML-based Single Si This document is divided into two main sections: -- **User Guide**: For organization administrators to configure SAML SSO through Prowler App. +- **[User Guide](#user-guide-configuration)**: For organization administrators to configure SAML SSO through Prowler App. -- **Developer and Administrator Guide**: For developers and system administrators running self-hosted Prowler App instances, providing technical details on environment configuration, API usage, and testing. +- **[Developer and Administrator Guide](#developer-and-administrator-guide)**: For developers and system administrators running self-hosted Prowler App instances, providing technical details on environment configuration, API usage, and testing. --- @@ -53,84 +53,125 @@ On the profile page, find the "SAML SSO Integration" card and click "Enable" to ![Enable SAML Integration](/images/prowler-app/saml/saml-step-2.png) - -**Choose Your Method** +The next section explains how to configure the IdP settings based on the selected Identity Provider. -**Use Step 3A (Generic Method)** for any SAML 2.0 compliant Identity Provider or when you need custom configuration. +#### Step 3: Configure the Identity Provider (IdP) +Choose a Method: -**Use Step 3B (Okta App Catalog)** if you're using Okta and want a simplified setup process with pre-configured settings. +- Use [**Generic Method**](#generic-method) for any SAML 2.0 compliant Identity Provider or when you need custom configuration. +- Use [**Okta App Catalog**](#okta-app-catalog) if you're using Okta and want a simplified setup process with pre-configured settings. - -#### Step 3A: Configure the Identity Provider (IdP) - Generic + + + Prowler App displays the SAML configuration information needed to configure the IdP. Use this information to create a new SAML application in the IdP. -Prowler App displays the SAML configuration information needed to configure the IdP. Use this information to create a new SAML application in the IdP. + 1. **Assertion Consumer Service (ACS) URL**: The endpoint in Prowler that will receive the SAML assertion from the IdP. + 2. **Audience URI (Entity ID)**: A unique identifier for the Prowler application (Service Provider). -1. **Assertion Consumer Service (ACS) URL**: The endpoint in Prowler that will receive the SAML assertion from the IdP. -2. **Audience URI (Entity ID)**: A unique identifier for the Prowler application (Service Provider). + To configure the IdP, copy the **ACS URL** and **Audience URI** from Prowler App and use them to set up a new SAML application. -To configure the IdP, copy the **ACS URL** and **Audience URI** from Prowler App and use them to set up a new SAML application. + ![IdP configuration](/images/prowler-app/saml/idp_config.png) -![IdP configuration](/images/prowler-app/saml/idp_config.png) + + **IdP Configuration** - -**IdP Configuration** + The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. For SSO integration with Azure AD / Entra ID, see our [Entra ID configuration instructions](/user-guide/tutorials/prowler-app-sso-entra). -The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. For SSO integration with Azure AD / Entra ID, see our [Entra ID configuration instructions](/user-guide/tutorials/prowler-app-sso-entra). + - -#### Step 3B: Configure Prowler from App Catalog - Okta + **Configure Attribute Mapping in the IdP** -Instead of creating a custom SAML integration, Okta administrators can configure Prowler Cloud directly from Okta's application catalog: + For Prowler App to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion: -1. **Access App Catalog**: Navigate to the IdP's application catalog (e.g., [Browse App Catalog](https://www.okta.com/integrations/) in Okta). + | Attribute Name | Description | Required | + |----------------|---------------------------------------------------------------------------------------------------------|----------| + | `firstName` | The user's first name. | Yes | + | `lastName` | The user's last name. | Yes | + | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | + | `companyName` | The user's company name. This is automatically populated if the IdP sends an `organization` attribute. | No | - ![Browse App Catalog](/images/prowler-app/saml/app-catalog-browse.png) + + **IdP Attribute Mapping** -2. **Search for Prowler Cloud**: Use the search functionality to find "Prowler Cloud" in the app catalog. The official Prowler Cloud application will appear in the search results. + Note that the attribute name is just an example and may be different depending on the IdP. For instance, if the IdP provides a `division` attribute, it can be mapped to `userType`. + ![IdP configuration](/images/prowler-app/saml/saml_attribute_statements.png) - ![Search for Prowler](/images/prowler-app/saml/app-catalog-browse-prowler.png) + + + **Dynamic Updates** -3. **Select Prowler Cloud Application**: Click on the Prowler Cloud application from the search results to view its details page. + Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again. - ![Prowler Application Details](/images/prowler-app/saml/app-catalog-browse-prowler-add.png) + -4. **Add Integration**: Click the "Add Integration" button to begin adding Prowler Cloud to the organization's applications. + + + Instead of creating a custom SAML integration, Okta administrators can configure Prowler Cloud directly from Okta's application catalog. -5. **Configure General Settings**: In the "Add Prowler Cloud" configuration screen, the integration automatically configures the necessary settings. + You can find a walkthrough video [here](https://youtu.be/NjSp5owvCdY). - ![Add Prowler Configuration](/images/prowler-app/saml/app-catalog-browse-prowler-configure.png) + 1. **Access App Catalog**: Navigate to the IdP's application catalog (e.g., [Browse App Catalog](https://www.okta.com/integrations/) in Okta). -6. **Assign Users**: Navigate to the **Assignments** tab and assign the appropriate users or groups to the Prowler application by clicking "Assign" and selecting "Assign to People" or "Assign to Groups". + ![Browse App Catalog](/images/prowler-app/saml/app-catalog-browse.png) -With this step, the Okta app catalog configuration is complete. Users can now access Prowler Cloud using either [IdP-initiated](#idp-initiated-sso) or [SP-initiated SSO](#sp-initiated-sso) flows. + 2. **Search for Prowler Cloud**: Use the search functionality to find "Prowler Cloud" in the app catalog. The official Prowler Cloud application will appear in the search results. -**If you used Step 3B (Okta App Catalog)**, jump to [Step 6: Save and Verify Configuration](#step-6-save-and-verify-configuration). + ![Search for Prowler](/images/prowler-app/saml/app-catalog-browse-prowler.png) -#### Step 4: Configure Attribute Mapping in the IdP + 3. **Select Prowler Cloud Application**: Click the Prowler Cloud application from the search results to view its details page. -For Prowler App to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion: + ![Prowler Application Details](/images/prowler-app/saml/app-catalog-browse-prowler-add.png) -| Attribute Name | Description | Required | -|----------------|---------------------------------------------------------------------------------------------------------|----------| -| `firstName` | The user's first name. | Yes | -| `lastName` | The user's last name. | Yes | -| `userType` | The Prowler role to be assigned to the user (e.g., `admin`, `auditor`). If a role with that name already exists, it will be used; otherwise, a new role called `no_permissions` will be created with minimal permissions. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | -| `companyName` | The user's company name. This is automatically populated if the IdP sends an `organization` attribute. | No | + 4. **Add Integration**: Click the "Add Integration" button to begin adding Prowler Cloud to the organization's applications. - -**IdP Attribute Mapping** + 5. **Configure General Settings**: In the "Add Prowler Cloud" configuration screen, the integration automatically configures the necessary settings. -Note that the attribute name is just an example and may be different depending on the IdP. For instance, if the IdP provides a 'division' attribute, it can be mapped to 'userType'. -![IdP configuration](/images/prowler-app/saml/saml_attribute_statements.png) + ![Add Prowler Configuration](/images/prowler-app/saml/app-catalog-browse-prowler-configure.png) - - -**Dynamic Updates** + 6. **Assign Users**: Navigate to the "Assignments" tab and assign the appropriate users or groups to the Prowler application by clicking "Assign" and selecting "Assign to People" or "Assign to Groups". -Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again. + ![Okta App Assignments](/images/prowler-app/saml/okta-app-assignments.png) - -#### Step 5: Upload IdP Metadata to Prowler + 7. **Configure User Attributes in Okta**: Okta acts as the central source for user profile information. Prowler App maps the following Okta user profile attributes during each SAML login: + + * **First name** (`firstName`): Maps to the user's first name in Prowler App. + * **Last name** (`lastName`): Maps to the user's last name in Prowler App. + + ![Okta User Profile — First Name and Last Name](/images/prowler-app/saml/okta-user-profile-name.png) + + * **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional. + * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive** and must match the exact name of an existing role in Prowler App. + + ![Okta User Profile — User Type and Organization](/images/prowler-app/saml/okta-user-profile-attributes.png) + + To modify these values, edit the user's profile directly in the Okta admin console under the "Profile" tab. Changes are reflected in Prowler App the next time the user logs in via SAML. + + + **User Type and Role Assignment** + + The `userType` attribute controls which Prowler role is assigned to the user: + + * If a role with the specified name already exists in Prowler App, the user automatically receives that role. + * If the role does not exist, Prowler App creates a new role with that exact name but without any permissions, preventing the user from performing any actions. + * If `userType` is not defined in the user's Okta profile, the user is assigned the `no_permissions` role. + + In all cases where the resulting role has no permissions, a Prowler administrator (a user whose role includes the "Manage Account" permission) must configure the appropriate permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). + + This behavior is intentional: by defaulting to no permissions, Prowler App ensures that a misconfiguration in Okta cannot inadvertently grant elevated access. + + **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` without permissions, and a Prowler administrator must configure the desired permissions for it. + + + + With this step, the Okta app catalog configuration is complete. Users can now access Prowler Cloud using either [IdP-initiated](#idp-initiated-sso) or [SP-initiated SSO](#sp-initiated-sso) flows. + + 8. **Download Metadata XML**: Inside the "Sign On" section, go to the "Metadata URL" and download the metadata XML file. + + Jump to [Step 4: Upload IdP Metadata to Prowler](#step-4:-upload-idp-metadata-to-prowler). + + + +#### Step 4: Upload IdP Metadata to Prowler Once the IdP is configured, it provides a **metadata XML file**. This file contains the IdP's configuration information, such as its public key and login URL. @@ -144,7 +185,7 @@ To complete the Prowler App configuration: ![Configure Prowler with IdP Metadata](/images/prowler-app/saml/saml-step-3.png) -#### Step 6: Save and Verify Configuration +#### Step 5: Save and Verify Configuration Click the "Save" button to complete the setup. The "SAML Integration" card will now display an "Active" status, indicating the configuration is complete and enabled. @@ -156,7 +197,8 @@ Click the "Save" button to complete the setup. The "SAML Integration" card will The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. -##### Remove SAML Configuration + +### Remove SAML Configuration SAML SSO can be disabled by removing the existing configuration from the integration panel. ![Remove SAML configuration](/images/prowler-app/saml/saml-step-remove.png) diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index dd2074c3b9..3b1594eaef 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -68,6 +68,8 @@ To perform security scans, link a cloud provider account. Prowler supports the f - **GitHub** +- **Oracle Cloud Infrastructure (OCI)** + Steps to add a provider: 1. Navigate to `Settings > Cloud Providers`. @@ -77,208 +79,38 @@ Steps to add a provider: ## **Step 4: Configure the Provider** -Select the cloud provider you want to scan. +Select the cloud provider to scan and configure authentication credentials. Each provider has specific requirements and authentication methods. Select a Provider -Once chosen, enter the Provider UID for authentication: - -- **AWS**: Enter your AWS Account ID. -- **GCP**: Enter your GCP Project ID. -- **Azure**: Enter your Azure Subscription ID. -- **Kubernetes**: Enter your Kubernetes Cluster context of your kubeconfig file. -- **M365**: Enter your M365 Domain ID. - -Optionally, provide a **Provider Alias** for easier identification. Follow the instructions provided to add your credentials: - -### **Step 4.1: AWS Credentials** - -For AWS, enter your `AWS Account ID` and choose one of the following methods to connect: - -#### **Step 4.1.1: IAM Access Keys** - -1. Select `Connect via Credentials`. - - AWS Credentials - -2. Enter your `Access Key ID`, `Secret Access Key` and optionally a `Session Token`: - - AWS Credentials - -#### **Step 4.1.2: IAM Role** - -1. Select `Connect assuming IAM Role`. - - AWS Role - -2. Enter the `Role ARN` and any optional field like the AWS Access Keys to assume the role, the `External ID`, the `Role Session Name` or the `Session Duration`: - - AWS Role - - -Check if your AWS Security Token Service (STS) has the EU (Ireland) endpoint active. If not, we will not be able to connect to your AWS account. - -If that is the case your STS configuration may look like this: - -AWS Role - -To solve this issue, please activate the EU (Ireland) STS endpoint. - - -### **Step 4.2: Azure Credentials**: - -For Azure, Prowler App uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application. - -Azure Credentials - ---- -### **Step 4.3: GCP Credentials** - -For Google Cloud, first enter your `GCP Project ID` and then select the authentication method you want to use: - -- **Service Account Authentication** (**Recommended**) -- **Application Default Credentials** - -**Service Account Authentication** is the recommended authentication method for automated systems and machine-to-machine interactions, like Prowler. For detailed information about this, refer to the [Google Cloud documentation](https://cloud.google.com/iam/docs/service-account-overview). - -GCP Authentication Methods - -#### **Step 4.3.1: Service Account Authentication** - -First of all, in the same project that you selected in the previous step, you need to create a service account and then generate a key in JSON format for it. For more information about this, you can follow the next Google Cloud documentation tutorials: - -- [Create a service account](https://cloud.google.com/iam/docs/creating-managing-service-accounts) -- [Generate a key for a service account](https://cloud.google.com/iam/docs/creating-managing-service-account-keys) - -GCP Service Account Credentials - -#### **Step 4.3.2: Application Default Credentials** - -1. Run the following command in your terminal to authenticate with GCP: - - ```bash - gcloud auth application-default login - ``` - -2. Once authenticated, get the `Client ID`, `Client Secret` and `Refresh Token` from `~/.config/gcloud/application_default_credentials`. - -3. Paste the `Client ID`, `Client Secret` and `Refresh Token` into Prowler App. - -GCP Credentials - -### **Step 4.4: Kubernetes Credentials**: - -For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate, paste the contents of your `kubeconfig` file into the `Kubeconfig content` field. - -By default, the `kubeconfig` file is located at `~/.kube/config`. - -Kubernetes Credentials - -If you are adding an **EKS**, **GKE**, **AKS** or external cluster, follow these additional steps to ensure proper authentication: - -**Make sure your cluster allow traffic from the Prowler Cloud IP address `52.48.254.174/32`** - -1. Apply the necessary Kubernetes resources to your EKS, GKE, AKS or external cluster (you can find the files in the [`kubernetes` directory of the Prowler repository](https://github.com/prowler-cloud/prowler/tree/master/kubernetes)): - - ```console - kubectl apply -f kubernetes/prowler-sa.yaml - kubectl apply -f kubernetes/prowler-role.yaml - kubectl apply -f kubernetes/prowler-rolebinding.yaml - ``` - -2. Generate a long-lived token for authentication: - - ```console - kubectl create token prowler-sa -n prowler-ns --duration=0 - ``` - - - **Security Note:** The `--duration=0` option generates a non-expiring token, which may pose a security risk if not managed properly. Users should decide on an appropriate expiration time based on their security policies. If a limited-time token is preferred, set `--duration=