mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-29 23:04:23 +00:00
fix(image): include missing merge changes from stage-1 and docs branches
The pre-commit hook stash/restore cycle during the first merge caused some auto-merged changes to be excluded from the merge commits. This commit includes the remaining changes: error code renumbering (9000→11000 range), input validation, image config scanners, and docs branch updates.
This commit is contained in:
31 files changed
+598
-299
No files matched your search
+10
-7
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
e2e-tests:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -204,7 +204,14 @@ jobs:
|
||||
# e.g., "ui/tests/providers/**" -> "tests/providers"
|
||||
TEST_PATHS="${{ env.E2E_TEST_PATHS }}"
|
||||
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u | tr '\n' ' ')
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
|
||||
# Drop auth setup helpers (not runnable test suites)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
|
||||
if [[ -z "$TEST_PATHS" ]]; then
|
||||
echo "No runnable E2E test paths after filtering setups"
|
||||
exit 0
|
||||
fi
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | tr '\n' ' ')
|
||||
echo "Resolved test paths: $TEST_PATHS"
|
||||
pnpm exec playwright test $TEST_PATHS
|
||||
fi
|
||||
@@ -226,8 +233,8 @@ jobs:
|
||||
skip-e2e:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
|
||||
needs.impact-analysis.outputs.run-all != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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"
|
||||
@@ -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` |
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.19.0
|
||||
version: 1.20.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -86,3 +86,81 @@ docker compose up -d
|
||||
<Note>
|
||||
We are evaluating adding these values to the default `docker-compose.yml` to avoid this issue in future releases.
|
||||
</Note>
|
||||
|
||||
### 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=<content of jwt_private.pem>
|
||||
DJANGO_TOKEN_VERIFYING_KEY=<content of jwt_public.pem>
|
||||
```
|
||||
|
||||
When these environment variables are set, the API will use them directly instead of attempting to write key files to the mounted volume.
|
||||
|
||||
<Note>
|
||||
A fix addressing this permission issue is being evaluated in [PR #9953](https://github.com/prowler-cloud/prowler/pull/9953).
|
||||
</Note>
|
||||
+7
-6
@@ -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):
|
||||
|
||||
@@ -38,7 +38,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.18.0"
|
||||
prowler_version = "5.19.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
@@ -63,8 +63,13 @@ class Provider(str, Enum):
|
||||
ORACLECLOUD = "oraclecloud"
|
||||
ALIBABACLOUD = "alibabacloud"
|
||||
OPENSTACK = "openstack"
|
||||
IMAGE = "image"
|
||||
|
||||
|
||||
# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo)
|
||||
# and bypass standard check/service loading.
|
||||
EXTERNAL_TOOL_PROVIDERS = frozenset({"iac", "llm", "image"})
|
||||
|
||||
# Compliance
|
||||
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any, Dict, Optional, Set
|
||||
from pydantic.v1 import BaseModel, Field, ValidationError, validator
|
||||
from pydantic.v1.error_wrappers import ErrorWrapper
|
||||
|
||||
from prowler.config.config import Provider
|
||||
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.utils import recover_checks_from_provider
|
||||
from prowler.lib.logger import logger
|
||||
@@ -159,12 +159,7 @@ class CheckMetadata(BaseModel):
|
||||
raise ValueError("ServiceName must be a non-empty string")
|
||||
|
||||
check_id = values.get("CheckID")
|
||||
if (
|
||||
check_id
|
||||
and values.get("Provider") != "iac"
|
||||
and values.get("Provider") != "llm"
|
||||
and values.get("Provider") != "image"
|
||||
):
|
||||
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
service_from_check_id = check_id.split("_")[0]
|
||||
if service_name != service_from_check_id:
|
||||
raise ValueError(
|
||||
@@ -180,12 +175,7 @@ class CheckMetadata(BaseModel):
|
||||
if not check_id:
|
||||
raise ValueError("CheckID must be a non-empty string")
|
||||
|
||||
if (
|
||||
check_id
|
||||
and values.get("Provider") != "iac"
|
||||
and values.get("Provider") != "llm"
|
||||
and values.get("Provider") != "image"
|
||||
):
|
||||
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
|
||||
if "-" in check_id:
|
||||
raise ValueError(
|
||||
f"CheckID {check_id} contains a hyphen, which is not allowed"
|
||||
@@ -872,6 +862,7 @@ class CheckReportImage(Check_Report):
|
||||
"""Contains the Container Image Check's finding information using Trivy."""
|
||||
|
||||
resource_name: str
|
||||
resource_id: str
|
||||
image_digest: str
|
||||
package_name: str
|
||||
installed_version: str
|
||||
@@ -897,8 +888,12 @@ class CheckReportImage(Check_Report):
|
||||
finding = {}
|
||||
super().__init__(metadata, finding)
|
||||
|
||||
self.resource = finding
|
||||
self.resource_name = image_name
|
||||
self.resource_id = (
|
||||
finding.get("VulnerabilityID", "")
|
||||
or finding.get("RuleID", "")
|
||||
or finding.get("ID", "")
|
||||
)
|
||||
self.image_digest = finding.get("PkgID", "")
|
||||
self.package_name = finding.get("PkgName", "")
|
||||
self.installed_version = finding.get("InstalledVersion", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageBaseException,
|
||||
ImageFindingProcessingError,
|
||||
ImageListFileNotFoundError,
|
||||
ImageListFileReadError,
|
||||
ImageNoImagesProvidedError,
|
||||
ImageScanError,
|
||||
ImageTrivyBinaryNotFoundError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ImageBaseException",
|
||||
"ImageFindingProcessingError",
|
||||
"ImageListFileNotFoundError",
|
||||
"ImageListFileReadError",
|
||||
"ImageNoImagesProvidedError",
|
||||
"ImageScanError",
|
||||
"ImageTrivyBinaryNotFoundError",
|
||||
]
|
||||
@@ -1,40 +1,60 @@
|
||||
from prowler.exceptions.exceptions import ProwlerException
|
||||
|
||||
|
||||
# Exceptions codes from 9000 to 9999 are reserved for Image exceptions
|
||||
# Exceptions codes from 11000 to 11999 are reserved for Image exceptions
|
||||
class ImageBaseException(ProwlerException):
|
||||
"""Base class for Image provider errors."""
|
||||
|
||||
IMAGE_ERROR_CODES = {
|
||||
(9000, "ImageNoImagesProvidedError"): {
|
||||
(11000, "ImageNoImagesProvidedError"): {
|
||||
"message": "No container images provided for scanning.",
|
||||
"remediation": "Provide at least one image using --image or --image-list-file.",
|
||||
},
|
||||
(9001, "ImageListFileNotFoundError"): {
|
||||
(11001, "ImageListFileNotFoundError"): {
|
||||
"message": "Image list file not found.",
|
||||
"remediation": "Ensure the image list file exists at the specified path.",
|
||||
},
|
||||
(9002, "ImageListFileReadError"): {
|
||||
(11002, "ImageListFileReadError"): {
|
||||
"message": "Error reading image list file.",
|
||||
"remediation": "Check file permissions and format. The file should contain one image per line.",
|
||||
},
|
||||
(9003, "ImageFindingProcessingError"): {
|
||||
(11003, "ImageFindingProcessingError"): {
|
||||
"message": "Error processing image scan finding.",
|
||||
"remediation": "Check the Trivy output format and ensure the finding structure is valid.",
|
||||
},
|
||||
(9004, "ImageTrivyBinaryNotFoundError"): {
|
||||
(11004, "ImageTrivyBinaryNotFoundError"): {
|
||||
"message": "Trivy binary not found.",
|
||||
"remediation": "Install Trivy from https://trivy.dev/latest/getting-started/installation/",
|
||||
},
|
||||
(9005, "ImageScanError"): {
|
||||
(11005, "ImageScanError"): {
|
||||
"message": "Error scanning container image.",
|
||||
"remediation": "Check the image name and ensure it is accessible.",
|
||||
},
|
||||
(9006, "ImageDockerLoginError"): {
|
||||
(11006, "ImageInvalidTimeoutError"): {
|
||||
"message": "Invalid timeout format.",
|
||||
"remediation": "Use a valid timeout like '5m', '300s', or '1h'.",
|
||||
},
|
||||
(11007, "ImageInvalidScannerError"): {
|
||||
"message": "Invalid scanner type.",
|
||||
"remediation": "Use valid scanners: vuln, secret, misconfig, license.",
|
||||
},
|
||||
(11008, "ImageInvalidSeverityError"): {
|
||||
"message": "Invalid severity level.",
|
||||
"remediation": "Use valid severities: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN.",
|
||||
},
|
||||
(11009, "ImageInvalidNameError"): {
|
||||
"message": "Invalid container image name.",
|
||||
"remediation": "Use a valid image reference (e.g., 'alpine:3.18', 'registry.example.com/repo/image:tag').",
|
||||
},
|
||||
(11010, "ImageInvalidConfigScannerError"): {
|
||||
"message": "Invalid image config scanner type.",
|
||||
"remediation": "Use valid image config scanners: misconfig, secret.",
|
||||
},
|
||||
(11011, "ImageDockerLoginError"): {
|
||||
"message": "Docker login failed for registry authentication.",
|
||||
"remediation": "Check your registry credentials and ensure the registry is reachable.",
|
||||
},
|
||||
(9007, "ImageDockerNotFoundError"): {
|
||||
(11012, "ImageDockerNotFoundError"): {
|
||||
"message": "Docker binary not found.",
|
||||
"remediation": "Install Docker to enable private registry authentication via docker login.",
|
||||
},
|
||||
@@ -58,7 +78,7 @@ class ImageNoImagesProvidedError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9000, file=file, original_exception=original_exception, message=message
|
||||
11000, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +87,7 @@ class ImageListFileNotFoundError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9001, file=file, original_exception=original_exception, message=message
|
||||
11001, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -76,7 +96,7 @@ class ImageListFileReadError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9002, file=file, original_exception=original_exception, message=message
|
||||
11002, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +105,7 @@ class ImageFindingProcessingError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9003, file=file, original_exception=original_exception, message=message
|
||||
11003, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -94,7 +114,7 @@ class ImageTrivyBinaryNotFoundError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9004, file=file, original_exception=original_exception, message=message
|
||||
11004, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -103,7 +123,52 @@ class ImageScanError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9005, file=file, original_exception=original_exception, message=message
|
||||
11005, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageInvalidTimeoutError(ImageBaseException):
|
||||
"""Exception raised when an invalid timeout format is provided."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11006, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageInvalidScannerError(ImageBaseException):
|
||||
"""Exception raised when an invalid scanner type is provided."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11007, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageInvalidSeverityError(ImageBaseException):
|
||||
"""Exception raised when an invalid severity level is provided."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11008, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageInvalidNameError(ImageBaseException):
|
||||
"""Exception raised when an invalid container image name is provided."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11009, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageInvalidConfigScannerError(ImageBaseException):
|
||||
"""Exception raised when an invalid image config scanner type is provided."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11010, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -112,7 +177,7 @@ class ImageDockerLoginError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9006, file=file, original_exception=original_exception, message=message
|
||||
11011, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -121,5 +186,5 @@ class ImageDockerNotFoundError(ImageBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
9007, file=file, original_exception=original_exception, message=message
|
||||
11012, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Generator, List
|
||||
from typing import Generator
|
||||
|
||||
from alive_progress import alive_bar
|
||||
from colorama import Fore, Style
|
||||
@@ -22,12 +23,22 @@ from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageDockerLoginError,
|
||||
ImageDockerNotFoundError,
|
||||
ImageFindingProcessingError,
|
||||
ImageInvalidConfigScannerError,
|
||||
ImageInvalidNameError,
|
||||
ImageInvalidScannerError,
|
||||
ImageInvalidSeverityError,
|
||||
ImageInvalidTimeoutError,
|
||||
ImageListFileNotFoundError,
|
||||
ImageListFileReadError,
|
||||
ImageNoImagesProvidedError,
|
||||
ImageScanError,
|
||||
ImageTrivyBinaryNotFoundError,
|
||||
)
|
||||
from prowler.providers.image.lib.arguments.arguments import (
|
||||
IMAGE_CONFIG_SCANNERS_CHOICES,
|
||||
SCANNERS_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
)
|
||||
|
||||
|
||||
class ImageProvider(Provider):
|
||||
@@ -39,18 +50,24 @@ class ImageProvider(Provider):
|
||||
"""
|
||||
|
||||
_type: str = "image"
|
||||
FINDING_BATCH_SIZE: int = 100
|
||||
MAX_IMAGE_LIST_LINES: int = 10_000
|
||||
MAX_IMAGE_NAME_LENGTH: int = 500
|
||||
_IMAGE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9.\-_/:@]+$")
|
||||
_SHELL_METACHARACTERS = frozenset(";|&$`\n\r")
|
||||
audit_metadata: Audit_Metadata
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
images: list[str] | None = None,
|
||||
image_list_file: str = None,
|
||||
image_list_file: str | None = None,
|
||||
scanners: list[str] | None = None,
|
||||
image_config_scanners: list[str] | None = None,
|
||||
trivy_severity: list[str] | None = None,
|
||||
ignore_unfixed: bool = False,
|
||||
timeout: str = "5m",
|
||||
config_path: str = None,
|
||||
config_content: dict = None,
|
||||
config_path: str | None = None,
|
||||
config_content: dict | None = None,
|
||||
fixer_config: dict | None = None,
|
||||
registry_username: str = None,
|
||||
registry_password: str = None,
|
||||
@@ -61,6 +78,9 @@ class ImageProvider(Provider):
|
||||
self.images = images if images is not None else []
|
||||
self.image_list_file = image_list_file
|
||||
self.scanners = scanners if scanners is not None else ["vuln", "secret"]
|
||||
self.image_config_scanners = (
|
||||
image_config_scanners if image_config_scanners is not None else []
|
||||
)
|
||||
self.trivy_severity = trivy_severity if trivy_severity is not None else []
|
||||
self.ignore_unfixed = ignore_unfixed
|
||||
self.timeout = timeout
|
||||
@@ -84,10 +104,15 @@ class ImageProvider(Provider):
|
||||
else:
|
||||
self._auth_method = "No auth"
|
||||
|
||||
self._validate_inputs()
|
||||
|
||||
# Load images from file if provided
|
||||
if image_list_file:
|
||||
self._load_images_from_file(image_list_file)
|
||||
|
||||
for image in self.images:
|
||||
self._validate_image_name(image)
|
||||
|
||||
if not self.images:
|
||||
raise ImageNoImagesProvidedError(
|
||||
file=__file__,
|
||||
@@ -124,17 +149,32 @@ class ImageProvider(Provider):
|
||||
def _load_images_from_file(self, file_path: str) -> None:
|
||||
"""Load image names from a file (one per line)."""
|
||||
try:
|
||||
line_count = 0
|
||||
with open(file_path, "r") as f:
|
||||
for line in f:
|
||||
line_count += 1
|
||||
if line_count > self.MAX_IMAGE_LIST_LINES:
|
||||
raise ImageListFileReadError(
|
||||
file=file_path,
|
||||
message=f"Image list file exceeds maximum of {self.MAX_IMAGE_LIST_LINES} lines.",
|
||||
)
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
self.images.append(line)
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if len(line) > self.MAX_IMAGE_NAME_LENGTH:
|
||||
logger.warning(
|
||||
f"Skipping image name exceeding {self.MAX_IMAGE_NAME_LENGTH} chars at line {line_count} in {file_path}"
|
||||
)
|
||||
continue
|
||||
self.images.append(line)
|
||||
logger.info(f"Loaded {len(self.images)} images from {file_path}")
|
||||
except FileNotFoundError:
|
||||
raise ImageListFileNotFoundError(
|
||||
file=file_path,
|
||||
message=f"Image list file not found: {file_path}",
|
||||
)
|
||||
except (ImageListFileReadError, ImageListFileNotFoundError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ImageListFileReadError(
|
||||
file=file_path,
|
||||
@@ -142,6 +182,61 @@ class ImageProvider(Provider):
|
||||
message=f"Error reading image list file: {error}",
|
||||
)
|
||||
|
||||
def _validate_inputs(self) -> None:
|
||||
"""Validate timeout, scanners, and severity inputs."""
|
||||
if not re.fullmatch(r"\d+[smh]", self.timeout):
|
||||
raise ImageInvalidTimeoutError(
|
||||
file=__file__,
|
||||
message=f"Invalid timeout format: '{self.timeout}'. Expected pattern like '5m', '300s', or '1h'.",
|
||||
)
|
||||
|
||||
for scanner in self.scanners:
|
||||
if scanner not in SCANNERS_CHOICES:
|
||||
raise ImageInvalidScannerError(
|
||||
file=__file__,
|
||||
message=f"Invalid scanner: '{scanner}'. Valid options: {', '.join(SCANNERS_CHOICES)}.",
|
||||
)
|
||||
|
||||
for config_scanner in self.image_config_scanners:
|
||||
if config_scanner not in IMAGE_CONFIG_SCANNERS_CHOICES:
|
||||
raise ImageInvalidConfigScannerError(
|
||||
file=__file__,
|
||||
message=f"Invalid image config scanner: '{config_scanner}'. Valid options: {', '.join(IMAGE_CONFIG_SCANNERS_CHOICES)}.",
|
||||
)
|
||||
|
||||
for severity in self.trivy_severity:
|
||||
if severity not in SEVERITY_CHOICES:
|
||||
raise ImageInvalidSeverityError(
|
||||
file=__file__,
|
||||
message=f"Invalid severity: '{severity}'. Valid options: {', '.join(SEVERITY_CHOICES)}.",
|
||||
)
|
||||
|
||||
def _validate_image_name(self, name: str) -> None:
|
||||
"""Validate a container image name for safety and correctness."""
|
||||
if not name:
|
||||
raise ImageInvalidNameError(
|
||||
file=__file__,
|
||||
message="Image name must not be empty.",
|
||||
)
|
||||
|
||||
if len(name) > self.MAX_IMAGE_NAME_LENGTH:
|
||||
raise ImageInvalidNameError(
|
||||
file=__file__,
|
||||
message=f"Image name exceeds maximum length of {self.MAX_IMAGE_NAME_LENGTH} characters: '{name[:50]}...'",
|
||||
)
|
||||
|
||||
if any(c in self._SHELL_METACHARACTERS for c in name):
|
||||
raise ImageInvalidNameError(
|
||||
file=__file__,
|
||||
message=f"Image name contains invalid characters: '{name}'",
|
||||
)
|
||||
|
||||
if not self._IMAGE_NAME_PATTERN.fullmatch(name):
|
||||
raise ImageInvalidNameError(
|
||||
file=__file__,
|
||||
message=f"Image name does not match valid OCI reference format: '{name}'",
|
||||
)
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return self._auth_method
|
||||
@@ -357,7 +452,7 @@ class ImageProvider(Provider):
|
||||
raise ImageFindingProcessingError(
|
||||
file=__file__,
|
||||
original_exception=error,
|
||||
message=f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}",
|
||||
message=f"Error processing finding: {error}",
|
||||
)
|
||||
|
||||
def _build_status_extended(self, finding: dict) -> str:
|
||||
@@ -385,7 +480,7 @@ class ImageProvider(Provider):
|
||||
" ".join(parts) if parts else finding.get("Description", "Finding detected")
|
||||
)
|
||||
|
||||
def run(self) -> List[CheckReportImage]:
|
||||
def run(self) -> list[CheckReportImage]:
|
||||
"""Execute the container image scan."""
|
||||
try:
|
||||
reports = []
|
||||
@@ -395,12 +490,12 @@ class ImageProvider(Provider):
|
||||
finally:
|
||||
self.cleanup()
|
||||
|
||||
def run_scan(self) -> Generator[List[CheckReportImage], None, None]:
|
||||
def run_scan(self) -> Generator[list[CheckReportImage], None, None]:
|
||||
"""
|
||||
Run Trivy scan on all configured images.
|
||||
|
||||
Yields:
|
||||
List[CheckReportImage]: Batches of findings
|
||||
list[CheckReportImage]: Batches of findings
|
||||
"""
|
||||
if self.registry_username and self.registry_password:
|
||||
registries = {self._extract_registry(img) for img in self.images}
|
||||
@@ -420,7 +515,7 @@ class ImageProvider(Provider):
|
||||
|
||||
def _scan_single_image(
|
||||
self, image: str
|
||||
) -> Generator[List[CheckReportImage], None, None]:
|
||||
) -> Generator[list[CheckReportImage], None, None]:
|
||||
"""
|
||||
Scan a single container image with Trivy.
|
||||
|
||||
@@ -428,7 +523,7 @@ class ImageProvider(Provider):
|
||||
image: The container image name/tag to scan
|
||||
|
||||
Yields:
|
||||
List[CheckReportImage]: Batches of findings
|
||||
list[CheckReportImage]: Batches of findings
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Scanning container image: {image}")
|
||||
@@ -445,6 +540,11 @@ class ImageProvider(Provider):
|
||||
self.timeout,
|
||||
]
|
||||
|
||||
if self.image_config_scanners:
|
||||
trivy_command.extend(
|
||||
["--image-config-scanners", ",".join(self.image_config_scanners)]
|
||||
)
|
||||
|
||||
if self.trivy_severity:
|
||||
trivy_command.extend(["--severity", ",".join(self.trivy_severity)])
|
||||
|
||||
@@ -463,9 +563,10 @@ class ImageProvider(Provider):
|
||||
# Check for Trivy failure
|
||||
if process.returncode != 0:
|
||||
error_msg = self._extract_trivy_errors(process.stderr)
|
||||
categorized_msg = self._categorize_trivy_error(error_msg)
|
||||
raise ImageScanError(
|
||||
file=__file__,
|
||||
message=f"Trivy scan failed for {image}: {error_msg}",
|
||||
message=f"Trivy scan failed for {image}: {categorized_msg}",
|
||||
)
|
||||
|
||||
# Parse JSON output
|
||||
@@ -484,7 +585,6 @@ class ImageProvider(Provider):
|
||||
|
||||
# Process findings in batches
|
||||
batch = []
|
||||
batch_size = 100
|
||||
|
||||
for result in results:
|
||||
target = result.get("Target", image)
|
||||
@@ -494,7 +594,7 @@ class ImageProvider(Provider):
|
||||
for vuln in result.get("Vulnerabilities", []):
|
||||
report = self._process_finding(vuln, target, result_type)
|
||||
batch.append(report)
|
||||
if len(batch) >= batch_size:
|
||||
if len(batch) >= self.FINDING_BATCH_SIZE:
|
||||
yield batch
|
||||
batch = []
|
||||
|
||||
@@ -502,7 +602,7 @@ class ImageProvider(Provider):
|
||||
for secret in result.get("Secrets", []):
|
||||
report = self._process_finding(secret, target, "secret")
|
||||
batch.append(report)
|
||||
if len(batch) >= batch_size:
|
||||
if len(batch) >= self.FINDING_BATCH_SIZE:
|
||||
yield batch
|
||||
batch = []
|
||||
|
||||
@@ -512,7 +612,7 @@ class ImageProvider(Provider):
|
||||
misconfig, target, "misconfiguration"
|
||||
)
|
||||
batch.append(report)
|
||||
if len(batch) >= batch_size:
|
||||
if len(batch) >= self.FINDING_BATCH_SIZE:
|
||||
yield batch
|
||||
batch = []
|
||||
|
||||
@@ -620,6 +720,22 @@ class ImageProvider(Provider):
|
||||
return line.strip()[:500]
|
||||
return "Unknown error"
|
||||
|
||||
@staticmethod
|
||||
def _categorize_trivy_error(error_msg: str) -> str:
|
||||
"""Categorize a Trivy error message to provide actionable guidance."""
|
||||
lower = error_msg.lower()
|
||||
|
||||
if any(kw in lower for kw in ("401", "403", "unauthorized", "denied")):
|
||||
return f"Auth failure — check `docker login`: {error_msg}"
|
||||
if any(kw in lower for kw in ("404", "manifest unknown", "not found")):
|
||||
return f"Image not found — check name/tag/registry: {error_msg}"
|
||||
if any(kw in lower for kw in ("429", "rate limit", "too many requests")):
|
||||
return f"Rate limited — wait or authenticate: {error_msg}"
|
||||
if any(kw in lower for kw in ("timeout", "connection refused", "no such host")):
|
||||
return f"Network issue — check connectivity: {error_msg}"
|
||||
|
||||
return error_msg
|
||||
|
||||
def print_credentials(self) -> None:
|
||||
"""Print scan configuration."""
|
||||
report_title = f"{Style.BRIGHT}Scanning container images:{Style.RESET_ALL}"
|
||||
@@ -637,6 +753,11 @@ class ImageProvider(Provider):
|
||||
f"Scanners: {Fore.YELLOW}{', '.join(self.scanners)}{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
if self.image_config_scanners:
|
||||
report_lines.append(
|
||||
f"Image config scanners: {Fore.YELLOW}{', '.join(self.image_config_scanners)}{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
if self.trivy_severity:
|
||||
report_lines.append(
|
||||
f"Severity filter: {Fore.YELLOW}{', '.join(self.trivy_severity)}{Style.RESET_ALL}"
|
||||
@@ -655,12 +776,12 @@ class ImageProvider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def test_connection(
|
||||
image: str = None,
|
||||
image: str | None = None,
|
||||
raise_on_exception: bool = True,
|
||||
provider_id: str = None,
|
||||
registry_username: str = None,
|
||||
registry_password: str = None,
|
||||
registry_token: str = None,
|
||||
provider_id: str | None = None,
|
||||
registry_username: str | None = None,
|
||||
registry_password: str | None = None,
|
||||
registry_token: str | None = None,
|
||||
) -> "Connection":
|
||||
"""
|
||||
Test connection to container registry by attempting to inspect an image.
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,7 +9,13 @@ from prowler.lib.check.models import CheckReportImage
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageDockerLoginError,
|
||||
ImageDockerNotFoundError,
|
||||
ImageInvalidConfigScannerError,
|
||||
ImageInvalidNameError,
|
||||
ImageInvalidScannerError,
|
||||
ImageInvalidSeverityError,
|
||||
ImageInvalidTimeoutError,
|
||||
ImageListFileNotFoundError,
|
||||
ImageListFileReadError,
|
||||
ImageNoImagesProvidedError,
|
||||
ImageScanError,
|
||||
ImageTrivyBinaryNotFoundError,
|
||||
@@ -46,6 +52,7 @@ class TestImageProvider:
|
||||
assert provider.type == "image"
|
||||
assert provider.images == ["alpine:3.18"]
|
||||
assert provider.scanners == ["vuln", "secret"]
|
||||
assert provider.image_config_scanners == []
|
||||
assert provider.trivy_severity == []
|
||||
assert provider.ignore_unfixed is False
|
||||
assert provider.timeout == "5m"
|
||||
@@ -986,3 +993,194 @@ class TestDockerLoginIntegration:
|
||||
"pull",
|
||||
"andoniaf/test-private:tag",
|
||||
]
|
||||
|
||||
|
||||
class TestImageProviderInputValidation:
|
||||
def test_invalid_timeout_format_raises_error(self):
|
||||
"""Test that a non-matching timeout string raises ImageInvalidTimeoutError."""
|
||||
with pytest.raises(ImageInvalidTimeoutError):
|
||||
_make_provider(timeout="invalid")
|
||||
|
||||
def test_invalid_timeout_no_unit_raises_error(self):
|
||||
"""Test that a numeric timeout without a unit raises ImageInvalidTimeoutError."""
|
||||
with pytest.raises(ImageInvalidTimeoutError):
|
||||
_make_provider(timeout="300")
|
||||
|
||||
def test_invalid_timeout_wrong_unit_raises_error(self):
|
||||
"""Test that a timeout with an unsupported unit raises ImageInvalidTimeoutError."""
|
||||
with pytest.raises(ImageInvalidTimeoutError):
|
||||
_make_provider(timeout="5d")
|
||||
|
||||
def test_valid_timeout_seconds(self):
|
||||
"""Test that a seconds-based timeout is accepted."""
|
||||
provider = _make_provider(timeout="300s")
|
||||
assert provider.timeout == "300s"
|
||||
|
||||
def test_valid_timeout_hours(self):
|
||||
"""Test that an hours-based timeout is accepted."""
|
||||
provider = _make_provider(timeout="1h")
|
||||
assert provider.timeout == "1h"
|
||||
|
||||
def test_invalid_scanner_raises_error(self):
|
||||
"""Test that an invalid scanner name raises ImageInvalidScannerError."""
|
||||
with pytest.raises(ImageInvalidScannerError):
|
||||
_make_provider(scanners=["vuln", "bad"])
|
||||
|
||||
def test_invalid_severity_raises_error(self):
|
||||
"""Test that an invalid severity level raises ImageInvalidSeverityError."""
|
||||
with pytest.raises(ImageInvalidSeverityError):
|
||||
_make_provider(trivy_severity=["HIGH", "SUPER_HIGH"])
|
||||
|
||||
def test_valid_all_scanners(self):
|
||||
"""Test that all valid scanner choices are accepted."""
|
||||
provider = _make_provider(scanners=["vuln", "secret", "misconfig", "license"])
|
||||
assert provider.scanners == ["vuln", "secret", "misconfig", "license"]
|
||||
|
||||
def test_valid_all_severities(self):
|
||||
"""Test that all valid severity choices are accepted."""
|
||||
provider = _make_provider(
|
||||
trivy_severity=["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]
|
||||
)
|
||||
assert provider.trivy_severity == [
|
||||
"CRITICAL",
|
||||
"HIGH",
|
||||
"MEDIUM",
|
||||
"LOW",
|
||||
"UNKNOWN",
|
||||
]
|
||||
|
||||
def test_image_config_scanners_defaults_to_empty(self):
|
||||
"""Test that image_config_scanners defaults to an empty list."""
|
||||
provider = _make_provider()
|
||||
assert provider.image_config_scanners == []
|
||||
|
||||
def test_valid_image_config_scanners(self):
|
||||
"""Test that valid image config scanners are accepted."""
|
||||
provider = _make_provider(image_config_scanners=["misconfig", "secret"])
|
||||
assert provider.image_config_scanners == ["misconfig", "secret"]
|
||||
|
||||
def test_invalid_image_config_scanner_raises_error(self):
|
||||
"""Test that an invalid image config scanner raises ImageInvalidConfigScannerError."""
|
||||
with pytest.raises(ImageInvalidConfigScannerError):
|
||||
_make_provider(image_config_scanners=["misconfig", "vuln"])
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_trivy_command_includes_image_config_scanners(self, mock_subprocess):
|
||||
"""Test that Trivy command includes --image-config-scanners when set."""
|
||||
provider = _make_provider(image_config_scanners=["misconfig", "secret"])
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
returncode=0, stdout=get_empty_trivy_output(), stderr=""
|
||||
)
|
||||
|
||||
for _ in provider._scan_single_image("alpine:3.18"):
|
||||
pass
|
||||
|
||||
call_args = mock_subprocess.call_args[0][0]
|
||||
assert "--image-config-scanners" in call_args
|
||||
idx = call_args.index("--image-config-scanners")
|
||||
assert call_args[idx + 1] == "misconfig,secret"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_trivy_command_omits_image_config_scanners_when_empty(
|
||||
self, mock_subprocess
|
||||
):
|
||||
"""Test that Trivy command omits --image-config-scanners when empty."""
|
||||
provider = _make_provider(image_config_scanners=[])
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
returncode=0, stdout=get_empty_trivy_output(), stderr=""
|
||||
)
|
||||
|
||||
for _ in provider._scan_single_image("alpine:3.18"):
|
||||
pass
|
||||
|
||||
call_args = mock_subprocess.call_args[0][0]
|
||||
assert "--image-config-scanners" not in call_args
|
||||
|
||||
|
||||
class TestImageProviderErrorCategorization:
|
||||
def test_categorize_auth_failure(self):
|
||||
"""Test that auth-related errors are categorized correctly."""
|
||||
result = ImageProvider._categorize_trivy_error(
|
||||
"401 unauthorized: access denied"
|
||||
)
|
||||
assert "Auth failure" in result
|
||||
|
||||
def test_categorize_not_found(self):
|
||||
"""Test that not-found errors are categorized correctly."""
|
||||
result = ImageProvider._categorize_trivy_error(
|
||||
"manifest unknown: image not found"
|
||||
)
|
||||
assert "Image not found" in result
|
||||
|
||||
def test_categorize_rate_limit(self):
|
||||
"""Test that rate-limit errors are categorized correctly."""
|
||||
result = ImageProvider._categorize_trivy_error("429 too many requests")
|
||||
assert "Rate limited" in result
|
||||
|
||||
def test_categorize_network_issue(self):
|
||||
"""Test that network errors are categorized correctly."""
|
||||
result = ImageProvider._categorize_trivy_error("connection refused to registry")
|
||||
assert "Network issue" in result
|
||||
|
||||
def test_categorize_unknown_error(self):
|
||||
"""Test that unrecognized errors are returned as-is."""
|
||||
msg = "some unknown trivy error"
|
||||
result = ImageProvider._categorize_trivy_error(msg)
|
||||
assert result == msg
|
||||
|
||||
|
||||
class TestImageProviderNameValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"bad_name",
|
||||
[
|
||||
"alpine;rm -rf /",
|
||||
"image|cat /etc/passwd",
|
||||
"image&background",
|
||||
"image$VAR",
|
||||
"image`whoami`",
|
||||
"image\ninjected",
|
||||
"image\rinjected",
|
||||
],
|
||||
)
|
||||
def test_image_provider_invalid_image_name_shell_chars(self, bad_name):
|
||||
"""Test that image names with shell metacharacters raise ImageInvalidNameError."""
|
||||
with pytest.raises(ImageInvalidNameError):
|
||||
_make_provider(images=[bad_name])
|
||||
|
||||
def test_image_provider_invalid_image_name_empty(self):
|
||||
"""Test that an empty string image name raises ImageInvalidNameError."""
|
||||
with pytest.raises(ImageInvalidNameError):
|
||||
_make_provider(images=[""])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valid_name",
|
||||
[
|
||||
"alpine:3.18",
|
||||
"nginx:latest",
|
||||
"registry.example.com/repo/image:tag",
|
||||
"ghcr.io/owner/image:v1.2.3",
|
||||
"myimage@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
"localhost:5000/myimage:latest",
|
||||
],
|
||||
)
|
||||
def test_image_provider_valid_image_names(self, valid_name):
|
||||
"""Test that various valid image name formats pass validation."""
|
||||
provider = _make_provider(images=[valid_name])
|
||||
assert valid_name in provider.images
|
||||
|
||||
def test_image_provider_image_name_too_long(self):
|
||||
"""Test that a name exceeding 500 chars raises ImageInvalidNameError."""
|
||||
long_name = "a" * 501
|
||||
with pytest.raises(ImageInvalidNameError):
|
||||
_make_provider(images=[long_name])
|
||||
|
||||
def test_image_provider_file_too_many_lines(self):
|
||||
"""Test that a file with more than MAX_IMAGE_LIST_LINES raises ImageListFileReadError."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
for i in range(10_001):
|
||||
f.write(f"image{i}:latest\n")
|
||||
f.flush()
|
||||
file_path = f.name
|
||||
|
||||
with pytest.raises(ImageListFileReadError):
|
||||
_make_provider(images=None, image_list_file=file_path)
|
||||
@@ -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 (
|
||||
<ContentLayout title="Scans" icon="lucide:timer">
|
||||
<AutoRefresh hasExecutingScan={hasExecutingScan} />
|
||||
<>
|
||||
<>
|
||||
{!hasManageScansPermission ? (
|
||||
@@ -177,11 +165,10 @@ const SSRDataTableScans = async ({
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
key={`scans-${Date.now()}`}
|
||||
columns={ColumnGetScans}
|
||||
data={expandedScansData || []}
|
||||
metadata={meta}
|
||||
<ScansTableWithPolling
|
||||
initialData={expandedScansData}
|
||||
initialMeta={meta}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -21,7 +21,9 @@ export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => {
|
||||
|
||||
return (
|
||||
<Button asChild variant="link" size="sm" className="text-xs">
|
||||
<Link href={href}>{label}</Link>
|
||||
<Link href={href} prefetch={false}>
|
||||
{label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user