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 4374ceea66..9936737320 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: @@ -204,7 +204,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 @@ -226,8 +233,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 fd2b223508..ec5f9e6ec5 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 5c4791a756..0c61622d06 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/__main__.py b/prowler/__main__.py index 6ceea64e03..d99c688e9e 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -8,6 +8,7 @@ from colorama import Fore, Style from colorama import init as colorama_init from prowler.config.config import ( + EXTERNAL_TOOL_PROVIDERS, csv_file_suffix, get_available_compliance_frameworks, html_file_suffix, @@ -208,8 +209,8 @@ def prowler(): # Load compliance frameworks logger.debug("Loading compliance frameworks from .json files") - # Skip compliance frameworks for IAC, LLM, and Image providers - if provider not in ("iac", "llm", "image"): + # Skip compliance frameworks for external-tool providers + if provider not in EXTERNAL_TOOL_PROVIDERS: bulk_compliance_frameworks = Compliance.get_bulk(provider) # Complete checks metadata with the compliance framework specification bulk_checks_metadata = update_checks_metadata_with_compliance( @@ -266,8 +267,8 @@ def prowler(): if not args.only_logs: global_provider.print_credentials() - # Skip service and check loading for IAC, LLM, and Image providers - if provider not in ("iac", "llm", "image"): + # Skip service and check loading for external-tool providers + if provider not in EXTERNAL_TOOL_PROVIDERS: # Import custom checks from folder if checks_folder: custom_checks = parse_checks_from_folder(global_provider, checks_folder) @@ -379,8 +380,8 @@ def prowler(): # Execute checks findings = [] - if provider in ("iac", "llm", "image"): - # For IAC, LLM, and Image providers, run the scan directly + if provider in EXTERNAL_TOOL_PROVIDERS: + # For external-tool providers, run the scan directly if provider == "llm": def streaming_callback(findings_batch): diff --git a/prowler/config/config.py b/prowler/config/config.py index b5ebe1e866..77674c7f93 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" @@ -63,8 +63,13 @@ class Provider(str, Enum): ORACLECLOUD = "oraclecloud" ALIBABACLOUD = "alibabacloud" OPENSTACK = "openstack" + IMAGE = "image" +# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo) +# and bypass standard check/service loading. +EXTERNAL_TOOL_PROVIDERS = frozenset({"iac", "llm", "image"}) + # Compliance actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 7524e79d66..648b962039 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -10,7 +10,7 @@ from typing import Any, Dict, Optional, Set from pydantic.v1 import BaseModel, Field, ValidationError, validator from pydantic.v1.error_wrappers import ErrorWrapper -from prowler.config.config import Provider +from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger @@ -159,12 +159,7 @@ class CheckMetadata(BaseModel): raise ValueError("ServiceName must be a non-empty string") check_id = values.get("CheckID") - if ( - check_id - and values.get("Provider") != "iac" - and values.get("Provider") != "llm" - and values.get("Provider") != "image" - ): + if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: service_from_check_id = check_id.split("_")[0] if service_name != service_from_check_id: raise ValueError( @@ -180,12 +175,7 @@ class CheckMetadata(BaseModel): if not check_id: raise ValueError("CheckID must be a non-empty string") - if ( - check_id - and values.get("Provider") != "iac" - and values.get("Provider") != "llm" - and values.get("Provider") != "image" - ): + if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: if "-" in check_id: raise ValueError( f"CheckID {check_id} contains a hyphen, which is not allowed" @@ -872,6 +862,7 @@ class CheckReportImage(Check_Report): """Contains the Container Image Check's finding information using Trivy.""" resource_name: str + resource_id: str image_digest: str package_name: str installed_version: str @@ -897,8 +888,12 @@ class CheckReportImage(Check_Report): finding = {} super().__init__(metadata, finding) - self.resource = finding self.resource_name = image_name + self.resource_id = ( + finding.get("VulnerabilityID", "") + or finding.get("RuleID", "") + or finding.get("ID", "") + ) self.image_digest = finding.get("PkgID", "") self.package_name = finding.get("PkgName", "") self.installed_version = finding.get("InstalledVersion", "") diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index b90789046e..c3045dbd76 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -43,7 +43,7 @@ Available Cloud Providers: alibabacloud Alibaba Cloud Provider iac IaC Provider (Beta) llm LLM Provider (Beta) - image Container Image Provider (PoC) + image Container Image Provider nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider (Beta) diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 39c3b0269a..82f64850f8 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -387,7 +387,7 @@ class Finding(BaseModel): output_data["resource_name"] = getattr( check_output, "resource_name", "" ) - output_data["resource_uid"] = getattr(check_output, "resource_name", "") + output_data["resource_uid"] = getattr(check_output, "resource_id", "") output_data["region"] = getattr(check_output, "region", "container") output_data["package_name"] = getattr(check_output, "package_name", "") output_data["installed_version"] = getattr( diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 2e1b499977..6a1d978d7c 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -95,10 +95,7 @@ def display_summary_table( ) elif provider.type == "image": entity_type = "Image" - if len(provider.images) == 1: - audited_entities = provider.images[0] - else: - audited_entities = f"{len(provider.images)} images" + audited_entities = ", ".join(provider.images) # Check if there are findings and that they are not all MANUAL if findings and not all(finding.status == "MANUAL" for finding in findings): diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 90bf4aaafd..845c40a690 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -279,6 +279,7 @@ class Provider(ABC): images=arguments.images, image_list_file=arguments.image_list_file, scanners=arguments.scanners, + image_config_scanners=arguments.image_config_scanners, trivy_severity=arguments.trivy_severity, ignore_unfixed=arguments.ignore_unfixed, timeout=arguments.timeout, diff --git a/prowler/providers/image/exceptions/__init__.py b/prowler/providers/image/exceptions/__init__.py index 95b0b3a110..e69de29bb2 100644 --- a/prowler/providers/image/exceptions/__init__.py +++ b/prowler/providers/image/exceptions/__init__.py @@ -1,19 +0,0 @@ -from prowler.providers.image.exceptions.exceptions import ( - ImageBaseException, - ImageFindingProcessingError, - ImageListFileNotFoundError, - ImageListFileReadError, - ImageNoImagesProvidedError, - ImageScanError, - ImageTrivyBinaryNotFoundError, -) - -__all__ = [ - "ImageBaseException", - "ImageFindingProcessingError", - "ImageListFileNotFoundError", - "ImageListFileReadError", - "ImageNoImagesProvidedError", - "ImageScanError", - "ImageTrivyBinaryNotFoundError", -] diff --git a/prowler/providers/image/exceptions/exceptions.py b/prowler/providers/image/exceptions/exceptions.py index c7b6f42503..497a99a331 100644 --- a/prowler/providers/image/exceptions/exceptions.py +++ b/prowler/providers/image/exceptions/exceptions.py @@ -1,40 +1,60 @@ from prowler.exceptions.exceptions import ProwlerException -# Exceptions codes from 9000 to 9999 are reserved for Image exceptions +# Exceptions codes from 11000 to 11999 are reserved for Image exceptions class ImageBaseException(ProwlerException): """Base class for Image provider errors.""" IMAGE_ERROR_CODES = { - (9000, "ImageNoImagesProvidedError"): { + (11000, "ImageNoImagesProvidedError"): { "message": "No container images provided for scanning.", "remediation": "Provide at least one image using --image or --image-list-file.", }, - (9001, "ImageListFileNotFoundError"): { + (11001, "ImageListFileNotFoundError"): { "message": "Image list file not found.", "remediation": "Ensure the image list file exists at the specified path.", }, - (9002, "ImageListFileReadError"): { + (11002, "ImageListFileReadError"): { "message": "Error reading image list file.", "remediation": "Check file permissions and format. The file should contain one image per line.", }, - (9003, "ImageFindingProcessingError"): { + (11003, "ImageFindingProcessingError"): { "message": "Error processing image scan finding.", "remediation": "Check the Trivy output format and ensure the finding structure is valid.", }, - (9004, "ImageTrivyBinaryNotFoundError"): { + (11004, "ImageTrivyBinaryNotFoundError"): { "message": "Trivy binary not found.", "remediation": "Install Trivy from https://trivy.dev/latest/getting-started/installation/", }, - (9005, "ImageScanError"): { + (11005, "ImageScanError"): { "message": "Error scanning container image.", "remediation": "Check the image name and ensure it is accessible.", }, - (9006, "ImageDockerLoginError"): { + (11006, "ImageInvalidTimeoutError"): { + "message": "Invalid timeout format.", + "remediation": "Use a valid timeout like '5m', '300s', or '1h'.", + }, + (11007, "ImageInvalidScannerError"): { + "message": "Invalid scanner type.", + "remediation": "Use valid scanners: vuln, secret, misconfig, license.", + }, + (11008, "ImageInvalidSeverityError"): { + "message": "Invalid severity level.", + "remediation": "Use valid severities: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN.", + }, + (11009, "ImageInvalidNameError"): { + "message": "Invalid container image name.", + "remediation": "Use a valid image reference (e.g., 'alpine:3.18', 'registry.example.com/repo/image:tag').", + }, + (11010, "ImageInvalidConfigScannerError"): { + "message": "Invalid image config scanner type.", + "remediation": "Use valid image config scanners: misconfig, secret.", + }, + (11011, "ImageDockerLoginError"): { "message": "Docker login failed for registry authentication.", "remediation": "Check your registry credentials and ensure the registry is reachable.", }, - (9007, "ImageDockerNotFoundError"): { + (11012, "ImageDockerNotFoundError"): { "message": "Docker binary not found.", "remediation": "Install Docker to enable private registry authentication via docker login.", }, @@ -58,7 +78,7 @@ class ImageNoImagesProvidedError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9000, file=file, original_exception=original_exception, message=message + 11000, file=file, original_exception=original_exception, message=message ) @@ -67,7 +87,7 @@ class ImageListFileNotFoundError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9001, file=file, original_exception=original_exception, message=message + 11001, file=file, original_exception=original_exception, message=message ) @@ -76,7 +96,7 @@ class ImageListFileReadError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9002, file=file, original_exception=original_exception, message=message + 11002, file=file, original_exception=original_exception, message=message ) @@ -85,7 +105,7 @@ class ImageFindingProcessingError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9003, file=file, original_exception=original_exception, message=message + 11003, file=file, original_exception=original_exception, message=message ) @@ -94,7 +114,7 @@ class ImageTrivyBinaryNotFoundError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9004, file=file, original_exception=original_exception, message=message + 11004, file=file, original_exception=original_exception, message=message ) @@ -103,7 +123,52 @@ class ImageScanError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9005, file=file, original_exception=original_exception, message=message + 11005, file=file, original_exception=original_exception, message=message + ) + + +class ImageInvalidTimeoutError(ImageBaseException): + """Exception raised when an invalid timeout format is provided.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 11006, file=file, original_exception=original_exception, message=message + ) + + +class ImageInvalidScannerError(ImageBaseException): + """Exception raised when an invalid scanner type is provided.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 11007, file=file, original_exception=original_exception, message=message + ) + + +class ImageInvalidSeverityError(ImageBaseException): + """Exception raised when an invalid severity level is provided.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 11008, file=file, original_exception=original_exception, message=message + ) + + +class ImageInvalidNameError(ImageBaseException): + """Exception raised when an invalid container image name is provided.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 11009, file=file, original_exception=original_exception, message=message + ) + + +class ImageInvalidConfigScannerError(ImageBaseException): + """Exception raised when an invalid image config scanner type is provided.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 11010, file=file, original_exception=original_exception, message=message ) @@ -112,7 +177,7 @@ class ImageDockerLoginError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9006, file=file, original_exception=original_exception, message=message + 11011, file=file, original_exception=original_exception, message=message ) @@ -121,5 +186,5 @@ class ImageDockerNotFoundError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9007, file=file, original_exception=original_exception, message=message + 11012, file=file, original_exception=original_exception, message=message ) diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index 05dcf9c5c6..31e2bfed8f 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -2,9 +2,10 @@ from __future__ import annotations import json import os +import re import subprocess import sys -from typing import Generator, List +from typing import Generator from alive_progress import alive_bar from colorama import Fore, Style @@ -22,12 +23,22 @@ from prowler.providers.image.exceptions.exceptions import ( ImageDockerLoginError, ImageDockerNotFoundError, ImageFindingProcessingError, + ImageInvalidConfigScannerError, + ImageInvalidNameError, + ImageInvalidScannerError, + ImageInvalidSeverityError, + ImageInvalidTimeoutError, ImageListFileNotFoundError, ImageListFileReadError, ImageNoImagesProvidedError, ImageScanError, ImageTrivyBinaryNotFoundError, ) +from prowler.providers.image.lib.arguments.arguments import ( + IMAGE_CONFIG_SCANNERS_CHOICES, + SCANNERS_CHOICES, + SEVERITY_CHOICES, +) class ImageProvider(Provider): @@ -39,18 +50,24 @@ class ImageProvider(Provider): """ _type: str = "image" + FINDING_BATCH_SIZE: int = 100 + MAX_IMAGE_LIST_LINES: int = 10_000 + MAX_IMAGE_NAME_LENGTH: int = 500 + _IMAGE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9.\-_/:@]+$") + _SHELL_METACHARACTERS = frozenset(";|&$`\n\r") audit_metadata: Audit_Metadata def __init__( self, images: list[str] | None = None, - image_list_file: str = None, + image_list_file: str | None = None, scanners: list[str] | None = None, + image_config_scanners: list[str] | None = None, trivy_severity: list[str] | None = None, ignore_unfixed: bool = False, timeout: str = "5m", - config_path: str = None, - config_content: dict = None, + config_path: str | None = None, + config_content: dict | None = None, fixer_config: dict | None = None, registry_username: str = None, registry_password: str = None, @@ -61,6 +78,9 @@ class ImageProvider(Provider): self.images = images if images is not None else [] self.image_list_file = image_list_file self.scanners = scanners if scanners is not None else ["vuln", "secret"] + self.image_config_scanners = ( + image_config_scanners if image_config_scanners is not None else [] + ) self.trivy_severity = trivy_severity if trivy_severity is not None else [] self.ignore_unfixed = ignore_unfixed self.timeout = timeout @@ -84,10 +104,15 @@ class ImageProvider(Provider): else: self._auth_method = "No auth" + self._validate_inputs() + # Load images from file if provided if image_list_file: self._load_images_from_file(image_list_file) + for image in self.images: + self._validate_image_name(image) + if not self.images: raise ImageNoImagesProvidedError( file=__file__, @@ -124,17 +149,32 @@ class ImageProvider(Provider): def _load_images_from_file(self, file_path: str) -> None: """Load image names from a file (one per line).""" try: + line_count = 0 with open(file_path, "r") as f: for line in f: + line_count += 1 + if line_count > self.MAX_IMAGE_LIST_LINES: + raise ImageListFileReadError( + file=file_path, + message=f"Image list file exceeds maximum of {self.MAX_IMAGE_LIST_LINES} lines.", + ) line = line.strip() - if line and not line.startswith("#"): - self.images.append(line) + if not line or line.startswith("#"): + continue + if len(line) > self.MAX_IMAGE_NAME_LENGTH: + logger.warning( + f"Skipping image name exceeding {self.MAX_IMAGE_NAME_LENGTH} chars at line {line_count} in {file_path}" + ) + continue + self.images.append(line) logger.info(f"Loaded {len(self.images)} images from {file_path}") except FileNotFoundError: raise ImageListFileNotFoundError( file=file_path, message=f"Image list file not found: {file_path}", ) + except (ImageListFileReadError, ImageListFileNotFoundError): + raise except Exception as error: raise ImageListFileReadError( file=file_path, @@ -142,6 +182,61 @@ class ImageProvider(Provider): message=f"Error reading image list file: {error}", ) + def _validate_inputs(self) -> None: + """Validate timeout, scanners, and severity inputs.""" + if not re.fullmatch(r"\d+[smh]", self.timeout): + raise ImageInvalidTimeoutError( + file=__file__, + message=f"Invalid timeout format: '{self.timeout}'. Expected pattern like '5m', '300s', or '1h'.", + ) + + for scanner in self.scanners: + if scanner not in SCANNERS_CHOICES: + raise ImageInvalidScannerError( + file=__file__, + message=f"Invalid scanner: '{scanner}'. Valid options: {', '.join(SCANNERS_CHOICES)}.", + ) + + for config_scanner in self.image_config_scanners: + if config_scanner not in IMAGE_CONFIG_SCANNERS_CHOICES: + raise ImageInvalidConfigScannerError( + file=__file__, + message=f"Invalid image config scanner: '{config_scanner}'. Valid options: {', '.join(IMAGE_CONFIG_SCANNERS_CHOICES)}.", + ) + + for severity in self.trivy_severity: + if severity not in SEVERITY_CHOICES: + raise ImageInvalidSeverityError( + file=__file__, + message=f"Invalid severity: '{severity}'. Valid options: {', '.join(SEVERITY_CHOICES)}.", + ) + + def _validate_image_name(self, name: str) -> None: + """Validate a container image name for safety and correctness.""" + if not name: + raise ImageInvalidNameError( + file=__file__, + message="Image name must not be empty.", + ) + + if len(name) > self.MAX_IMAGE_NAME_LENGTH: + raise ImageInvalidNameError( + file=__file__, + message=f"Image name exceeds maximum length of {self.MAX_IMAGE_NAME_LENGTH} characters: '{name[:50]}...'", + ) + + if any(c in self._SHELL_METACHARACTERS for c in name): + raise ImageInvalidNameError( + file=__file__, + message=f"Image name contains invalid characters: '{name}'", + ) + + if not self._IMAGE_NAME_PATTERN.fullmatch(name): + raise ImageInvalidNameError( + file=__file__, + message=f"Image name does not match valid OCI reference format: '{name}'", + ) + @property def auth_method(self) -> str: return self._auth_method @@ -357,7 +452,7 @@ class ImageProvider(Provider): raise ImageFindingProcessingError( file=__file__, original_exception=error, - message=f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}", + message=f"Error processing finding: {error}", ) def _build_status_extended(self, finding: dict) -> str: @@ -385,7 +480,7 @@ class ImageProvider(Provider): " ".join(parts) if parts else finding.get("Description", "Finding detected") ) - def run(self) -> List[CheckReportImage]: + def run(self) -> list[CheckReportImage]: """Execute the container image scan.""" try: reports = [] @@ -395,12 +490,12 @@ class ImageProvider(Provider): finally: self.cleanup() - def run_scan(self) -> Generator[List[CheckReportImage], None, None]: + def run_scan(self) -> Generator[list[CheckReportImage], None, None]: """ Run Trivy scan on all configured images. Yields: - List[CheckReportImage]: Batches of findings + list[CheckReportImage]: Batches of findings """ if self.registry_username and self.registry_password: registries = {self._extract_registry(img) for img in self.images} @@ -420,7 +515,7 @@ class ImageProvider(Provider): def _scan_single_image( self, image: str - ) -> Generator[List[CheckReportImage], None, None]: + ) -> Generator[list[CheckReportImage], None, None]: """ Scan a single container image with Trivy. @@ -428,7 +523,7 @@ class ImageProvider(Provider): image: The container image name/tag to scan Yields: - List[CheckReportImage]: Batches of findings + list[CheckReportImage]: Batches of findings """ try: logger.info(f"Scanning container image: {image}") @@ -445,6 +540,11 @@ class ImageProvider(Provider): self.timeout, ] + if self.image_config_scanners: + trivy_command.extend( + ["--image-config-scanners", ",".join(self.image_config_scanners)] + ) + if self.trivy_severity: trivy_command.extend(["--severity", ",".join(self.trivy_severity)]) @@ -463,9 +563,10 @@ class ImageProvider(Provider): # Check for Trivy failure if process.returncode != 0: error_msg = self._extract_trivy_errors(process.stderr) + categorized_msg = self._categorize_trivy_error(error_msg) raise ImageScanError( file=__file__, - message=f"Trivy scan failed for {image}: {error_msg}", + message=f"Trivy scan failed for {image}: {categorized_msg}", ) # Parse JSON output @@ -484,7 +585,6 @@ class ImageProvider(Provider): # Process findings in batches batch = [] - batch_size = 100 for result in results: target = result.get("Target", image) @@ -494,7 +594,7 @@ class ImageProvider(Provider): for vuln in result.get("Vulnerabilities", []): report = self._process_finding(vuln, target, result_type) batch.append(report) - if len(batch) >= batch_size: + if len(batch) >= self.FINDING_BATCH_SIZE: yield batch batch = [] @@ -502,7 +602,7 @@ class ImageProvider(Provider): for secret in result.get("Secrets", []): report = self._process_finding(secret, target, "secret") batch.append(report) - if len(batch) >= batch_size: + if len(batch) >= self.FINDING_BATCH_SIZE: yield batch batch = [] @@ -512,7 +612,7 @@ class ImageProvider(Provider): misconfig, target, "misconfiguration" ) batch.append(report) - if len(batch) >= batch_size: + if len(batch) >= self.FINDING_BATCH_SIZE: yield batch batch = [] @@ -620,6 +720,22 @@ class ImageProvider(Provider): return line.strip()[:500] return "Unknown error" + @staticmethod + def _categorize_trivy_error(error_msg: str) -> str: + """Categorize a Trivy error message to provide actionable guidance.""" + lower = error_msg.lower() + + if any(kw in lower for kw in ("401", "403", "unauthorized", "denied")): + return f"Auth failure — check `docker login`: {error_msg}" + if any(kw in lower for kw in ("404", "manifest unknown", "not found")): + return f"Image not found — check name/tag/registry: {error_msg}" + if any(kw in lower for kw in ("429", "rate limit", "too many requests")): + return f"Rate limited — wait or authenticate: {error_msg}" + if any(kw in lower for kw in ("timeout", "connection refused", "no such host")): + return f"Network issue — check connectivity: {error_msg}" + + return error_msg + def print_credentials(self) -> None: """Print scan configuration.""" report_title = f"{Style.BRIGHT}Scanning container images:{Style.RESET_ALL}" @@ -637,6 +753,11 @@ class ImageProvider(Provider): f"Scanners: {Fore.YELLOW}{', '.join(self.scanners)}{Style.RESET_ALL}" ) + if self.image_config_scanners: + report_lines.append( + f"Image config scanners: {Fore.YELLOW}{', '.join(self.image_config_scanners)}{Style.RESET_ALL}" + ) + if self.trivy_severity: report_lines.append( f"Severity filter: {Fore.YELLOW}{', '.join(self.trivy_severity)}{Style.RESET_ALL}" @@ -655,12 +776,12 @@ class ImageProvider(Provider): @staticmethod def test_connection( - image: str = None, + image: str | None = None, raise_on_exception: bool = True, - provider_id: str = None, - registry_username: str = None, - registry_password: str = None, - registry_token: str = None, + provider_id: str | None = None, + registry_username: str | None = None, + registry_password: str | None = None, + registry_token: str | None = None, ) -> "Connection": """ Test connection to container registry by attempting to inspect an image. diff --git a/prowler/providers/image/lib/arguments/arguments.py b/prowler/providers/image/lib/arguments/arguments.py index e27efac97e..3809aec667 100644 --- a/prowler/providers/image/lib/arguments/arguments.py +++ b/prowler/providers/image/lib/arguments/arguments.py @@ -5,6 +5,11 @@ SCANNERS_CHOICES = [ "license", ] +IMAGE_CONFIG_SCANNERS_CHOICES = [ + "misconfig", + "secret", +] + SEVERITY_CHOICES = [ "CRITICAL", "HIGH", @@ -50,6 +55,15 @@ def init_parser(self): help="Trivy scanners to use. Default: vuln, secret. Available: vuln, secret, misconfig, license", ) + scan_config_group.add_argument( + "--image-config-scanners", + dest="image_config_scanners", + nargs="+", + default=[], + choices=IMAGE_CONFIG_SCANNERS_CHOICES, + help="Trivy image config scanners (scans Dockerfile-level metadata). Available: misconfig, secret", + ) + scan_config_group.add_argument( "--trivy-severity", dest="trivy_severity", 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/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index 324b9b6c20..f2192d8d0b 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ..." def mock_get_available_providers(): @@ -29,6 +29,7 @@ def mock_get_available_providers(): "m365", "github", "iac", + "image", "nhn", "mongodbatlas", "oraclecloud", diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index 659d03d527..6c0a923a23 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -9,7 +9,13 @@ from prowler.lib.check.models import CheckReportImage from prowler.providers.image.exceptions.exceptions import ( ImageDockerLoginError, ImageDockerNotFoundError, + ImageInvalidConfigScannerError, + ImageInvalidNameError, + ImageInvalidScannerError, + ImageInvalidSeverityError, + ImageInvalidTimeoutError, ImageListFileNotFoundError, + ImageListFileReadError, ImageNoImagesProvidedError, ImageScanError, ImageTrivyBinaryNotFoundError, @@ -46,6 +52,7 @@ class TestImageProvider: assert provider.type == "image" assert provider.images == ["alpine:3.18"] assert provider.scanners == ["vuln", "secret"] + assert provider.image_config_scanners == [] assert provider.trivy_severity == [] assert provider.ignore_unfixed is False assert provider.timeout == "5m" @@ -986,3 +993,194 @@ class TestDockerLoginIntegration: "pull", "andoniaf/test-private:tag", ] + + +class TestImageProviderInputValidation: + def test_invalid_timeout_format_raises_error(self): + """Test that a non-matching timeout string raises ImageInvalidTimeoutError.""" + with pytest.raises(ImageInvalidTimeoutError): + _make_provider(timeout="invalid") + + def test_invalid_timeout_no_unit_raises_error(self): + """Test that a numeric timeout without a unit raises ImageInvalidTimeoutError.""" + with pytest.raises(ImageInvalidTimeoutError): + _make_provider(timeout="300") + + def test_invalid_timeout_wrong_unit_raises_error(self): + """Test that a timeout with an unsupported unit raises ImageInvalidTimeoutError.""" + with pytest.raises(ImageInvalidTimeoutError): + _make_provider(timeout="5d") + + def test_valid_timeout_seconds(self): + """Test that a seconds-based timeout is accepted.""" + provider = _make_provider(timeout="300s") + assert provider.timeout == "300s" + + def test_valid_timeout_hours(self): + """Test that an hours-based timeout is accepted.""" + provider = _make_provider(timeout="1h") + assert provider.timeout == "1h" + + def test_invalid_scanner_raises_error(self): + """Test that an invalid scanner name raises ImageInvalidScannerError.""" + with pytest.raises(ImageInvalidScannerError): + _make_provider(scanners=["vuln", "bad"]) + + def test_invalid_severity_raises_error(self): + """Test that an invalid severity level raises ImageInvalidSeverityError.""" + with pytest.raises(ImageInvalidSeverityError): + _make_provider(trivy_severity=["HIGH", "SUPER_HIGH"]) + + def test_valid_all_scanners(self): + """Test that all valid scanner choices are accepted.""" + provider = _make_provider(scanners=["vuln", "secret", "misconfig", "license"]) + assert provider.scanners == ["vuln", "secret", "misconfig", "license"] + + def test_valid_all_severities(self): + """Test that all valid severity choices are accepted.""" + provider = _make_provider( + trivy_severity=["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"] + ) + assert provider.trivy_severity == [ + "CRITICAL", + "HIGH", + "MEDIUM", + "LOW", + "UNKNOWN", + ] + + def test_image_config_scanners_defaults_to_empty(self): + """Test that image_config_scanners defaults to an empty list.""" + provider = _make_provider() + assert provider.image_config_scanners == [] + + def test_valid_image_config_scanners(self): + """Test that valid image config scanners are accepted.""" + provider = _make_provider(image_config_scanners=["misconfig", "secret"]) + assert provider.image_config_scanners == ["misconfig", "secret"] + + def test_invalid_image_config_scanner_raises_error(self): + """Test that an invalid image config scanner raises ImageInvalidConfigScannerError.""" + with pytest.raises(ImageInvalidConfigScannerError): + _make_provider(image_config_scanners=["misconfig", "vuln"]) + + @patch("subprocess.run") + def test_trivy_command_includes_image_config_scanners(self, mock_subprocess): + """Test that Trivy command includes --image-config-scanners when set.""" + provider = _make_provider(image_config_scanners=["misconfig", "secret"]) + mock_subprocess.return_value = MagicMock( + returncode=0, stdout=get_empty_trivy_output(), stderr="" + ) + + for _ in provider._scan_single_image("alpine:3.18"): + pass + + call_args = mock_subprocess.call_args[0][0] + assert "--image-config-scanners" in call_args + idx = call_args.index("--image-config-scanners") + assert call_args[idx + 1] == "misconfig,secret" + + @patch("subprocess.run") + def test_trivy_command_omits_image_config_scanners_when_empty( + self, mock_subprocess + ): + """Test that Trivy command omits --image-config-scanners when empty.""" + provider = _make_provider(image_config_scanners=[]) + mock_subprocess.return_value = MagicMock( + returncode=0, stdout=get_empty_trivy_output(), stderr="" + ) + + for _ in provider._scan_single_image("alpine:3.18"): + pass + + call_args = mock_subprocess.call_args[0][0] + assert "--image-config-scanners" not in call_args + + +class TestImageProviderErrorCategorization: + def test_categorize_auth_failure(self): + """Test that auth-related errors are categorized correctly.""" + result = ImageProvider._categorize_trivy_error( + "401 unauthorized: access denied" + ) + assert "Auth failure" in result + + def test_categorize_not_found(self): + """Test that not-found errors are categorized correctly.""" + result = ImageProvider._categorize_trivy_error( + "manifest unknown: image not found" + ) + assert "Image not found" in result + + def test_categorize_rate_limit(self): + """Test that rate-limit errors are categorized correctly.""" + result = ImageProvider._categorize_trivy_error("429 too many requests") + assert "Rate limited" in result + + def test_categorize_network_issue(self): + """Test that network errors are categorized correctly.""" + result = ImageProvider._categorize_trivy_error("connection refused to registry") + assert "Network issue" in result + + def test_categorize_unknown_error(self): + """Test that unrecognized errors are returned as-is.""" + msg = "some unknown trivy error" + result = ImageProvider._categorize_trivy_error(msg) + assert result == msg + + +class TestImageProviderNameValidation: + @pytest.mark.parametrize( + "bad_name", + [ + "alpine;rm -rf /", + "image|cat /etc/passwd", + "image&background", + "image$VAR", + "image`whoami`", + "image\ninjected", + "image\rinjected", + ], + ) + def test_image_provider_invalid_image_name_shell_chars(self, bad_name): + """Test that image names with shell metacharacters raise ImageInvalidNameError.""" + with pytest.raises(ImageInvalidNameError): + _make_provider(images=[bad_name]) + + def test_image_provider_invalid_image_name_empty(self): + """Test that an empty string image name raises ImageInvalidNameError.""" + with pytest.raises(ImageInvalidNameError): + _make_provider(images=[""]) + + @pytest.mark.parametrize( + "valid_name", + [ + "alpine:3.18", + "nginx:latest", + "registry.example.com/repo/image:tag", + "ghcr.io/owner/image:v1.2.3", + "myimage@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "localhost:5000/myimage:latest", + ], + ) + def test_image_provider_valid_image_names(self, valid_name): + """Test that various valid image name formats pass validation.""" + provider = _make_provider(images=[valid_name]) + assert valid_name in provider.images + + def test_image_provider_image_name_too_long(self): + """Test that a name exceeding 500 chars raises ImageInvalidNameError.""" + long_name = "a" * 501 + with pytest.raises(ImageInvalidNameError): + _make_provider(images=[long_name]) + + def test_image_provider_file_too_many_lines(self): + """Test that a file with more than MAX_IMAGE_LIST_LINES raises ImageListFileReadError.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + for i in range(10_001): + f.write(f"image{i}:latest\n") + f.flush() + file_path = f.name + + with pytest.raises(ImageListFileReadError): + _make_provider(images=None, image_list_file=file_path) 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/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 })