diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 3f90ad3d3a..0626f33707 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -14,7 +14,7 @@ ignored: - "*.md" - "**/*.md" - mkdocs.yml - + # Config files that don't affect runtime - .gitignore - .gitattributes @@ -23,7 +23,7 @@ ignored: - .backportrc.json - CODEOWNERS - LICENSE - + # IDE/Editor configs - .vscode/** - .idea/** @@ -31,10 +31,13 @@ ignored: # 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/** @@ -47,18 +50,18 @@ critical: - 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 diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 8de1e9d9fa..caa712f3dd 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -25,7 +25,7 @@ jobs: e2e-tests: needs: impact-analysis if: | - github.repository == 'prowler-cloud/prowler' && + 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: @@ -200,7 +200,14 @@ jobs: # 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 | tr '\n' ' ') + 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 @@ -222,8 +229,8 @@ jobs: skip-e2e: needs: impact-analysis if: | - github.repository == 'prowler-cloud/prowler' && - needs.impact-analysis.outputs.has-ui-e2e != 'true' && + 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: diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml deleted file mode 100644 index 6dcc2f73af..0000000000 --- a/.github/workflows/ui-e2e-tests.yml +++ /dev/null @@ -1,172 +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_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 }} - - steps: - - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Create k8s Kind Cluster - uses: helm/kind-action@v1 - with: - cluster_name: kind - - name: Modify kubeconfig - run: | - # Modify the kubeconfig to use the kind cluster server to https://kind-control-plane:6443 - # from worker service into docker-compose.yml - kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443 - kubectl config view - - name: Add network kind to docker compose - run: | - # Add the network kind to the docker compose to interconnect to kind cluster - yq -i '.networks.kind.external = true' docker-compose.yml - # Add network kind to worker service and default network too - 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 AWS SDK Default Adding Provider - run: | - echo "Adding AWS credentials for testing AWS SDK Default Adding Provider..." - 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: | - # 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@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 - shell: bash - 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: pnpm run test:e2e - - 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: | - echo "Shutting down services..." - docker compose down -v || true - echo "Cleanup completed" diff --git a/AGENTS.md b/AGENTS.md index e203deb845..5d31782ef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Use these skills for detailed patterns on-demand: | `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 @@ -56,6 +57,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | 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` | @@ -63,6 +65,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | 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` | @@ -92,6 +95,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | 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` | diff --git a/README.md b/README.md index 5bb96eaca5..d583a33a15 100644 --- a/README.md +++ b/README.md @@ -104,18 +104,19 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 584 | 84 | 40 | 17 | Official | UI, API, CLI | -| Azure | 169 | 22 | 16 | 12 | Official | UI, API, CLI | +| 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 | 71 | 7 | 4 | 3 | 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 | 23 | 2 | 0 | 5 | Official | 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] diff --git a/api/AGENTS.md b/api/AGENTS.md index 399f12b691..e1e989d751 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -3,6 +3,7 @@ > **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 @@ -15,9 +16,11 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: |--------|-------| | 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` | @@ -27,6 +30,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | 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` | diff --git a/api/pyproject.toml b/api/pyproject.toml index 7fa01a062a..e0f577e076 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -49,7 +49,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.19.0" +version = "1.20.0" [project.scripts] celery = "src.backend.config.settings.celery" diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 778233ddab..432528c562 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.19.0 + version: 1.20.0 description: |- Prowler API specification. diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index d807dfd8fe..7ddcfe2461 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -392,7 +392,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.19.0" + spectacular_settings.VERSION = "1.20.0" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 2a1d56d605..8b5382f156 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -115,8 +115,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.17.0" -PROWLER_API_VERSION="5.17.0" +PROWLER_UI_VERSION="5.18.0" +PROWLER_API_VERSION="5.18.0" ``` diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 1fbf715ea7..cd3da32946 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -86,3 +86,81 @@ 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). + diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index e158d53458..c6d3173022 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.19.0] (Prowler UNRELEASED) + +### 🚀 Added + +- AI Skills: Added a skill for creating new Attack Paths queries in openCypher, compatible with Neo4j and Neptune [(#9975)](https://github.com/prowler-cloud/prowler/pull/9975) +- `image` provider for container image scanning with Trivy integration [(#9984)](https://github.com/prowler-cloud/prowler/pull/9984) + +--- + ## [5.18.0] (Prowler v5.18.0) ### 🚀 Added @@ -70,7 +79,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - CIS 5.0 compliance framework for the Azure provider [(#9777)](https://github.com/prowler-cloud/prowler/pull/9777) - `Cloudflare` Bot protection, WAF, Privacy, Anti-Scraping and Zone configuration checks [(#9425)](https://github.com/prowler-cloud/prowler/pull/9425) - `Cloudflare` `waf` and `dns record` checks [(#9426)](https://github.com/prowler-cloud/prowler/pull/9426) -- `image` provider for container image scanning with Trivy integration [(#9984)](https://github.com/prowler-cloud/prowler/pull/9984) ### Changed diff --git a/prowler/config/config.py b/prowler/config/config.py index 52178c7470..7219e9a7eb 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -38,7 +38,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.18.0" +prowler_version = "5.19.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/pyproject.toml b/pyproject.toml index 8f8a74f992..85fda311a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">3.9.1,<3.13" -version = "5.18.0" +version = "5.19.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/skills/README.md b/skills/README.md index 4296b49088..e5430a6671 100644 --- a/skills/README.md +++ b/skills/README.md @@ -77,6 +77,7 @@ Patterns tailored for Prowler development: | `prowler-provider` | Add new cloud providers | | `prowler-pr` | Pull request conventions | | `prowler-docs` | Documentation style guide | +| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | ### Meta Skills diff --git a/skills/prowler-attack-paths-query/SKILL.md b/skills/prowler-attack-paths-query/SKILL.md new file mode 100644 index 0000000000..9a68c8a3ea --- /dev/null +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -0,0 +1,479 @@ +--- +name: prowler-attack-paths-query +description: > + Creates Prowler Attack Paths openCypher queries for graph analysis (compatible with Neo4j and Neptune). + Trigger: When creating or updating Attack Paths queries that detect privilege escalation paths, + network exposure, or security misconfigurations in cloud environments. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, api] + auto_invoke: + - "Creating Attack Paths queries" + - "Updating existing Attack Paths queries" + - "Adding privilege escalation detection queries" +allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, Task +--- + +## Overview + +Attack Paths queries are openCypher queries that analyze cloud infrastructure graphs +(ingested via Cartography) to detect security risks like privilege escalation paths, +network exposure, and misconfigurations. + +Queries are written in **openCypher Version 9** to ensure compatibility with both Neo4j and Amazon Neptune. + +--- + +## Input Sources + +Queries can be created from: + +1. **pathfinding.cloud ID** (e.g., `ECS-001`, `GLUE-001`) + - The JSON index contains: `id`, `name`, `description`, `services`, `permissions`, `exploitationSteps`, `prerequisites`, etc. + - Reference: https://github.com/DataDog/pathfinding.cloud + + **Fetching a single path by ID** — The aggregated `paths.json` is too large for WebFetch + (content gets truncated). Use Bash with `curl` and a JSON parser instead: + + Prefer `jq` (concise), fall back to `python3` (guaranteed in this Python project): + ```bash + # With jq + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq '.[] | select(.id == "ecs-002")' + + # With python3 (fallback) + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | python3 -c "import json,sys; print(json.dumps(next((p for p in json.load(sys.stdin) if p['id']=='ecs-002'), None), indent=2))" + ``` + +2. **Listing Available Attack Paths** + - Use Bash to list available paths from the JSON index: + ```bash + # List all path IDs and names (jq) + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq -r '.[] | "\(.id): \(.name)"' + + # List all path IDs and names (python3 fallback) + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | python3 -c "import json,sys; [print(f\"{p['id']}: {p['name']}\") for p in json.load(sys.stdin)]" + + # List paths filtered by service prefix + curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ + | jq -r '.[] | select(.id | startswith("ecs")) | "\(.id): \(.name)"' + ``` + +3. **Natural Language Description** + - User describes the Attack Paths in plain language + - Agent maps to appropriate openCypher patterns + +--- + +## Query Structure + +### File Location + +``` +api/src/backend/api/attack_paths/queries/{provider}.py +``` + +Example: `api/src/backend/api/attack_paths/queries/aws.py` + +### Query Definition Pattern + +```python +from api.attack_paths.queries.types import ( + AttackPathsQueryDefinition, + AttackPathsQueryParameterDefinition, +) +from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL + +# {REFERENCE_ID} (e.g., EC2-001, GLUE-001) +AWS_{QUERY_NAME} = AttackPathsQueryDefinition( + id="aws-{kebab-case-name}", + name="Privilege Escalation: {permission1} + {permission2}", + description="{Detailed description of the Attack Paths}.", + provider="aws", + cypher=f""" + // Find principals with {permission1} + 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) = '{permission1_lowercase}' + OR toLower(action) = '{service}:*' + OR action = '*' + ) + + // Find {permission2} + MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) + WHERE stmt2.effect = 'Allow' + AND any(action IN stmt2.action WHERE + toLower(action) = '{permission2_lowercase}' + OR toLower(action) = '{service2}:*' + OR action = '*' + ) + + // Find target resources + MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: '{service}.amazonaws.com'}}) + 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=[], +) +``` + +### Register in Query List + +Add to the `{PROVIDER}_QUERIES` list at the bottom of the file: + +```python +AWS_QUERIES: list[AttackPathsQueryDefinition] = [ + # ... existing queries ... + AWS_{NEW_QUERY_NAME}, # Add here +] +``` + +--- + +## Step-by-Step Creation Process + +### 1. Read the Queries Module + +**FIRST**, read all files in the queries module to understand the structure: + +``` +api/src/backend/api/attack_paths/queries/ +├── __init__.py # Module exports +├── types.py # AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition +├── registry.py # Query registry logic +└── {provider}.py # Provider-specific queries (e.g., aws.py) +``` + +Read these files to learn: + +- Type definitions and available fields +- How queries are registered +- Current query patterns, style, and naming conventions + +### 2. Determine Schema Source + +Check the Cartography dependency in `api/pyproject.toml`: + +```bash +grep cartography api/pyproject.toml +``` + +Parse the dependency to determine the schema source: + +**If git-based dependency** (e.g., `cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1`): + +- Extract the repository (e.g., `prowler-cloud/cartography`) +- Extract the version/tag (e.g., `0.126.1`) +- Fetch schema from that repository at that tag + +**If PyPI dependency** (e.g., `cartography = "^0.126.0"` or `cartography>=0.126.0`): + +- Extract the version (e.g., `0.126.0`) +- Use the official `cartography-cncf` repository + +**Schema URL patterns** (ALWAYS use the specific version tag, not master/main): + +``` +# Official Cartography (cartography-cncf) +https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md + +# Prowler fork (prowler-cloud) +https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md +``` + +**Examples**: + +```bash +# For prowler-cloud/cartography@0.126.1 (git), fetch AWS schema: +https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/0.126.1/docs/root/modules/aws/schema.md + +# For cartography = "^0.126.0" (PyPI), fetch AWS schema: +https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/aws/schema.md +``` + +**IMPORTANT**: Always match the schema version to the dependency version in `pyproject.toml`. Using master/main may reference node labels or properties that don't exist in the deployed version. + +**Additional Prowler Labels**: The Attack Paths sync task adds extra labels: + +- `ProwlerFinding` - Prowler finding nodes with `status`, `provider_uid` properties +- `ProviderResource` - Generic resource marker +- `{Provider}Resource` - Provider-specific marker (e.g., `AWSResource`) + +These are defined in `api/src/backend/tasks/jobs/attack_paths/config.py`. + +### 3. Consult the Schema for Available Data + +Use the Cartography schema to discover: + +- What node labels exist for the target resources +- What properties are available on those nodes +- What relationships connect the nodes + +This informs query design by showing what data is actually available to query. + +### 4. Create Query Definition + +Use the standard pattern (see above) with: + +- **id**: Auto-generated as `{provider}-{kebab-case-description}` +- **name**: Human-readable, e.g., "Privilege Escalation: {perm1} + {perm2}" +- **description**: Explain the attack vector and impact +- **provider**: Provider identifier (aws, azure, gcp, kubernetes, github) +- **cypher**: The openCypher query with proper escaping +- **parameters**: Optional list of user-provided parameters (use `parameters=[]` if none needed) + +### 5. Add Query to Provider List + +Add the constant to the `{PROVIDER}_QUERIES` list. + +--- + +## Query Naming Conventions + +### Query ID + +``` +{provider}-{category}-{description} +``` + +Examples: + +- `aws-ec2-privesc-passrole-iam` +- `aws-iam-privesc-attach-role-policy-assume-role` +- `aws-rds-unencrypted-storage` + +### Query Constant Name + +``` +{PROVIDER}_{CATEGORY}_{DESCRIPTION} +``` + +Examples: + +- `AWS_EC2_PRIVESC_PASSROLE_IAM` +- `AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE` +- `AWS_RDS_UNENCRYPTED_STORAGE` + +--- + +## Query Categories + +| Category | Description | Example | +| -------------------- | ------------------------------ | ------------------------- | +| Basic Resource | List resources with properties | RDS instances, S3 buckets | +| Network Exposure | Internet-exposed resources | EC2 with public IPs | +| Privilege Escalation | IAM privilege escalation paths | PassRole + RunInstances | +| Data Access | Access to sensitive data | EC2 with S3 access | + +--- + +## Common openCypher Patterns + +### Match Account and Principal + +```cypher +MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) +``` + +### Check IAM Action Permissions + +```cypher +WHERE stmt.effect = 'Allow' + AND any(action IN stmt.action WHERE + toLower(action) = 'iam:passrole' + OR toLower(action) = 'iam:*' + OR action = '*' + ) +``` + +### Find Roles Trusting a Service + +```cypher +MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) +``` + +### Check Resource Scope + +```cypher +WHERE any(resource IN stmt.resource WHERE + resource = '*' + OR target_role.arn CONTAINS resource + OR resource CONTAINS target_role.name +) +``` + +### Include Prowler Findings + +```cypher +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 +``` + +--- + +## Common Node Labels by Provider + +### AWS + +| Label | Description | +| -------------------- | ----------------------------------- | +| `AWSAccount` | AWS account root | +| `AWSPrincipal` | IAM principal (user, role, service) | +| `AWSRole` | IAM role | +| `AWSUser` | IAM user | +| `AWSPolicy` | IAM policy | +| `AWSPolicyStatement` | Policy statement | +| `EC2Instance` | EC2 instance | +| `EC2SecurityGroup` | Security group | +| `S3Bucket` | S3 bucket | +| `RDSInstance` | RDS database instance | +| `LoadBalancer` | Classic ELB | +| `LoadBalancerV2` | ALB/NLB | +| `LaunchTemplate` | EC2 launch template | + +### Common Relationships + +| Relationship | Description | +| ---------------------- | ----------------------- | +| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | +| `STS_ASSUMEROLE_ALLOW` | Can assume role | +| `POLICY` | Has policy attached | +| `STATEMENT` | Policy has statement | + +--- + +## Parameters + +For queries requiring user input, define parameters: + +```python +parameters=[ + AttackPathsQueryParameterDefinition( + name="ip", + label="IP address", + description="Public IP address, e.g. 192.0.2.0.", + placeholder="192.0.2.0", + ), + AttackPathsQueryParameterDefinition( + name="tag_key", + label="Tag key", + description="Tag key to filter resources.", + placeholder="Environment", + ), +], +``` + +--- + +## Best Practices + +1. **Always filter by provider_uid**: Use `{id: $provider_uid}` on account nodes and `{provider_uid: $provider_uid}` on ProwlerFinding nodes + +2. **Use consistent naming**: Follow existing patterns in the file + +3. **Include Prowler findings**: Always add the OPTIONAL MATCH for ProwlerFinding nodes + +4. **Return distinct findings**: Use `collect(DISTINCT pf)` to avoid duplicates + +5. **Comment the query purpose**: Add inline comments explaining each MATCH clause + +6. **Validate schema first**: Ensure all node labels and properties exist in Cartography schema + +--- + +## openCypher Compatibility + +Queries must be written in **openCypher Version 9** to ensure compatibility with both Neo4j and Amazon Neptune. + +> **Why Version 9?** Amazon Neptune implements openCypher Version 9. By targeting this specification, queries work on both Neo4j and Neptune without modification. + +### Avoid These (Not in openCypher spec) + +| Feature | Reason | +| --------------------------------------------------- | ----------------------------------------------- | +| APOC procedures (`apoc.*`) | Neo4j-specific plugin, not available in Neptune | +| Virtual nodes (`apoc.create.vNode`) | APOC-specific | +| Virtual relationships (`apoc.create.vRelationship`) | APOC-specific | +| Neptune extensions | Not available in Neo4j | +| `reduce()` function | Use `UNWIND` + aggregation instead | +| `FOREACH` clause | Use `WITH` + `UNWIND` + `SET` instead | +| Regex match operator (`=~`) | Not supported in Neptune | + +### CALL Subqueries + +Supported with limitations: + +- Use `WITH` clause to import variables: `CALL { WITH var ... }` +- Updates inside CALL subqueries are NOT supported +- Emitted variables cannot overlap with variables before the CALL + +--- + +## Reference + +### pathfinding.cloud (Attack Path Definitions) + +- **Repository**: https://github.com/DataDog/pathfinding.cloud +- **All paths JSON**: `https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json` +- Use WebFetch to query specific paths or list available services + +### Cartography Schema + +- **URL pattern**: `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md` +- Always use the version from `api/pyproject.toml`, not master/main + +### openCypher Specification + +- **Neptune openCypher compliance** (what Neptune supports): https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html +- **Rewriting Cypher for Neptune** (converting Neo4j-specific syntax): https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html +- **openCypher project** (spec, grammar, TCK): https://github.com/opencypher/openCypher + +--- + +## Learning from the Queries Module + +**IMPORTANT**: Before creating a new query, ALWAYS read the entire queries module: + +``` +api/src/backend/api/attack_paths/queries/ +├── __init__.py # Module exports +├── types.py # Type definitions +├── registry.py # Registry logic +└── {provider}.py # Provider queries (aws.py, etc.) +``` + +Use the existing queries to learn: + +- Query structure and formatting +- Variable naming conventions +- How to include Prowler findings +- Comment style + +> **Compatibility Warning**: Some existing queries use Neo4j-specific features +> (e.g., `apoc.create.vNode`, `apoc.create.vRelationship`, regex `=~`) that are +> **NOT compatible** with Amazon Neptune. Use these queries to learn general +> patterns (structure, naming, Prowler findings integration, comment style) but +> **DO NOT copy APOC procedures or other Neo4j-specific syntax** into new queries. +> New queries must be pure openCypher Version 9. Refer to the +> [openCypher Compatibility](#opencypher-compatibility) section for the full list +> of features to avoid. + +**DO NOT** use generic templates. Match the exact style of existing **compatible** queries in the file. diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index cd8d6a6f35..7ac2c70086 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.18.0] (Prowler UNRELEASED) +## [1.18.1] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Scans page polling now only refreshes scan table data instead of re-rendering the entire server component tree, eliminating redundant API calls to providers, findings, and compliance endpoints every 5 seconds + +--- + +## [1.18.0] (Prowler v5.18.0) ### 🔄 Changed diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index c3f1489714..2d0416f37c 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,21 +1,19 @@ import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; -import { getScans, getScansByState } from "@/actions/scans"; +import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; import { MutedFindingsConfigButton } from "@/components/providers"; import { - AutoRefresh, NoProvidersAdded, NoProvidersConnected, ScansFilters, } from "@/components/scans"; import { LaunchScanWorkflow } from "@/components/scans/launch-workflow"; import { SkeletonTableScans } from "@/components/scans/table"; -import { ColumnGetScans } from "@/components/scans/table/scans"; +import { ScansTableWithPolling } from "@/components/scans/table/scans"; import { ContentLayout } from "@/components/ui"; import { CustomBanner } from "@/components/ui/custom/custom-banner"; -import { DataTable } from "@/components/ui/table"; import { createProviderDetailsMapping, extractProviderUIDs, @@ -57,15 +55,6 @@ export default async function Scans({ const hasManageScansPermission = session?.user?.permissions?.manage_scans; - // Get scans data to check for executing scans - const scansData = await getScansByState(); - - const hasExecutingScan = scansData?.data?.some( - (scan: ScanProps) => - scan.attributes.state === "executing" || - scan.attributes.state === "available", - ); - // Extract provider UIDs and create provider details mapping for filtering const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; const providerDetails = providersData @@ -82,7 +71,6 @@ export default async function Scans({ return ( - <> <> {!hasManageScansPermission ? ( @@ -177,11 +165,10 @@ const SSRDataTableScans = async ({ }) || []; return ( - ); }; diff --git a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx index 7fb31d7e2a..b0047445c7 100644 --- a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx +++ b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx @@ -13,6 +13,7 @@ import { Form } from "@/components/ui/form"; import { toast } from "@/components/ui/toast"; import { onDemandScanFormSchema } from "@/types"; +import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling"; import { SelectScanProvider } from "./select-scan-provider"; type ProviderInfo = { @@ -85,6 +86,8 @@ export const LaunchScanWorkflow = ({ }); // Reset form after successful submission form.reset(); + // Notify the scans table to refresh and pick up the new scan + window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT)); } }; diff --git a/ui/components/scans/table/scans/index.ts b/ui/components/scans/table/scans/index.ts index 2567c0098c..18c3035f7d 100644 --- a/ui/components/scans/table/scans/index.ts +++ b/ui/components/scans/table/scans/index.ts @@ -1,3 +1,4 @@ export * from "./column-get-scans"; export * from "./data-table-row-actions"; export * from "./data-table-row-details"; +export * from "./scans-table-with-polling"; diff --git a/ui/components/scans/table/scans/scans-table-with-polling.tsx b/ui/components/scans/table/scans/scans-table-with-polling.tsx new file mode 100644 index 0000000000..5c59a5f267 --- /dev/null +++ b/ui/components/scans/table/scans/scans-table-with-polling.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { getScans } from "@/actions/scans"; +import { AutoRefresh } from "@/components/scans"; +import { DataTable } from "@/components/ui/table"; +import { MetaDataProps, ScanProps, SearchParamsProps } from "@/types"; + +import { ColumnGetScans } from "./column-get-scans"; + +export const SCAN_LAUNCHED_EVENT = "scan-launched"; + +interface ScansTableWithPollingProps { + initialData: ScanProps[]; + initialMeta?: MetaDataProps; + searchParams: SearchParamsProps; +} + +const EXECUTING_STATES = ["executing", "available"] as const; + +function expandScansWithProviderInfo( + scans: ScanProps[], + included?: Array<{ type: string; id: string; attributes: any }>, +) { + return ( + scans?.map((scan) => { + const providerId = scan.relationships?.provider?.data?.id; + + if (!providerId) { + return { ...scan, providerInfo: undefined }; + } + + const providerData = included?.find( + (item) => item.type === "providers" && item.id === providerId, + ); + + if (!providerData) { + return { ...scan, providerInfo: undefined }; + } + + return { + ...scan, + providerInfo: { + provider: providerData.attributes.provider, + uid: providerData.attributes.uid, + alias: providerData.attributes.alias, + }, + }; + }) || [] + ); +} + +export function ScansTableWithPolling({ + initialData, + initialMeta, + searchParams, +}: ScansTableWithPollingProps) { + const [scansData, setScansData] = useState(initialData); + const [meta, setMeta] = useState(initialMeta); + + const hasExecutingScan = scansData.some((scan) => + EXECUTING_STATES.includes( + scan.attributes.state as (typeof EXECUTING_STATES)[number], + ), + ); + + const handleRefresh = useCallback(async () => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const sort = searchParams.sort?.toString(); + + const filters = Object.fromEntries( + Object.entries(searchParams).filter( + ([key]) => key.startsWith("filter[") && key !== "scanId", + ), + ); + + const query = (filters["filter[search]"] as string) || ""; + + const result = await getScans({ + query, + page, + sort, + filters, + pageSize, + include: "provider", + }); + + if (result?.data) { + const expanded = expandScansWithProviderInfo( + result.data, + result.included, + ); + setScansData(expanded); + + if (result && "meta" in result) { + setMeta(result.meta as MetaDataProps); + } + } + }, [searchParams]); + + // Listen for scan launch events to trigger an immediate refresh + useEffect(() => { + const handler = () => { + handleRefresh(); + }; + window.addEventListener(SCAN_LAUNCHED_EVENT, handler); + return () => window.removeEventListener(SCAN_LAUNCHED_EVENT, handler); + }, [handleRefresh]); + + return ( + <> + + + + ); +} diff --git a/ui/components/ui/custom/custom-table-link.tsx b/ui/components/ui/custom/custom-table-link.tsx index 80b6b000bf..9fba1e2c55 100644 --- a/ui/components/ui/custom/custom-table-link.tsx +++ b/ui/components/ui/custom/custom-table-link.tsx @@ -21,7 +21,9 @@ export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => { return ( ); }; diff --git a/ui/tests/scans/scans-page.ts b/ui/tests/scans/scans-page.ts index e3e19804fc..6f0052873d 100644 --- a/ui/tests/scans/scans-page.ts +++ b/ui/tests/scans/scans-page.ts @@ -105,6 +105,7 @@ export class ScansPage extends BasePage { await expect(this.scanTable).toBeVisible(); // Find a row that contains the account ID (provider UID in Cloud Provider column) + // Note: Use a more specific locator strategy if possible in the future const rowWithAccountId = this.scanTable .locator("tbody tr") .filter({ hasText: accountId })