From ac013ec6fc7617a2514c639b8759e4a989b92fac Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:44:22 +0100 Subject: [PATCH 01/16] feat(docs): permission error while deploying docker (#9954) --- docs/troubleshooting.mdx | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) 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). + From d016039b18448db8bcc709c70aa1c6ebf5586dfb Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Thu, 5 Feb 2026 13:07:51 +0100 Subject: [PATCH 02/16] chore(ui): prepare changelog for v5.18.0 release (#9962) --- ui/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index cd8d6a6f35..3a667853a7 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.18.0] (Prowler UNRELEASED) +## [1.18.0] (Prowler v5.18.0) ### 🔄 Changed From e07687ce6738b7222228893bb2deb101e994683a Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Thu, 5 Feb 2026 22:16:42 +0100 Subject: [PATCH 03/16] docs: Update version to v5.18.0 (#9965) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- docs/getting-started/installation/prowler-app.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" ``` From a24f818547c344a2b18c53dbb3b99fcc3a13e0ff Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Thu, 5 Feb 2026 22:17:38 +0100 Subject: [PATCH 04/16] chore(release): Bump version to v5.19.0 (#9964) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- prowler/config/config.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/prowler/config/config.py b/prowler/config/config.py index 41ac9a5cf7..daa7378280 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" From cb76e7785133cf5cf4ccf1da97f4559154c1c553 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Thu, 5 Feb 2026 22:18:33 +0100 Subject: [PATCH 05/16] chore(api): Bump version to v1.20.0 (#9968) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/src/backend/api/specs/v1.yaml | 2 +- api/src/backend/api/v1/views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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." ) From 9e20cb2e5a98807cf0f41eb6b52bca07eb4d8e47 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 6 Feb 2026 10:49:15 +0100 Subject: [PATCH 06/16] fix(ui): optimize scans page polling to avoid redundant API calls (#9974) Co-authored-by: pedrooot --- .github/test-impact.yml | 17 ++- .github/workflows/ui-e2e-tests-v2.yml | 15 ++- ui/CHANGELOG.md | 8 ++ ui/app/(prowler)/scans/page.tsx | 25 +--- .../launch-scan-workflow-form.tsx | 3 + ui/components/scans/table/scans/index.ts | 1 + .../table/scans/scans-table-with-polling.tsx | 126 ++++++++++++++++++ ui/components/ui/custom/custom-table-link.tsx | 4 +- ui/tests/scans/scans-page.ts | 1 + 9 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 ui/components/scans/table/scans/scans-table-with-polling.tsx 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/ui/CHANGELOG.md b/ui/CHANGELOG.md index 3a667853a7..7ac2c70086 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [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 }) From 619d1ffc622ea7072ea7cff07ff57d21f8616f9f Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 6 Feb 2026 11:20:10 +0100 Subject: [PATCH 07/16] chore(ci): remove legacy E2E workflow superseded by optimized v2 (#9977) --- .github/workflows/ui-e2e-tests.yml | 172 ----------------------------- 1 file changed, 172 deletions(-) delete mode 100644 .github/workflows/ui-e2e-tests.yml 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" From ecc8eaf366d8fe6d3d55db5bedfbc30f9b10eec6 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Fri, 6 Feb 2026 11:57:33 +0100 Subject: [PATCH 08/16] feat(skills): create new Attack Packs queries in openCypher (#9975) --- AGENTS.md | 4 + api/AGENTS.md | 4 + prowler/CHANGELOG.md | 8 + skills/README.md | 1 + skills/prowler-attack-paths-query/SKILL.md | 479 +++++++++++++++++++++ 5 files changed, 496 insertions(+) create mode 100644 skills/prowler-attack-paths-query/SKILL.md 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/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/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c52fe10dc9..328cf92304 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,14 @@ 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) + +--- + ## [5.18.0] (Prowler v5.18.0) ### 🚀 Added 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. From db03556ef66dcc2708a5d1a563c3a01a0375c4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 9 Feb 2026 09:09:46 +0100 Subject: [PATCH 09/16] chore(readme): update content (#9972) --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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] From cc1197d632afb47bff38c7f4a6f18cbc5e814111 Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:07:46 +0100 Subject: [PATCH 10/16] fix(image): add image provider to parser test usage string and mock The hardcoded usage string and mock_get_available_providers in parser_test.py did not include the image provider, causing 7 test failures in CI. --- tests/lib/cli/parser_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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", From 74ad1186cf0cc66cfd3d4e68c9c0cfa296721f6b Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:00:42 +0100 Subject: [PATCH 11/16] fix(image): harden input validation and fix traceback edge case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add timeout, scanner, and severity input validation with custom exceptions - Fix potential None dereference on error.__traceback__ - Modernize typing imports (List → list) - Remove PoC label from CLI help text - Update CHANGELOG entry with PR reference --- prowler/CHANGELOG.md | 2 +- prowler/lib/cli/parser.py | 2 +- .../providers/image/exceptions/__init__.py | 6 ++ .../providers/image/exceptions/exceptions.py | 39 ++++++++ prowler/providers/image/image_provider.py | 83 +++++++++++++---- tests/providers/image/image_provider_test.py | 90 +++++++++++++++++++ 6 files changed, 203 insertions(+), 19 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 581d956d1f..e158d53458 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -70,7 +70,7 @@ 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) -- Container Image provider using Trivy for vulnerability and secret scanning +- `image` provider for container image scanning with Trivy integration [(#9984)](https://github.com/prowler-cloud/prowler/pull/9984) ### Changed 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/providers/image/exceptions/__init__.py b/prowler/providers/image/exceptions/__init__.py index 95b0b3a110..6daaa639b5 100644 --- a/prowler/providers/image/exceptions/__init__.py +++ b/prowler/providers/image/exceptions/__init__.py @@ -1,6 +1,9 @@ from prowler.providers.image.exceptions.exceptions import ( ImageBaseException, ImageFindingProcessingError, + ImageInvalidScannerError, + ImageInvalidSeverityError, + ImageInvalidTimeoutError, ImageListFileNotFoundError, ImageListFileReadError, ImageNoImagesProvidedError, @@ -11,6 +14,9 @@ from prowler.providers.image.exceptions.exceptions import ( __all__ = [ "ImageBaseException", "ImageFindingProcessingError", + "ImageInvalidScannerError", + "ImageInvalidSeverityError", + "ImageInvalidTimeoutError", "ImageListFileNotFoundError", "ImageListFileReadError", "ImageNoImagesProvidedError", diff --git a/prowler/providers/image/exceptions/exceptions.py b/prowler/providers/image/exceptions/exceptions.py index f819462883..ab83a2ed10 100644 --- a/prowler/providers/image/exceptions/exceptions.py +++ b/prowler/providers/image/exceptions/exceptions.py @@ -30,6 +30,18 @@ class ImageBaseException(ProwlerException): "message": "Error scanning container image.", "remediation": "Check the image name and ensure it is accessible.", }, + (9006, "ImageInvalidTimeoutError"): { + "message": "Invalid timeout format.", + "remediation": "Use a valid timeout like '5m', '300s', or '1h'.", + }, + (9007, "ImageInvalidScannerError"): { + "message": "Invalid scanner type.", + "remediation": "Use valid scanners: vuln, secret, misconfig, license.", + }, + (9008, "ImageInvalidSeverityError"): { + "message": "Invalid severity level.", + "remediation": "Use valid severities: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -97,3 +109,30 @@ class ImageScanError(ImageBaseException): super().__init__( 9005, 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__( + 9006, 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__( + 9007, 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__( + 9008, 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 df06bfe966..f5a27117a3 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -1,9 +1,10 @@ from __future__ import annotations import json +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 @@ -19,12 +20,19 @@ from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.image.exceptions.exceptions import ( ImageFindingProcessingError, + ImageInvalidScannerError, + ImageInvalidSeverityError, + ImageInvalidTimeoutError, ImageListFileNotFoundError, ImageListFileReadError, ImageNoImagesProvidedError, ImageScanError, ImageTrivyBinaryNotFoundError, ) +from prowler.providers.image.lib.arguments.arguments import ( + SCANNERS_CHOICES, + SEVERITY_CHOICES, +) class ImageProvider(Provider): @@ -36,18 +44,19 @@ class ImageProvider(Provider): """ _type: str = "image" + FINDING_BATCH_SIZE: int = 100 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, 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, ): logger.info("Instantiating Image Provider...") @@ -64,6 +73,8 @@ class ImageProvider(Provider): self._identity = "prowler" 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) @@ -122,6 +133,28 @@ 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 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)}.", + ) + @property def auth_method(self) -> str: return self._auth_method @@ -243,7 +276,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: @@ -271,19 +304,19 @@ 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.""" reports = [] for batch in self.run_scan(): reports.extend(batch) return reports - 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 """ for image in self.images: try: @@ -296,7 +329,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. @@ -304,7 +337,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}") @@ -339,9 +372,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 @@ -360,7 +394,6 @@ class ImageProvider(Provider): # Process findings in batches batch = [] - batch_size = 100 for result in results: target = result.get("Target", image) @@ -370,7 +403,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 = [] @@ -378,7 +411,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 = [] @@ -388,7 +421,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 = [] @@ -480,6 +513,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}" @@ -511,9 +560,9 @@ class ImageProvider(Provider): @staticmethod def test_connection( - image: str = None, + image: str | None = None, raise_on_exception: bool = True, - provider_id: str = None, + provider_id: str | None = None, ) -> "Connection": """ Test connection to container registry by attempting to inspect an image. diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index f52095d27f..ab9a11bdad 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -6,6 +6,9 @@ import pytest from prowler.lib.check.models import CheckReportImage from prowler.providers.image.exceptions.exceptions import ( + ImageInvalidScannerError, + ImageInvalidSeverityError, + ImageInvalidTimeoutError, ImageListFileNotFoundError, ImageNoImagesProvidedError, ImageScanError, @@ -395,3 +398,90 @@ class TestImageProvider: with pytest.raises(ImageScanError): for _ in provider.run_scan(): pass + + +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", + ] + + +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 From a3db22defcde65e9622be9ed642e314d748d1b8f Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:33:36 +0100 Subject: [PATCH 12/16] fix(image): remove redundant resource assignment in CheckReportImage The parent class Check_Report.__init__ already sets self.resource when the resource argument is a dict, making the assignment in CheckReportImage.__init__ redundant. This resolves the CodeQL "Overwriting attribute in super-class" alert. --- prowler/lib/check/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 7524e79d66..499f75c457 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -897,7 +897,6 @@ class CheckReportImage(Check_Report): finding = {} super().__init__(metadata, finding) - self.resource = finding self.resource_name = image_name self.image_digest = finding.get("PkgID", "") self.package_name = finding.get("PkgName", "") From 132be2c72880be4d587503ea5b0a8107d6ed60d2 Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:43:11 +0100 Subject: [PATCH 13/16] fix(image): add input validation and file size limits - Add IMAGE to Provider enum for runtime routing - Validate image names against shell metacharacters and OCI format - Enforce max line count and name length on image list files - Add tests for name validation and file size limits --- prowler/config/config.py | 1 + .../providers/image/exceptions/exceptions.py | 13 ++++ prowler/providers/image/image_provider.py | 53 ++++++++++++++++- tests/providers/image/image_provider_test.py | 59 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/prowler/config/config.py b/prowler/config/config.py index b5ebe1e866..52178c7470 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -63,6 +63,7 @@ class Provider(str, Enum): ORACLECLOUD = "oraclecloud" ALIBABACLOUD = "alibabacloud" OPENSTACK = "openstack" + IMAGE = "image" # Compliance diff --git a/prowler/providers/image/exceptions/exceptions.py b/prowler/providers/image/exceptions/exceptions.py index ab83a2ed10..f040210ad6 100644 --- a/prowler/providers/image/exceptions/exceptions.py +++ b/prowler/providers/image/exceptions/exceptions.py @@ -42,6 +42,10 @@ class ImageBaseException(ProwlerException): "message": "Invalid severity level.", "remediation": "Use valid severities: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN.", }, + (9009, "ImageInvalidNameError"): { + "message": "Invalid container image name.", + "remediation": "Use a valid image reference (e.g., 'alpine:3.18', 'registry.example.com/repo/image:tag').", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -136,3 +140,12 @@ class ImageInvalidSeverityError(ImageBaseException): super().__init__( 9008, 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__( + 9009, 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 f5a27117a3..d66c9f280c 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -20,6 +20,7 @@ from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.image.exceptions.exceptions import ( ImageFindingProcessingError, + ImageInvalidNameError, ImageInvalidScannerError, ImageInvalidSeverityError, ImageInvalidTimeoutError, @@ -45,6 +46,10 @@ 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__( @@ -79,6 +84,9 @@ class ImageProvider(Provider): 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__, @@ -115,17 +123,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, @@ -155,6 +178,32 @@ class ImageProvider(Provider): 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 diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index ab9a11bdad..2cc8fe7a2d 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -6,10 +6,12 @@ import pytest from prowler.lib.check.models import CheckReportImage from prowler.providers.image.exceptions.exceptions import ( + ImageInvalidNameError, ImageInvalidScannerError, ImageInvalidSeverityError, ImageInvalidTimeoutError, ImageListFileNotFoundError, + ImageListFileReadError, ImageNoImagesProvidedError, ImageScanError, ImageTrivyBinaryNotFoundError, @@ -485,3 +487,60 @@ class TestImageProviderErrorCategorization: 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) From b1ebefb26b71aa2275a1c069dd1905792a2a8847 Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:50:24 +0100 Subject: [PATCH 14/16] chore(image): move changelog entry to 5.19.0 unreleased section --- prowler/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index e5f02e4b78..c6d3173022 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 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) --- @@ -78,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 From 43ad2f705ca3d97696a461796f7feec2185fadbb Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:55:15 +0100 Subject: [PATCH 15/16] feat(image): add --image-config-scanners support for Trivy - Add CLI argument for image config scanners (misconfig, secret) - Add validation and ImageInvalidConfigScannerError exception - Append --image-config-scanners to Trivy command when set - Add tests for defaults, validation, and command construction --- prowler/providers/common/provider.py | 1 + .../providers/image/exceptions/exceptions.py | 13 +++++ prowler/providers/image/image_provider.py | 23 +++++++++ .../image/lib/arguments/arguments.py | 14 ++++++ tests/providers/image/image_provider_test.py | 49 +++++++++++++++++++ 5 files changed, 100 insertions(+) diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index df141fd312..f638fe47f2 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/exceptions.py b/prowler/providers/image/exceptions/exceptions.py index f040210ad6..591358d829 100644 --- a/prowler/providers/image/exceptions/exceptions.py +++ b/prowler/providers/image/exceptions/exceptions.py @@ -46,6 +46,10 @@ class ImageBaseException(ProwlerException): "message": "Invalid container image name.", "remediation": "Use a valid image reference (e.g., 'alpine:3.18', 'registry.example.com/repo/image:tag').", }, + (9010, "ImageInvalidConfigScannerError"): { + "message": "Invalid image config scanner type.", + "remediation": "Use valid image config scanners: misconfig, secret.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -149,3 +153,12 @@ class ImageInvalidNameError(ImageBaseException): super().__init__( 9009, 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__( + 9010, 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 d66c9f280c..1e11651253 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -20,6 +20,7 @@ from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.image.exceptions.exceptions import ( ImageFindingProcessingError, + ImageInvalidConfigScannerError, ImageInvalidNameError, ImageInvalidScannerError, ImageInvalidSeverityError, @@ -31,6 +32,7 @@ from prowler.providers.image.exceptions.exceptions import ( ImageTrivyBinaryNotFoundError, ) from prowler.providers.image.lib.arguments.arguments import ( + IMAGE_CONFIG_SCANNERS_CHOICES, SCANNERS_CHOICES, SEVERITY_CHOICES, ) @@ -57,6 +59,7 @@ class ImageProvider(Provider): images: list[str] | None = 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", @@ -69,6 +72,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 @@ -171,6 +177,13 @@ class ImageProvider(Provider): 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( @@ -403,6 +416,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)]) @@ -595,6 +613,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}" 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/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index 2cc8fe7a2d..75bebaefc5 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -6,6 +6,7 @@ import pytest from prowler.lib.check.models import CheckReportImage from prowler.providers.image.exceptions.exceptions import ( + ImageInvalidConfigScannerError, ImageInvalidNameError, ImageInvalidScannerError, ImageInvalidSeverityError, @@ -48,6 +49,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" @@ -456,6 +458,53 @@ class TestImageProviderInputValidation: "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): From bfd82180328ebbc74e2d505e32de92a18c3c56c2 Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:20:26 +0100 Subject: [PATCH 16/16] fix(image): address PR review feedback - Change exception error codes from 9000 to 11000 range to avoid Cloudflare conflict - Add EXTERNAL_TOOL_PROVIDERS constant to replace hardcoded provider tuples - Add resource_id field to CheckReportImage populated from Trivy finding IDs - Use resource_id for resource_uid in finding output - Join image names in summary table instead of showing count - Clear exceptions __init__.py re-exports --- prowler/__main__.py | 13 +++--- prowler/config/config.py | 4 ++ prowler/lib/check/models.py | 22 ++++----- prowler/lib/outputs/finding.py | 2 +- prowler/lib/outputs/summary_table.py | 5 +- .../providers/image/exceptions/__init__.py | 25 ---------- .../providers/image/exceptions/exceptions.py | 46 +++++++++---------- 7 files changed, 45 insertions(+), 72 deletions(-) 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 7219e9a7eb..77674c7f93 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -66,6 +66,10 @@ class Provider(str, Enum): 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 499f75c457..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 @@ -898,6 +889,11 @@ class CheckReportImage(Check_Report): super().__init__(metadata, 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/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/image/exceptions/__init__.py b/prowler/providers/image/exceptions/__init__.py index 6daaa639b5..e69de29bb2 100644 --- a/prowler/providers/image/exceptions/__init__.py +++ b/prowler/providers/image/exceptions/__init__.py @@ -1,25 +0,0 @@ -from prowler.providers.image.exceptions.exceptions import ( - ImageBaseException, - ImageFindingProcessingError, - ImageInvalidScannerError, - ImageInvalidSeverityError, - ImageInvalidTimeoutError, - ImageListFileNotFoundError, - ImageListFileReadError, - ImageNoImagesProvidedError, - ImageScanError, - ImageTrivyBinaryNotFoundError, -) - -__all__ = [ - "ImageBaseException", - "ImageFindingProcessingError", - "ImageInvalidScannerError", - "ImageInvalidSeverityError", - "ImageInvalidTimeoutError", - "ImageListFileNotFoundError", - "ImageListFileReadError", - "ImageNoImagesProvidedError", - "ImageScanError", - "ImageTrivyBinaryNotFoundError", -] diff --git a/prowler/providers/image/exceptions/exceptions.py b/prowler/providers/image/exceptions/exceptions.py index 591358d829..0e115a83ec 100644 --- a/prowler/providers/image/exceptions/exceptions.py +++ b/prowler/providers/image/exceptions/exceptions.py @@ -1,52 +1,52 @@ 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, "ImageInvalidTimeoutError"): { + (11006, "ImageInvalidTimeoutError"): { "message": "Invalid timeout format.", "remediation": "Use a valid timeout like '5m', '300s', or '1h'.", }, - (9007, "ImageInvalidScannerError"): { + (11007, "ImageInvalidScannerError"): { "message": "Invalid scanner type.", "remediation": "Use valid scanners: vuln, secret, misconfig, license.", }, - (9008, "ImageInvalidSeverityError"): { + (11008, "ImageInvalidSeverityError"): { "message": "Invalid severity level.", "remediation": "Use valid severities: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN.", }, - (9009, "ImageInvalidNameError"): { + (11009, "ImageInvalidNameError"): { "message": "Invalid container image name.", "remediation": "Use a valid image reference (e.g., 'alpine:3.18', 'registry.example.com/repo/image:tag').", }, - (9010, "ImageInvalidConfigScannerError"): { + (11010, "ImageInvalidConfigScannerError"): { "message": "Invalid image config scanner type.", "remediation": "Use valid image config scanners: misconfig, secret.", }, @@ -70,7 +70,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 ) @@ -79,7 +79,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 ) @@ -88,7 +88,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 ) @@ -97,7 +97,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 ) @@ -106,7 +106,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 ) @@ -115,7 +115,7 @@ 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 ) @@ -124,7 +124,7 @@ class ImageInvalidTimeoutError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9006, file=file, original_exception=original_exception, message=message + 11006, file=file, original_exception=original_exception, message=message ) @@ -133,7 +133,7 @@ class ImageInvalidScannerError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9007, file=file, original_exception=original_exception, message=message + 11007, file=file, original_exception=original_exception, message=message ) @@ -142,7 +142,7 @@ class ImageInvalidSeverityError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9008, file=file, original_exception=original_exception, message=message + 11008, file=file, original_exception=original_exception, message=message ) @@ -151,7 +151,7 @@ class ImageInvalidNameError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9009, file=file, original_exception=original_exception, message=message + 11009, file=file, original_exception=original_exception, message=message ) @@ -160,5 +160,5 @@ class ImageInvalidConfigScannerError(ImageBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - 9010, file=file, original_exception=original_exception, message=message + 11010, file=file, original_exception=original_exception, message=message )