Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79efa1dbb2 | ||
|
|
383a9bf903 | ||
|
|
4605d9a770 | ||
|
|
f0da33f451 | ||
|
|
a44a725507 | ||
|
|
b8ca30400b | ||
|
|
a006525e78 | ||
|
|
ed510e217d | ||
|
|
04511f339e | ||
|
|
f418b32c81 | ||
|
|
65fb146e76 | ||
|
|
5ea363d582 | ||
|
|
3ec379a75a | ||
|
|
ea020ed46e | ||
|
|
60b936005c | ||
|
|
03502c2426 | ||
|
|
e6320b178a | ||
|
|
4195a4f818 | ||
|
|
8d003c60d0 | ||
|
|
d5136f364c | ||
|
|
453c953f37 | ||
|
|
c114aa304b | ||
|
|
c2b8092461 | ||
|
|
26d9d24e5d | ||
|
|
ee59e35bc2 | ||
|
|
e0fa23b9ee | ||
|
|
4dbc3c7e74 | ||
|
|
576433d85d | ||
|
|
bf179212a5 | ||
|
|
60f936a10b | ||
|
|
15630f54d2 | ||
|
|
706603fe4d | ||
|
|
dc67fe4f37 | ||
|
|
2c233c2f6c | ||
|
|
69e1d19abe | ||
|
|
859421b0ec | ||
|
|
ea36f12a01 | ||
|
|
50a9138bea | ||
|
|
09821e6328 | ||
|
|
94899e20fd | ||
|
|
79da676c86 | ||
|
|
8b35b69731 | ||
|
|
81d90fc31e | ||
|
|
8b5f1250a9 | ||
|
|
98d2db4e13 | ||
|
|
c9068515b2 | ||
|
|
3823186914 | ||
|
|
03cb59c20d | ||
|
|
d819639f0e | ||
|
|
6c8d6994bb | ||
|
|
07d48ab15d | ||
|
|
5fe1a6713b | ||
|
|
61d13f078c | ||
|
|
bbb297aee7 | ||
|
|
d0d29108e0 | ||
|
|
f382400037 | ||
|
|
396ccf56bb | ||
|
|
3069486549 | ||
|
|
9f616a5d43 | ||
|
|
2198ba2d84 | ||
|
|
974f4251dd | ||
|
|
75c22df63b | ||
|
|
757cd44ecb | ||
|
|
c0fdd5bdf3 | ||
|
|
61ef44a03b | ||
|
|
682353e054 | ||
|
|
3860cd3dce | ||
|
|
1b228d590b | ||
|
|
8b265a8314 | ||
|
|
ba258a5346 | ||
|
|
282fe5b46b | ||
|
|
c08cb65d84 | ||
|
|
4727da7ca7 | ||
|
|
b378f15798 | ||
|
|
f9c02da90a | ||
|
|
865eebe7fb | ||
|
|
8270979ec8 | ||
|
|
369f852837 | ||
|
|
1e8454a3cb | ||
|
|
6f6ae88a66 | ||
|
|
c71f226e5c | ||
|
|
bbf5e1fa9f | ||
|
|
9cab9b8653 | ||
|
|
806be2d061 | ||
|
|
2769cb9876 | ||
|
|
623dc3125a | ||
|
|
1edcf6e5de | ||
|
|
8bdb597921 | ||
|
|
1746e1052b | ||
|
|
6827eef347 | ||
|
|
90fc815d3c | ||
|
|
8f35fff255 | ||
|
|
ab51d09543 | ||
|
|
12faeb9aa2 | ||
|
|
9ed07de610 | ||
|
|
8007501574 | ||
|
|
36514534cb | ||
|
|
9621bdfb9c | ||
|
|
f013a5e1ad | ||
|
|
18453e592e | ||
|
|
86e4408f29 | ||
|
|
ae43d21efb | ||
|
|
7c84822fa3 | ||
|
|
51c5fa7168 | ||
|
|
e9121f5f1a | ||
|
|
821fe43efd | ||
|
|
9ffbb4b758 | ||
|
|
9c5285adc3 | ||
|
|
f295d290dd | ||
|
|
e5df95c259 | ||
|
|
b6a8af3c54 | ||
|
|
fb7064401b | ||
|
|
8d60f9703a | ||
|
|
ceb601028e | ||
|
|
6422178b76 | ||
|
|
587c47bfe2 | ||
|
|
13a31d9225 | ||
|
|
0715619435 |
@@ -110,11 +110,27 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY=""
|
||||
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN=""
|
||||
|
||||
# The AWS region where your S3 bucket is located (e.g., "us-east-1")
|
||||
# Required if the bucket uses SSE-KMS: download URLs are then signed with SigV4, which
|
||||
# is scoped to this region, so it must match the bucket's
|
||||
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION=""
|
||||
|
||||
# The name of the S3 bucket where scan output should be stored
|
||||
DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET=""
|
||||
|
||||
# The storage endpoint the API and Celery workers use to upload and list scan output
|
||||
# (e.g. "http://minio:9000"). Leave empty on AWS S3. Set it when scan output is stored on
|
||||
# S3-compatible object storage such as MinIO instead of real S3.
|
||||
# If set without DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL below, report download URLs are
|
||||
# signed against this internal host, and a browser outside the container network cannot open them.
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL=""
|
||||
|
||||
# The storage address the browser can reach, used only to sign report download URLs
|
||||
# (e.g. "https://storage.example.com"). Leave empty on AWS S3. Set it when storage is
|
||||
# only reachable inside the container network, such as MinIO on "http://minio:9000".
|
||||
# The reverse proxy in front of it must forward the Host header unchanged: SigV4 signs
|
||||
# Host, so rewriting it to the internal name invalidates the signature.
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL=""
|
||||
|
||||
# Django settings
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api
|
||||
DJANGO_BIND_ADDRESS=0.0.0.0
|
||||
@@ -158,7 +174,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.41.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.45.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# SDK
|
||||
/* @prowler-cloud/detection-remediation
|
||||
/prowler/ @prowler-cloud/detection-remediation
|
||||
/tests/ @prowler-cloud/detection-remediation
|
||||
/dashboard/ @prowler-cloud/detection-remediation
|
||||
/docs/ @prowler-cloud/detection-remediation
|
||||
/examples/ @prowler-cloud/detection-remediation
|
||||
/util/ @prowler-cloud/detection-remediation
|
||||
/contrib/ @prowler-cloud/detection-remediation
|
||||
/permissions/ @prowler-cloud/detection-remediation
|
||||
/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api
|
||||
/* @prowler-cloud/engineering
|
||||
/prowler/ @prowler-cloud/engineering
|
||||
/tests/ @prowler-cloud/engineering
|
||||
/dashboard/ @prowler-cloud/engineering
|
||||
/docs/ @prowler-cloud/engineering
|
||||
/examples/ @prowler-cloud/engineering
|
||||
/util/ @prowler-cloud/engineering
|
||||
/contrib/ @prowler-cloud/engineering
|
||||
/permissions/ @prowler-cloud/engineering
|
||||
/codecov.yml @prowler-cloud/engineering
|
||||
|
||||
# API
|
||||
/api/ @prowler-cloud/api
|
||||
/api/ @prowler-cloud/engineering
|
||||
|
||||
# UI
|
||||
/ui/ @prowler-cloud/ui
|
||||
/ui/ @prowler-cloud/engineering
|
||||
|
||||
# AI
|
||||
/mcp_server/ @prowler-cloud/detection-remediation
|
||||
/mcp_server/ @prowler-cloud/engineering
|
||||
|
||||
# Platform
|
||||
/.github/ @prowler-cloud/platform
|
||||
|
||||
@@ -46,6 +46,17 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if grep -q "prowler-cloud/prowler" uv.lock; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -ne 1 ]; then
|
||||
echo "::error::grep failed reading uv.lock (exit code $status)."
|
||||
exit "$status"
|
||||
fi
|
||||
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
@@ -66,6 +77,17 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if grep -q "prowler-cloud/prowler" uv.lock; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -ne 1 ]; then
|
||||
echo "::error::grep failed reading uv.lock (exit code $status)."
|
||||
exit "$status"
|
||||
fi
|
||||
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
|
||||
@@ -451,6 +451,17 @@ modules:
|
||||
e2e:
|
||||
- ui/tests/home/**
|
||||
|
||||
- name: ui-registry
|
||||
match:
|
||||
- ui/actions/registry/**
|
||||
- ui/app/**/registry/**
|
||||
- ui/components/registry/**
|
||||
- ui/lib/registry/**
|
||||
- ui/tests/registry/**
|
||||
tests: []
|
||||
e2e:
|
||||
- ui/tests/registry/**
|
||||
|
||||
- name: ui-shadcn
|
||||
match:
|
||||
- ui/components/shadcn/**
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Check labels
|
||||
id: label_check
|
||||
uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
|
||||
uses: agilepathway/label-checker@c324842522fbd012e4f590afe3b4e591301322ed # v1.6.66
|
||||
with:
|
||||
allow_failure: true
|
||||
prefix_mode: true
|
||||
|
||||
@@ -44,7 +44,10 @@ jobs:
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install boto3
|
||||
# Pinned to the versions in pyproject.toml: the ISO partitions region
|
||||
# data comes from the endpoints.json bundled with botocore, so the
|
||||
# botocore version is itself a data source and must be deterministic
|
||||
run: pip install boto3==1.40.61 botocore==1.40.61
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
|
||||
### Changes
|
||||
|
||||
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
|
||||
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`).
|
||||
|
||||
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
|
||||
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
|
||||
|
||||
@@ -10,12 +10,13 @@ on:
|
||||
- master
|
||||
- "v5.*"
|
||||
paths:
|
||||
- '.github/workflows/ui-e2e-tests-v2.yml'
|
||||
- '.github/test-impact.yml'
|
||||
- 'ui/**'
|
||||
- 'api/**' # API changes can affect UI E2E
|
||||
- '!ui/CHANGELOG.md'
|
||||
- '!api/CHANGELOG.md'
|
||||
- ".github/workflows/ui-e2e-tests-v2.yml"
|
||||
- ".github/workflows/test-impact-analysis.yml"
|
||||
- ".github/test-impact.yml"
|
||||
- "ui/**"
|
||||
- "api/**" # API changes can affect UI E2E
|
||||
- "!ui/CHANGELOG.md"
|
||||
- "!api/CHANGELOG.md"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -40,11 +41,11 @@ jobs:
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AUTH_SECRET: 'fallback-ci-secret-for-testing'
|
||||
AUTH_SECRET: "fallback-ci-secret-for-testing"
|
||||
AUTH_TRUST_HOST: true
|
||||
NEXTAUTH_URL: 'http://localhost:3000'
|
||||
AUTH_URL: 'http://localhost:3000'
|
||||
UI_API_BASE_URL: 'http://localhost:8080/api/v1'
|
||||
NEXTAUTH_URL: "http://localhost:3000"
|
||||
AUTH_URL: "http://localhost:3000"
|
||||
UI_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 }}
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
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_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 }}
|
||||
@@ -237,8 +238,8 @@ jobs:
|
||||
|
||||
- name: Add AWS credentials for testing
|
||||
run: |
|
||||
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
|
||||
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
|
||||
echo "AWS_ACCESS_KEY_ID=${E2E_AWS_PROVIDER_ACCESS_KEY}" >> .env
|
||||
echo "AWS_SECRET_ACCESS_KEY=${E2E_AWS_PROVIDER_SECRET_KEY}" >> .env
|
||||
|
||||
- name: Build API image from current code
|
||||
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
|
||||
@@ -292,7 +293,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: 'ui/.nvmrc'
|
||||
node-version-file: "ui/.nvmrc"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
@@ -337,60 +338,59 @@ jobs:
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm run test:e2e:install
|
||||
|
||||
- name: Run E2E tests
|
||||
- name: Run standard E2E tests
|
||||
id: standard-e2e
|
||||
working-directory: ./ui
|
||||
run: |
|
||||
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
|
||||
echo "Running ALL E2E tests..."
|
||||
echo "Running all standard E2E tests..."
|
||||
pnpm run test:e2e
|
||||
else
|
||||
echo "Running targeted E2E tests: ${E2E_TEST_PATHS}"
|
||||
# Convert glob patterns to playwright test paths
|
||||
# e.g., "ui/tests/providers/**" -> "tests/providers"
|
||||
echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}"
|
||||
TEST_PATHS="${E2E_TEST_PATHS}"
|
||||
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
|
||||
# Drop auth setup helpers (not runnable test suites)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
|
||||
# Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**),
|
||||
# expand to specific subdirs to avoid Playwright discovering setup files
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true)
|
||||
|
||||
if echo "$TEST_PATHS" | grep -qx 'tests/'; then
|
||||
echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..."
|
||||
SPECIFIC_DIRS=""
|
||||
for dir in tests/*/; do
|
||||
[[ "$dir" == "tests/setups/" ]] && continue
|
||||
[[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue
|
||||
SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n'
|
||||
done
|
||||
# Replace "tests/" with specific dirs, keep other paths
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/')
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true)
|
||||
TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}"
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u)
|
||||
fi
|
||||
if [[ -z "$TEST_PATHS" ]]; then
|
||||
echo "No runnable E2E test paths after filtering setups"
|
||||
exit 0
|
||||
fi
|
||||
# Filter out directories that don't contain any test files
|
||||
|
||||
VALID_PATHS=""
|
||||
while IFS= read -r p; do
|
||||
[[ -z "$p" ]] && continue
|
||||
if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
|
||||
VALID_PATHS="${VALID_PATHS}${p}"$'\n'
|
||||
while IFS= read -r path; do
|
||||
[[ -z "$path" ]] && continue
|
||||
if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
|
||||
VALID_PATHS="${VALID_PATHS}${path}"$'\n'
|
||||
else
|
||||
echo "Skipping empty test directory: $p"
|
||||
echo "Skipping empty test directory: $path"
|
||||
fi
|
||||
done <<< "$TEST_PATHS"
|
||||
VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true)
|
||||
if [[ -z "$VALID_PATHS" ]]; then
|
||||
echo "No test files found in any resolved paths — skipping E2E"
|
||||
exit 0
|
||||
|
||||
if [[ -n "$VALID_PATHS" ]]; then
|
||||
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
|
||||
echo "Resolved standard test paths: $TEST_PATHS"
|
||||
read -ra test_paths <<< "$TEST_PATHS"
|
||||
pnpm exec playwright test "${test_paths[@]}"
|
||||
else
|
||||
echo "No standard E2E test paths selected."
|
||||
fi
|
||||
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
|
||||
echo "Resolved test paths: $TEST_PATHS"
|
||||
read -ra test_paths <<< "$TEST_PATHS"
|
||||
pnpm exec playwright test "${test_paths[@]}"
|
||||
fi
|
||||
|
||||
- name: Run Registry fixture E2E tests
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(steps.standard-e2e.outcome == 'success' || steps.standard-e2e.outcome == 'failure') &&
|
||||
(env.RUN_ALL_TESTS == 'true' || contains(format(' {0} ', env.E2E_TEST_PATHS), ' ui/tests/registry/'))
|
||||
working-directory: ./ui
|
||||
run: pnpm run test:e2e:registry
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: failure()
|
||||
|
||||
@@ -17,6 +17,40 @@ ignore:
|
||||
- vulnerability: CVE-2026-71556
|
||||
package:
|
||||
name: github.com/go-git/go-git/v5
|
||||
# CVE-2026-84304 is the same temporary exception documented in .trivyignore.yaml:
|
||||
# Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.83.1 fix is not in any release.
|
||||
# Prowler only runs `trivy image` / `trivy fs`, never client/server mode, so no gRPC
|
||||
# endpoint exists in the image. Pinned to the embedded version so the rule stops
|
||||
# matching on its own once Trivy bumps grpc. Remove with the Trivy exception by 2026-10-15.
|
||||
# https://github.com/aquasecurity/trivy/pull/11176
|
||||
- vulnerability: CVE-2026-84304
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
version: v1.82.1
|
||||
# CVE-2026-84445 is the same temporary exception documented in .trivyignore.yaml:
|
||||
# Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.82.2 / 1.83.2 fix is not in any
|
||||
# release. The panic needs a gRPC server built with `xds.NewGRPCServer()`; Prowler only
|
||||
# runs `trivy image` / `trivy fs`, so the image serves no gRPC at all. Pinned to the
|
||||
# embedded version so the rule stops matching on its own once Trivy bumps grpc. Remove
|
||||
# with the Trivy exception by 2026-10-15.
|
||||
# https://github.com/advisories/GHSA-2v4p-qf9q-27wj
|
||||
- vulnerability: CVE-2026-84445
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
version: v1.82.1
|
||||
# CVE-2026-56855 / CVE-2026-78662 are the same temporary exception documented in
|
||||
# .trivyignore.yaml: Trivy 0.74.0 still embeds golang.org/x/crypto v0.55.0, while the
|
||||
# 0.56.0 fix (published 2026-09-02) hasn't reached any Trivy release, or even Trivy
|
||||
# main, yet. Pinned to the embedded version so the rule stops matching on its own once
|
||||
# Trivy bumps it. Remove with the Trivy exception by 2026-10-15.
|
||||
- vulnerability: CVE-2026-56855
|
||||
package:
|
||||
name: golang.org/x/crypto
|
||||
version: v0.55.0
|
||||
- vulnerability: CVE-2026-78662
|
||||
package:
|
||||
name: golang.org/x/crypto
|
||||
version: v0.55.0
|
||||
- vulnerability: CVE-2026-56852
|
||||
package:
|
||||
name: golang.org/x/text
|
||||
@@ -81,3 +115,14 @@ ignore:
|
||||
- vulnerability: CVE-2026-9669
|
||||
package:
|
||||
name: python
|
||||
# CVE-2026-82049 (tarfile data/tar filter bypass via a hard link to a symlink) has no
|
||||
# fixed CPython release on any branch: the fix is merged on main and 3.13 only, and the
|
||||
# 3.12 backport is still open. Grype records 3.14.0b1 as the fix, so only-fixed does not
|
||||
# drop it, yet python:3.12.14-slim-trixie reports it too. Prowler never extracts tar
|
||||
# archives to disk: the ECR image inspection reads members in memory with extractfile().
|
||||
# Remove once the base image ships a 3.12 release that includes the backport.
|
||||
# https://github.com/python/cpython/issues/157190
|
||||
# https://github.com/python/cpython/pull/157454
|
||||
- vulnerability: CVE-2026-82049
|
||||
package:
|
||||
name: python
|
||||
|
||||
@@ -68,7 +68,7 @@ vulnerabilities:
|
||||
expired_at: 2026-11-30
|
||||
|
||||
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
|
||||
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
|
||||
# (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
|
||||
# SBOM and reports what it declares, which is not the same as what the image contains:
|
||||
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
|
||||
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
|
||||
@@ -113,40 +113,86 @@ vulnerabilities:
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75899
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75975
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-76172
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-75931
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-84292
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-69192
|
||||
purls:
|
||||
- "pkg:npm/ip-address"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# CVE-2026-62901 is a DoS in System.Net.WebSockets (unchecked input for loop condition,
|
||||
# CWE-606), fixed in .NET 9.0.19 / 10.0.11 (published 2026-08-11). The vulnerable runtime
|
||||
# ships inside the PowerShell tarball the Dockerfile pins: 7.5.9 is the latest 7.5.x and
|
||||
# bundles .NET 9.0.18; 7.6.4 bundles .NET 10.0.x < 10.0.11, so no published PowerShell
|
||||
# release contains the fix yet. Prowler only invokes pwsh locally to run M365 module
|
||||
# cmdlets; the image does not accept inbound WebSocket connections, so the DoS path is
|
||||
# not reachable from the network. Remove this temporary suppression as soon as a
|
||||
# PowerShell release shipping .NET 9.0.19+ is available.
|
||||
- id: CVE-2026-62901
|
||||
# CVE-2026-84304 is a DoS in grpc-go <= 1.83.0: a peer fragments a gRPC stream into
|
||||
# millions of tiny HTTP/2 DATA frames until the receiver runs out of heap. Fixed in
|
||||
# 1.83.1 (published 2026-09-01). Trivy 0.74.0, the latest published release and the
|
||||
# version the images ship, pins 1.82.1 as an indirect dependency:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
|
||||
# Upstream bump still open: https://github.com/aquasecurity/trivy/pull/11176
|
||||
# Trivy only speaks gRPC in client/server mode (`trivy server`, `--server`). Prowler
|
||||
# invokes it exclusively as `trivy image` and `trivy fs` on a local path, so no gRPC
|
||||
# listener or connection ever exists in the image and the affected path is not
|
||||
# reachable. Remove this temporary suppression as soon as a Trivy release pins
|
||||
# grpc >= 1.83.1.
|
||||
- id: CVE-2026-84304
|
||||
purls:
|
||||
- "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-x64"
|
||||
- "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-arm64"
|
||||
expired_at: 2026-09-15
|
||||
- "pkg:golang/google.golang.org/grpc"
|
||||
expired_at: 2026-10-15
|
||||
|
||||
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
|
||||
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
|
||||
# CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a
|
||||
# cloned repository. Trivy 0.73.0, the latest published release and the version the
|
||||
# images ship, still pins that vulnerable version:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
|
||||
# Trivy main already contains the 5.19.2 fix, but no published release includes it yet:
|
||||
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
|
||||
# Prowler invokes Trivy only with `fs` on an existing local path or with `image`; it does
|
||||
# not ask Trivy to clone or mutate a Git worktree, so the affected path is not reachable.
|
||||
# Remove this temporary suppression as soon as a fixed Trivy release is available.
|
||||
- id: CVE-2026-71556
|
||||
# CVE-2026-84445 is a DoS in grpc-go servers built with `xds.NewGRPCServer()`: a request
|
||||
# carrying neither `:authority` nor `Host` reaches the xDS routing interceptor, which
|
||||
# indexes an empty slice of authorities and panics. The per-RPC goroutine does not
|
||||
# recover, so the whole server process dies. Fixed in 1.82.2 and 1.83.2 (published
|
||||
# 2026-09-08). Trivy 0.74.0, the latest published release and the version the images
|
||||
# ship, pins 1.82.1 as an indirect dependency:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
|
||||
# Trivy main already carries 1.83.2, but no published release includes it yet.
|
||||
# The reachability argument is the one made for CVE-2026-84304 above, only narrower:
|
||||
# this panic needs an xDS-managed gRPC server. Prowler invokes Trivy exclusively as
|
||||
# `trivy image` and `trivy fs` on a local path, never `trivy server`, so the image runs
|
||||
# no gRPC server at all, xDS or otherwise. Remove this temporary suppression as soon as
|
||||
# a Trivy release pins grpc >= 1.83.2.
|
||||
# https://github.com/advisories/GHSA-2v4p-qf9q-27wj
|
||||
- id: CVE-2026-84445
|
||||
purls:
|
||||
- "pkg:golang/github.com/go-git/go-git/v5"
|
||||
expired_at: 2026-09-15
|
||||
- "pkg:golang/google.golang.org/grpc@v1.82.1"
|
||||
expired_at: 2026-10-15
|
||||
|
||||
# CVE-2026-56855 and CVE-2026-78662 are DoS deadlocks in x/crypto/ssh: a malicious peer
|
||||
# can flood or misuse channel messages (RFC 4254) to block the whole connection.
|
||||
# Fixed in golang.org/x/crypto v0.56.0 (published 2026-09-02). Trivy 0.74.0, the latest
|
||||
# published release and the version the images ship, still pins v0.55.0, and Trivy main
|
||||
# has not bumped it either:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
|
||||
# x/crypto/ssh is pulled in transitively through go-git's ssh transport, the same
|
||||
# dependency chain as the CVE-2026-71556 entry above. Prowler invokes Trivy only with
|
||||
# `fs` on an existing local path or with `image`; it never asks Trivy to clone over SSH
|
||||
# or to run `trivy server`, so no SSH connection -- as client or server -- ever exists in
|
||||
# the image and the affected code path is not reachable. Remove this temporary
|
||||
# suppression as soon as a fixed Trivy release is available.
|
||||
- id: CVE-2026-56855
|
||||
purls:
|
||||
- "pkg:golang/golang.org/x/crypto@v0.55.0"
|
||||
expired_at: 2026-10-15
|
||||
- id: CVE-2026-78662
|
||||
purls:
|
||||
- "pkg:golang/golang.org/x/crypto@v0.55.0"
|
||||
expired_at: 2026-10-15
|
||||
|
||||
- id: CVE-2026-56852
|
||||
purls:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
FROM python:3.14.7-slim-trixie@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
ARG POWERSHELL_VERSION=7.5.11
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
@@ -17,25 +17,30 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
|
||||
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
|
||||
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
|
||||
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
|
||||
ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8
|
||||
ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
|
||||
|
||||
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
|
||||
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
|
||||
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
|
||||
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
|
||||
# (image ships 3.5.6-1~deb13u2)
|
||||
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
|
||||
# gzip 1.13-1+deb13u1 CVE-2026-41992
|
||||
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
|
||||
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
|
||||
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
|
||||
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
|
||||
# published python:3.12-slim-trixie carries the same vulnerable version. The three
|
||||
# packages are all built from openssl and are flagged separately, so all are named.
|
||||
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
|
||||
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
|
||||
# openssl packages are flagged separately, so all are named.
|
||||
# Drop each one once the base image ships its fixed version.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
build-essential pkg-config libzstd-dev zlib1g-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade \
|
||||
util-linux libssl3t64 openssl openssl-provider-legacy \
|
||||
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -126,12 +126,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 639 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| AWS | 662 | 86 | 50 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 191 | 22 | 25 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 110 | 20 | 22 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 92 | 7 | 11 | 11 | Official | UI, API, CLI |
|
||||
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| M365 | 143 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| M365 | 144 | 10 | 9 | 10 | Official | UI, API, CLI |
|
||||
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
@@ -139,7 +139,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
|
||||
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI |
|
||||
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
|
||||
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
|
||||
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
|
||||
|
||||
@@ -59,5 +59,9 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID=""
|
||||
DJANGO_GITHUB_OAUTH_CLIENT_SECRET=""
|
||||
DJANGO_GITHUB_OAUTH_CALLBACK_URL=""
|
||||
|
||||
# Public base URL of the Prowler UI, used to link Jira issues back to findings.
|
||||
# Leave empty to omit the link.
|
||||
DJANGO_UI_BASE_URL=""
|
||||
|
||||
# Deletion Task Batch Size
|
||||
DJANGO_DELETION_BATCH_SIZE=5000
|
||||
|
||||
@@ -4,6 +4,69 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.45.0] (Prowler v5.44.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465)
|
||||
- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746)
|
||||
- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832)
|
||||
- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
|
||||
- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
|
||||
- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878)
|
||||
- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882)
|
||||
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893)
|
||||
|
||||
---
|
||||
|
||||
## [1.44.0] (Prowler v5.43.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Report download URLs can be signed against a browser-reachable storage host via `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL`, so downloads complete on deployments where storage is only reachable inside the container network [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
|
||||
- A scan report download no longer fails with a server error when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is unset, which is common on storage with no meaningful region [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
|
||||
- Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email [(#12831)](https://github.com/prowler-cloud/prowler/pull/12831)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs [(#12804)](https://github.com/prowler-cloud/prowler/pull/12804)
|
||||
- PowerShell from 7.5.9 to 7.5.11 in the API container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 [(#12811)](https://github.com/prowler-cloud/prowler/pull/12811)
|
||||
- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848)
|
||||
|
||||
---
|
||||
|
||||
## [1.43.0] (Prowler v5.42.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Speed up compliance overview ingestion by reading ThreatScore mappings from the compliance template instead of each finding, generating time-ordered `uuid7` row ids and grouping inserted rows by framework and requirement [(#12738)](https://github.com/prowler-cloud/prowler/pull/12738)
|
||||
|
||||
---
|
||||
|
||||
## [1.42.0] (Prowler v5.41.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681)
|
||||
|
||||
---
|
||||
|
||||
## [1.41.0] (Prowler v5.40.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
FROM python:3.14.7-slim-trixie@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
ARG POWERSHELL_VERSION=7.5.11
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
@@ -16,19 +16,23 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
|
||||
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
|
||||
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
|
||||
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
|
||||
ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8
|
||||
ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
|
||||
|
||||
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
|
||||
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
|
||||
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
|
||||
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
|
||||
# (image ships 3.5.6-1~deb13u2)
|
||||
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
|
||||
# gzip 1.13-1+deb13u1 CVE-2026-41992
|
||||
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
|
||||
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
|
||||
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
|
||||
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
|
||||
# published python:3.12-slim-trixie carries the same vulnerable version. The three
|
||||
# packages are all built from openssl and are flagged separately, so all are named.
|
||||
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
|
||||
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
|
||||
# openssl packages are flagged separately, so all are named.
|
||||
# Drop each one once the base image ships its fixed version.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
@@ -46,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade \
|
||||
util-linux libssl3t64 openssl openssl-provider-legacy \
|
||||
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1
|
||||
@@ -0,0 +1 @@
|
||||
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
|
||||
@@ -0,0 +1 @@
|
||||
Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.42.0"
|
||||
version = "1.46.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -137,7 +137,7 @@ constraint-dependencies = [
|
||||
"aliyun-log-fastpb==0.2.0",
|
||||
"amqp==5.3.1",
|
||||
"annotated-types==0.7.0",
|
||||
"anyio==4.12.1",
|
||||
"anyio==4.14.2",
|
||||
"applicationinsights==0.11.10",
|
||||
"apscheduler==3.11.2",
|
||||
"argcomplete==3.5.3",
|
||||
@@ -375,7 +375,7 @@ constraint-dependencies = [
|
||||
"pydantic-core==2.41.5",
|
||||
"pygithub==2.8.0",
|
||||
"pygments==2.20.0",
|
||||
"pyjwt==2.13.0",
|
||||
"pyjwt==2.14.0",
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
@@ -476,8 +476,8 @@ constraint-dependencies = [
|
||||
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
|
||||
#
|
||||
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
|
||||
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.14.0
|
||||
# patches GHSA-ffc3-869f-jxw9 (HMAC/PEM key-confusion); a constraint cannot satisfy these
|
||||
# against the SDK's hard pins, so override them to the patched versions until the SDK
|
||||
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
|
||||
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
|
||||
@@ -500,5 +500,5 @@ override-dependencies = [
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"dulwich==1.2.5",
|
||||
"pyjwt[crypto]==2.13.0"
|
||||
"pyjwt[crypto]==2.14.0"
|
||||
]
|
||||
|
||||
@@ -207,6 +207,11 @@ def drop_database(database: str) -> None:
|
||||
sink_module.get_backend().drop_database(database)
|
||||
|
||||
|
||||
def list_databases() -> list[str]:
|
||||
"""List database names on the ingest cluster. Temp scan DBs always live here."""
|
||||
return ingest.list_databases()
|
||||
|
||||
|
||||
def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
return sink_module.get_backend().drop_subgraph(database, provider_id)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from api.attack_paths.ingest.driver import (
|
||||
get_session,
|
||||
get_uri,
|
||||
init_driver,
|
||||
list_databases,
|
||||
run_cypher,
|
||||
)
|
||||
|
||||
@@ -25,5 +26,6 @@ __all__ = [
|
||||
"get_session",
|
||||
"get_uri",
|
||||
"init_driver",
|
||||
"list_databases",
|
||||
"run_cypher",
|
||||
]
|
||||
|
||||
@@ -165,6 +165,14 @@ def drop_database(database: str) -> None:
|
||||
session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA")
|
||||
|
||||
|
||||
def list_databases() -> list[str]:
|
||||
"""List every database name on the Neo4j temp-database cluster."""
|
||||
# A cluster returns one row per hosting server, so dedupe on name
|
||||
with get_session() as session:
|
||||
result = session.run("SHOW DATABASES YIELD name RETURN DISTINCT name")
|
||||
return [record["name"] for record in result]
|
||||
|
||||
|
||||
def clear_cache(database: str) -> None:
|
||||
"""Best-effort cache clear for a Neo4j database."""
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from math import isfinite
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,7 +7,7 @@ from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey, TenantAPIKeyManager
|
||||
from cryptography.fernet import InvalidToken
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
@@ -18,12 +19,15 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Writing on every request makes all requests of a busy key contend on one row
|
||||
API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60
|
||||
|
||||
|
||||
class OrphanedAPIKeyError(Exception):
|
||||
"""Raised when an API key outlived the user that owns it.
|
||||
|
||||
Handled by `authenticate`, which commits the revocation written while detecting it
|
||||
and then rejects the request with `AuthenticationFailed`.
|
||||
The revocation is written by a plain `update()` before this is raised, so it is
|
||||
already persisted by the time `authenticate` catches it and rejects the request.
|
||||
"""
|
||||
|
||||
|
||||
@@ -37,8 +41,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
"""
|
||||
Override to use admin connection, bypassing RLS during authentication.
|
||||
|
||||
Returns the validated API key row, locked with `select_for_update`, so callers
|
||||
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
|
||||
Returns the validated API key row from a single read. `authenticate` builds
|
||||
the auth claims from that same row instead of looking it up again, so a key
|
||||
revoked or orphaned right after validation can't still authenticate.
|
||||
"""
|
||||
try:
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
@@ -67,9 +72,11 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
raise AuthenticationFailed("API Key has already expired.")
|
||||
|
||||
try:
|
||||
# Loading `entity` in the same query keeps a user deleted after this read
|
||||
# from turning the later `api_key.entity` access into a 500
|
||||
api_key = (
|
||||
self.model.objects.using(MainRouter.admin_db)
|
||||
.select_for_update()
|
||||
.select_related("entity")
|
||||
.get(id=api_key_pk)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
@@ -85,8 +92,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
# Revoke it as well, so it stops showing up as active and later attempts fail
|
||||
# the `revoked` check above like any other revoked key.
|
||||
if api_key.entity_id is None:
|
||||
api_key.revoked = True
|
||||
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
|
||||
self.model.objects.using(MainRouter.admin_db).filter(
|
||||
id=api_key.id, revoked=False
|
||||
).update(revoked=True)
|
||||
logger.warning(
|
||||
"Revoked orphaned API key: prefix=%s tenant=%s",
|
||||
api_key.prefix,
|
||||
@@ -112,34 +120,38 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
except ValueError:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Validation, the `last_used_at` update and the auth claims all read the same
|
||||
# row, locked until the transaction ends. Looking the key up a second time to
|
||||
# build the claims used to leave a window where a key revoked or orphaned right
|
||||
# after passing validation still authenticated.
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
try:
|
||||
api_key = self._authenticate_credentials(request, key)
|
||||
except OrphanedAPIKeyError:
|
||||
# Rejected below instead of here: leaving the block normally commits
|
||||
# the revocation `_authenticate_credentials` wrote, while raising from
|
||||
# inside would roll it back.
|
||||
pass
|
||||
else:
|
||||
# The prefix used to be checked by the second lookup
|
||||
if api_key.prefix != prefix:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
try:
|
||||
api_key = self._authenticate_credentials(request, key)
|
||||
except OrphanedAPIKeyError:
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
api_key.last_used_at = timezone.now()
|
||||
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
# The prefix used to be checked by the second lookup
|
||||
if api_key.prefix != prefix:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
entity = api_key.entity
|
||||
return entity, {
|
||||
"tenant_id": str(api_key.tenant_id),
|
||||
"sub": str(entity.id),
|
||||
"api_key_prefix": api_key.prefix,
|
||||
}
|
||||
self._throttled_touch_last_used_at(api_key)
|
||||
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
entity = api_key.entity
|
||||
return entity, {
|
||||
"tenant_id": str(api_key.tenant_id),
|
||||
"sub": str(entity.id),
|
||||
"api_key_prefix": api_key.prefix,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None:
|
||||
"""Write `last_used_at` at most once per throttle interval, without locking the row."""
|
||||
now = timezone.now()
|
||||
stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS)
|
||||
|
||||
if api_key.last_used_at is not None and api_key.last_used_at >= stale_before:
|
||||
return
|
||||
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
|
||||
id=api_key.id, revoked=False
|
||||
).filter(
|
||||
Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
|
||||
).update(last_used_at=now)
|
||||
|
||||
|
||||
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
|
||||
|
||||
@@ -409,7 +409,7 @@ def batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_
|
||||
|
||||
Args:
|
||||
tenant_id (str): Tenant ID the queryset belongs to.
|
||||
queryset (QuerySet): The queryset of objects to delete.
|
||||
queryset: The queryset of objects to delete.
|
||||
batch_size (int): The number of objects to delete in each batch.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -1439,8 +1439,20 @@ class InvitationFilter(FilterSet):
|
||||
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
|
||||
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
|
||||
expires_at = DateFilter(field_name="expires_at", lookup_expr="date")
|
||||
state = ChoiceFilter(choices=Invitation.State.choices)
|
||||
state__in = ChoiceInFilter(choices=Invitation.State.choices, lookup_expr="in")
|
||||
state = ChoiceFilter(choices=Invitation.State.choices, method="filter_state")
|
||||
state__in = ChoiceInFilter(
|
||||
choices=Invitation.State.choices, lookup_expr="in", method="filter_state_in"
|
||||
)
|
||||
|
||||
def filter_state(self, queryset, name, value):
|
||||
return self.filter_state_in(queryset, name, [value])
|
||||
|
||||
def filter_state_in(self, queryset, name, value):
|
||||
lapsed = Invitation.lapsed_q()
|
||||
query = Q(state__in=value) & ~lapsed
|
||||
if Invitation.State.EXPIRED in value:
|
||||
query |= lapsed
|
||||
return queryset.filter(query)
|
||||
|
||||
class Meta:
|
||||
model = Invitation
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import uuid
|
||||
|
||||
import api.rls
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0097_attack_paths_scan_db_defaults"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TenantOnboardingProfile",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"declared_cloud_accounts",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("1", "1"),
|
||||
("2-10", "2-10"),
|
||||
("11-50", "11-50"),
|
||||
("51-200", "51-200"),
|
||||
("200+", "200+"),
|
||||
],
|
||||
max_length=16,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"declared_role",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("security", "Security"),
|
||||
("devops_platform", "DevOps / Platform"),
|
||||
("developer", "Developer"),
|
||||
("compliance_grc", "Compliance / GRC"),
|
||||
("other", "Other"),
|
||||
],
|
||||
max_length=32,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"declared_seniority",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("practitioner", "Practitioner / IC"),
|
||||
("lead", "Team lead / Manager"),
|
||||
("director", "Director / Head of"),
|
||||
("executive", "VP / C-level"),
|
||||
("founder", "Founder / Owner"),
|
||||
],
|
||||
max_length=32,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("skipped", models.BooleanField(default=False)),
|
||||
(
|
||||
"submitted_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_onboarding_profiles",
|
||||
related_query_name="tenant_onboarding_profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "tenant_onboarding_profiles",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="tenantonboardingprofile",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant_id",), name="unique_tenant_onboarding_profile"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="tenantonboardingprofile",
|
||||
# `statements` written out explicitly: RowLevelSecurityConstraint
|
||||
# .deconstruct() does not serialize it, so an autogenerated
|
||||
# migration falls back to ["SELECT"] and leaves the table without
|
||||
# INSERT/UPDATE/DELETE policies.
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_tenantonboardingprofile",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
# The onboarding profile step was reverted after 0098 had been merged, so
|
||||
# the table goes away through a new migration rather than by deleting 0098.
|
||||
dependencies = [
|
||||
("api", "0098_tenant_onboarding_profile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="TenantOnboardingProfile",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from django.db import migrations
|
||||
|
||||
TASK_NAME = "attack-paths-reap-orphaned-tmp-databases"
|
||||
INTERVAL_HOURS = 6
|
||||
|
||||
|
||||
def create_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
)
|
||||
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=TASK_NAME,
|
||||
defaults={
|
||||
"task": TASK_NAME,
|
||||
"interval": schedule,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def delete_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
PeriodicTask.objects.filter(name=TASK_NAME).delete()
|
||||
|
||||
# Clean up the schedule if no other task references it
|
||||
IntervalSchedule.objects.filter(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
periodictask__isnull=True,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0099_delete_tenant_onboarding_profile"),
|
||||
("django_celery_beat", "0019_alter_periodictasks_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_periodic_task, delete_periodic_task),
|
||||
]
|
||||
@@ -617,9 +617,66 @@ class Task(RowLevelSecurityProtectedModel):
|
||||
resource_name = "tasks"
|
||||
|
||||
|
||||
class ScanQuerySet(models.QuerySet):
|
||||
"""Shared selectors for "the latest scan of a provider".
|
||||
|
||||
The queryset must already be scoped by the caller: manager, tenant, RBAC,
|
||||
providers and database alias.
|
||||
"""
|
||||
|
||||
# How "which completed scan is the provider's current one" is ordered.
|
||||
LATEST_ORDER_BY = (
|
||||
models.F("completed_at").desc(nulls_last=True),
|
||||
models.F("inserted_at").desc(),
|
||||
models.F("id").desc(),
|
||||
)
|
||||
|
||||
def _eligible_for_latest(self) -> "ScanQuerySet":
|
||||
"""Restrict to the scans that may be a provider's latest.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: The completed scans.
|
||||
"""
|
||||
return self.filter(state=StateChoices.COMPLETED)
|
||||
|
||||
def latest_per_provider(self) -> "ScanQuerySet":
|
||||
"""Pick each provider's latest scan with `DISTINCT ON (provider_id)`.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: One scan per provider, the latest one.
|
||||
"""
|
||||
return (
|
||||
self._eligible_for_latest()
|
||||
.order_by("provider_id", *self.LATEST_ORDER_BY)
|
||||
.distinct("provider_id")
|
||||
)
|
||||
|
||||
def latest_ids_per_provider(self) -> list[UUID]:
|
||||
"""Evaluate `latest_per_provider` and return the scan ids.
|
||||
|
||||
The ids are materialised so callers can pass them as a literal `IN`
|
||||
list; as a subquery Postgres misestimates the row count and picks a
|
||||
slow nested loop.
|
||||
|
||||
Returns:
|
||||
list[UUID]: The id of each provider's latest scan.
|
||||
"""
|
||||
return list(self.latest_per_provider().values_list("id", flat=True))
|
||||
|
||||
def latest_first(self) -> "ScanQuerySet":
|
||||
"""Order eligible scans newest first, without deduplicating per provider.
|
||||
|
||||
Expects the queryset to be already filtered to a single provider.
|
||||
|
||||
Returns:
|
||||
ScanQuerySet: The eligible scans, latest first.
|
||||
"""
|
||||
return self._eligible_for_latest().order_by(*self.LATEST_ORDER_BY)
|
||||
|
||||
|
||||
class Scan(RowLevelSecurityProtectedModel):
|
||||
objects = ActiveProviderManager()
|
||||
all_objects = models.Manager()
|
||||
objects = ActiveProviderManager.from_queryset(ScanQuerySet)()
|
||||
all_objects = ScanQuerySet.as_manager()
|
||||
|
||||
_SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None
|
||||
|
||||
@@ -726,6 +783,12 @@ class Scan(RowLevelSecurityProtectedModel):
|
||||
name="scans_prov_state_ins_desc_idx",
|
||||
),
|
||||
# TODO This might replace `scans_prov_state_ins_desc_idx` completely. Review usage
|
||||
# Since `ScanQuerySet`, no code path reads a provider's
|
||||
# completed scans by `-inserted_at`. The only query left that
|
||||
# matches this index (and `scans_prov_state_ins_desc_idx` above)
|
||||
# is `GET /scans?filter[provider]=…&filter[state]=completed` with
|
||||
# the default sort. Both are candidates to drop in a follow-up
|
||||
# once production `pg_stat_user_indexes.idx_scan` confirms it.
|
||||
models.Index(
|
||||
fields=["tenant_id", "provider_id", "-inserted_at"],
|
||||
condition=Q(state=StateChoices.COMPLETED),
|
||||
@@ -1380,6 +1443,15 @@ class Invitation(RowLevelSecurityProtectedModel):
|
||||
self.email = self.email.strip().lower()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def lapsed_q(cls):
|
||||
"""Pending invitations whose expiry date has already passed."""
|
||||
return Q(state=cls.State.PENDING, expires_at__lte=datetime.now(UTC))
|
||||
|
||||
@property
|
||||
def is_lapsed(self):
|
||||
return self.state == self.State.PENDING and self.expires_at <= datetime.now(UTC)
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "invitations"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Integration, Provider, Role, User
|
||||
from api.models import Integration, Provider, Role, Task, User
|
||||
from django.db.models import Q, QuerySet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import BasePermission
|
||||
@@ -17,6 +17,50 @@ class Permissions(Enum):
|
||||
UNLIMITED_VISIBILITY = "unlimited_visibility"
|
||||
|
||||
|
||||
# Revoking a task needs the permission of the operation that queued it.
|
||||
# None and unmapped names are not revocable; a revoked provider deletion
|
||||
# would leave the provider soft-deleted with nothing re-queuing the cleanup.
|
||||
TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = {
|
||||
"provider-connection-check": [Permissions.MANAGE_PROVIDERS],
|
||||
"provider-deletion": None,
|
||||
"integration-connection-check": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-s3": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-security-hub": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"integration-jira": [Permissions.MANAGE_INTEGRATIONS],
|
||||
"scan-perform": [Permissions.MANAGE_SCANS],
|
||||
"scan-perform-scheduled": [Permissions.MANAGE_SCANS],
|
||||
"scan-compliance-overviews": [Permissions.MANAGE_SCANS],
|
||||
"scan-compliance-reports": [Permissions.MANAGE_SCANS],
|
||||
"scan-finding-group-summaries": [Permissions.MANAGE_SCANS],
|
||||
"scan-report": [Permissions.MANAGE_SCANS],
|
||||
"attack-paths-scan-perform": [Permissions.MANAGE_SCANS],
|
||||
"findings-mute-latest-scans": [Permissions.MANAGE_SCANS],
|
||||
"lighthouse-connection-check": [],
|
||||
"lighthouse-provider-connection-check": [],
|
||||
"lighthouse-provider-models-refresh": [],
|
||||
}
|
||||
|
||||
|
||||
def get_user_roles(user: User, tenant_id: str) -> list[Role]:
|
||||
"""Return every role assigned to the user in the tenant."""
|
||||
return list(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
)
|
||||
|
||||
|
||||
def roles_have_permissions(
|
||||
roles: list[Role], required_permissions: list[Permissions]
|
||||
) -> bool:
|
||||
"""Return True when every required permission is granted by at least one role."""
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
|
||||
|
||||
class HasPermissions(BasePermission):
|
||||
"""
|
||||
Custom permission to check if the user's role has the required permissions.
|
||||
@@ -34,19 +78,11 @@ class HasPermissions(BasePermission):
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
user_roles = list(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=request.user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
)
|
||||
user_roles = get_user_roles(request.user, tenant_id)
|
||||
if not user_roles:
|
||||
return False
|
||||
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in user_roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
return roles_have_permissions(user_roles, required_permissions)
|
||||
|
||||
|
||||
def get_role(user: User, tenant_id: str) -> Role:
|
||||
@@ -85,6 +121,25 @@ def get_providers(role: Role) -> QuerySet[Provider]:
|
||||
).distinct()
|
||||
|
||||
|
||||
def get_tasks(role: Role) -> QuerySet[Task]:
|
||||
"""Return the tasks visible to the role: tenant-wide ones and those of its providers."""
|
||||
queryset = Task.objects.filter(tenant_id=role.tenant_id)
|
||||
if role.unlimited_visibility:
|
||||
return queryset
|
||||
|
||||
# Task has no provider FK, so match provider ids inside the stored kwargs.
|
||||
# all_objects keeps a soft-deleted provider visible to its own groups, so the
|
||||
# role that queued its deletion can still follow the task.
|
||||
hidden = Q()
|
||||
for provider_id in (
|
||||
Provider.all_objects.filter(tenant_id=role.tenant_id)
|
||||
.exclude(provider_groups__in=role.provider_groups.all())
|
||||
.values_list("id", flat=True)
|
||||
):
|
||||
hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id))
|
||||
return queryset.exclude(hidden) if hidden else queryset
|
||||
|
||||
|
||||
def get_integrations(
|
||||
role: Role, providers: QuerySet[Provider] | None = None
|
||||
) -> QuerySet[Integration]:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.42.0
|
||||
version: 1.46.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -14823,7 +14823,9 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_tasks_list
|
||||
description: Retrieve a list of all tasks with options for filtering by name,
|
||||
state, and other criteria.
|
||||
state, and other criteria. Tasks that reference a provider are only returned
|
||||
when the role can access it; tasks without a provider reference are returned
|
||||
for every role.
|
||||
summary: List all tasks
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -14922,7 +14924,8 @@ paths:
|
||||
/api/v1/tasks/{id}:
|
||||
get:
|
||||
operationId: api_v1_tasks_retrieve
|
||||
description: Fetch detailed information about a specific task by its ID.
|
||||
description: Fetch detailed information about a specific task by its ID. Tasks
|
||||
tied to a provider outside the visibility of the role are not found.
|
||||
summary: Retrieve data from a specific task
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -14963,7 +14966,9 @@ paths:
|
||||
delete:
|
||||
operationId: api_v1_tasks_destroy
|
||||
description: Try to revoke a task using its ID. Only tasks that are not yet
|
||||
in progress can be revoked.
|
||||
in progress can be revoked, and the caller needs the same permission as the
|
||||
operation that queued the task (for example MANAGE_SCANS for a scan). Provider
|
||||
deletions cannot be revoked.
|
||||
summary: Revoke a task
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
|
||||
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
|
||||
@@ -11,6 +11,7 @@ from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
|
||||
from django.db.utils import ConnectionDoesNotExist
|
||||
from django.urls import reverse
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
@@ -527,7 +528,7 @@ class TestAPIKeyAuthentication:
|
||||
def test_last_used_at_tracking(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Verify last_used_at timestamp updates on each authentication."""
|
||||
"""Verify last_used_at timestamp is set on first use and throttled after that."""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
@@ -536,7 +537,11 @@ class TestAPIKeyAuthentication:
|
||||
|
||||
# Use API key to authenticate
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
first_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert first_response.status_code == 200
|
||||
|
||||
# Reload from database and check last_used_at is set
|
||||
@@ -544,17 +549,23 @@ class TestAPIKeyAuthentication:
|
||||
first_used_at = api_key.last_used_at
|
||||
assert first_used_at is not None
|
||||
|
||||
# Use the same key again after a small delay
|
||||
time.sleep(0.1)
|
||||
|
||||
# Using the same key again within the throttle interval does not rewrite it
|
||||
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert second_response.status_code == 200
|
||||
|
||||
# Reload and verify last_used_at was updated
|
||||
api_key.refresh_from_db()
|
||||
second_used_at = api_key.last_used_at
|
||||
assert second_used_at is not None
|
||||
assert second_used_at > first_used_at
|
||||
assert api_key.last_used_at == first_used_at
|
||||
|
||||
# Past the throttle interval, the next use refreshes it
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
third_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert third_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at > first_used_at
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1441,6 +1452,7 @@ class TestAPIKeyRLSBypass:
|
||||
|
||||
The update to last_used_at during authentication must also use the
|
||||
admin database since it occurs before RLS context is established.
|
||||
Past the throttle interval, using the key again refreshes the timestamp.
|
||||
"""
|
||||
client = APIClient()
|
||||
api_key = api_keys_fixture[0]
|
||||
@@ -1448,7 +1460,11 @@ class TestAPIKeyRLSBypass:
|
||||
assert api_key.last_used_at is None
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
first_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
|
||||
@@ -1456,9 +1472,11 @@ class TestAPIKeyRLSBypass:
|
||||
first_timestamp = api_key.last_used_at
|
||||
assert first_timestamp is not None
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
second_response = client.get(
|
||||
reverse("provider-list"), headers=api_key_headers
|
||||
)
|
||||
assert second_response.status_code == 200
|
||||
|
||||
api_key.refresh_from_db()
|
||||
|
||||
@@ -187,6 +187,27 @@ class TestRoutingByDatabasePrefix:
|
||||
sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc")
|
||||
mock_ingest.drop_database.assert_not_called()
|
||||
|
||||
def test_list_databases_always_routes_to_ingest(self, sink_backend_stub):
|
||||
with patch("api.attack_paths.database.ingest") as mock_ingest:
|
||||
mock_ingest.list_databases.return_value = ["db-tmp-scan-uuid-1"]
|
||||
|
||||
assert db_module.list_databases() == ["db-tmp-scan-uuid-1"]
|
||||
|
||||
mock_ingest.list_databases.assert_called_once_with()
|
||||
|
||||
def test_ingest_list_databases_dedupes_cluster_rows(self):
|
||||
from api.attack_paths.ingest import driver as ingest_driver
|
||||
|
||||
with patch.object(ingest_driver, "get_session") as mock_get_session:
|
||||
session = mock_get_session.return_value.__enter__.return_value
|
||||
session.run.return_value = [{"name": "db-tmp-scan-uuid-1"}]
|
||||
|
||||
assert ingest_driver.list_databases() == ["db-tmp-scan-uuid-1"]
|
||||
|
||||
session.run.assert_called_once_with(
|
||||
"SHOW DATABASES YIELD name RETURN DISTINCT name"
|
||||
)
|
||||
|
||||
def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub):
|
||||
with patch("api.attack_paths.database.ingest") as mock_ingest:
|
||||
db_module.clear_cache("db-tmp-scan-uuid-1")
|
||||
|
||||
@@ -5,16 +5,18 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.authentication import (
|
||||
API_KEY_LAST_USED_AT_THROTTLE_SECONDS,
|
||||
OrphanedAPIKeyError,
|
||||
SSEAuthentication,
|
||||
TenantAPIKeyAuthentication,
|
||||
)
|
||||
from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey
|
||||
from api.models import TenantAPIKey, User
|
||||
from django.db import connections
|
||||
from django.db.models.query import QuerySet
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
@@ -286,14 +288,15 @@ class TestTenantAPIKeyAuthentication:
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
|
||||
def test_authenticate_reads_the_api_key_once_without_a_row_lock(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the API key is read a single time and the row is locked.
|
||||
"""Test the API key is read a single time and no row is locked.
|
||||
|
||||
Validation, the `last_used_at` update and the claims must all come from the
|
||||
same authoritative row: a second, unlocked lookup would reopen the window
|
||||
where a key revoked in between still authenticates.
|
||||
same authoritative row: a second lookup would reopen the window where a key
|
||||
revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized
|
||||
every request for a hot key onto one locked row and is not used any more.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
@@ -310,33 +313,40 @@ class TestTenantAPIKeyAuthentication:
|
||||
]
|
||||
|
||||
assert len(api_key_selects) == 1
|
||||
assert "FOR UPDATE" in api_key_selects[0]
|
||||
assert "FOR UPDATE" not in api_key_selects[0]
|
||||
|
||||
def test_authenticate_ignores_revocation_after_the_locked_read(
|
||||
def test_authenticate_ignores_revocation_after_the_single_read(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the claims describe the row that was validated, not a later state.
|
||||
|
||||
Regression test: the key used to be looked up again to build the auth dict,
|
||||
without rechecking `revoked` or `entity`. A key revoked or orphaned between
|
||||
both reads still authenticated, and the claims came from that stale row. With
|
||||
a single locked read the write below cannot land mid-authentication, and the
|
||||
revocation only takes effect on the next request.
|
||||
both reads still authenticated, and the claims came from that stale row.
|
||||
There is now only a single read, so this race is closed by construction and
|
||||
the revocation only takes effect on the next request.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
entity_at_validation = api_key.entity
|
||||
original_save = TenantAPIKey.save
|
||||
original_authenticate_credentials = (
|
||||
TenantAPIKeyAuthentication._authenticate_credentials
|
||||
)
|
||||
|
||||
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
|
||||
# Runs after validation, right before the claims are built: the exact
|
||||
# window a concurrent revocation or user deletion used to slip into
|
||||
def revoke_and_orphan_after_reading(self, request, key):
|
||||
# Runs right after the single read `authenticate` will use to build the
|
||||
# claims: the exact window a concurrent revocation used to slip into
|
||||
result = original_authenticate_credentials(self, request, key)
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
|
||||
return original_save(instance, *args, **kwargs)
|
||||
return result
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
|
||||
with patch.object(
|
||||
TenantAPIKeyAuthentication,
|
||||
"_authenticate_credentials",
|
||||
revoke_and_orphan_after_reading,
|
||||
):
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
assert entity == entity_at_validation
|
||||
@@ -350,6 +360,43 @@ class TestTenantAPIKeyAuthentication:
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_survives_owner_deleted_after_the_single_read(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test a user deleted right after the read does not turn into a 500.
|
||||
|
||||
Without the row lock a concurrent user deletion can land between the read
|
||||
and building the claims. `entity` is loaded by the same query, so no later
|
||||
lookup can raise `DoesNotExist`.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
owner_id = api_key.entity_id
|
||||
original_authenticate_credentials = (
|
||||
TenantAPIKeyAuthentication._authenticate_credentials
|
||||
)
|
||||
|
||||
def delete_owner_after_reading(self, request, key):
|
||||
result = original_authenticate_credentials(self, request, key)
|
||||
User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete()
|
||||
return result
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with patch.object(
|
||||
TenantAPIKeyAuthentication,
|
||||
"_authenticate_credentials",
|
||||
delete_owner_after_reading,
|
||||
):
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
assert auth_dict["sub"] == str(owner_id)
|
||||
assert entity.id == owner_id
|
||||
|
||||
# From the next request on, the orphaned key is rejected with a 401
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
def test_authenticate_expired_api_key(
|
||||
self, auth_backend, create_test_user, tenants_fixture, request_factory
|
||||
):
|
||||
@@ -421,24 +468,90 @@ class TestTenantAPIKeyAuthentication:
|
||||
if original_last_used:
|
||||
assert api_key.last_used_at > original_last_used
|
||||
|
||||
def test_authenticate_saves_to_admin_database(
|
||||
def test_authenticate_updates_last_used_at_on_admin_database(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that the API key save operation uses admin database."""
|
||||
"""Test that the `last_used_at` update runs against the admin database."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# Mock the save method to verify it's called with using='admin'
|
||||
with patch.object(TenantAPIKey, "save") as mock_save:
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
# Verify save was called with using=admin_db
|
||||
mock_save.assert_called_once_with(
|
||||
update_fields=["last_used_at"], using=MainRouter.admin_db
|
||||
)
|
||||
api_key_updates = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
|
||||
assert len(api_key_updates) == 1
|
||||
assert "last_used_at" in api_key_updates[0]
|
||||
|
||||
def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that a second authentication within the throttle interval is a no-op write."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
# First call sets last_used_at
|
||||
auth_backend.authenticate(request)
|
||||
api_key.refresh_from_db()
|
||||
first_used_at = api_key.last_used_at
|
||||
assert first_used_at is not None
|
||||
|
||||
# Second call, still within the throttle interval, must issue no UPDATE
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key_updates = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
assert api_key_updates == []
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at == first_used_at
|
||||
|
||||
def test_authenticate_rewrites_last_used_at_after_throttle_interval(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test that `last_used_at` is refreshed once it is older than the throttle interval."""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
start = datetime.now(UTC)
|
||||
with freeze_time(start):
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key.refresh_from_db()
|
||||
first_used_at = api_key.last_used_at
|
||||
assert first_used_at is not None
|
||||
|
||||
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
|
||||
with freeze_time(later):
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key_updates = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
assert len(api_key_updates) == 1
|
||||
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.last_used_at > first_used_at
|
||||
|
||||
def test_authenticate_returns_correct_auth_dict(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from api.db_router import MainRouter
|
||||
from api.models import (
|
||||
Provider,
|
||||
ProviderComplianceScore,
|
||||
Resource,
|
||||
ResourceTag,
|
||||
SAMLConfiguration,
|
||||
SAMLDomainIndex,
|
||||
Scan,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
TenantComplianceSummary,
|
||||
@@ -524,3 +526,226 @@ class TestTenantComplianceSummaryModel:
|
||||
|
||||
assert summary1.id != summary2.id
|
||||
assert summary1.requirements_passed != summary2.requirements_passed
|
||||
|
||||
|
||||
def _latest_scan_fixture(tenant, provider, *, completed_at, inserted_at=None, **kwargs):
|
||||
scan = Scan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
trigger=kwargs.pop("trigger", Scan.TriggerChoices.MANUAL),
|
||||
state=kwargs.pop("state", StateChoices.COMPLETED),
|
||||
completed_at=completed_at,
|
||||
**kwargs,
|
||||
)
|
||||
if inserted_at is not None:
|
||||
# `inserted_at` is auto_now_add, so it has to be forced after the fact.
|
||||
Scan.all_objects.filter(pk=scan.pk).update(inserted_at=inserted_at)
|
||||
scan.refresh_from_db()
|
||||
return scan
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetOrdering:
|
||||
def test_completed_later_wins_over_inserted_later(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""The scan that FINISHED last is current, not the one that started last."""
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
finished_last = _latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now - timedelta(hours=3),
|
||||
completed_at=now,
|
||||
)
|
||||
_latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now - timedelta(hours=1),
|
||||
completed_at=now - timedelta(hours=1),
|
||||
)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [finished_last.id]
|
||||
|
||||
def test_null_completed_at_provider_is_still_returned(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""NULLS LAST, not `completed_at__isnull=False`.
|
||||
|
||||
Excluding NULL `completed_at` would drop the provider from every
|
||||
"latest" endpoint instead of falling back to `inserted_at`.
|
||||
"""
|
||||
tenant, *_ = tenants_fixture
|
||||
only_scan = _latest_scan_fixture(tenant, aws_provider, completed_at=None)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [only_scan.id]
|
||||
|
||||
def test_null_completed_at_never_outranks_a_finished_scan(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""Postgres sorts NULLs first under DESC; NULLS LAST is what fixes it."""
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
finished = _latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now - timedelta(hours=2),
|
||||
completed_at=now - timedelta(hours=2),
|
||||
)
|
||||
_latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
inserted_at=now,
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [finished.id]
|
||||
|
||||
def test_id_breaks_an_exact_timestamp_tie_deterministically(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
scans = [
|
||||
_latest_scan_fixture(
|
||||
tenant, aws_provider, inserted_at=now, completed_at=now
|
||||
)
|
||||
for _ in range(3)
|
||||
]
|
||||
expected = max(scan.id for scan in scans)
|
||||
|
||||
picks = {
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()[0]
|
||||
for _ in range(5)
|
||||
}
|
||||
assert picks == {expected}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetEligibility:
|
||||
def test_unfinished_scans_are_excluded(self, tenants_fixture, aws_provider):
|
||||
tenant, *_ = tenants_fixture
|
||||
_latest_scan_fixture(
|
||||
tenant,
|
||||
aws_provider,
|
||||
completed_at=None,
|
||||
state=StateChoices.EXECUTING,
|
||||
)
|
||||
assert (
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetManagerChoice:
|
||||
def test_active_manager_hides_soft_deleted_providers(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""`Scan.objects` drops soft-deleted providers, `all_objects` keeps them.
|
||||
|
||||
The queryset must not decide this for the caller.
|
||||
"""
|
||||
tenant, *_ = tenants_fixture
|
||||
scan = _latest_scan_fixture(
|
||||
tenant, aws_provider, completed_at=datetime.now(UTC)
|
||||
)
|
||||
|
||||
Provider.all_objects.filter(pk=aws_provider.pk).update(is_deleted=True)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id
|
||||
).latest_ids_per_provider() == [scan.id]
|
||||
assert Scan.objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetPerProviderScoping:
|
||||
def test_one_scan_per_provider(self, tenants_fixture, aws_provider_pair):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider_one, provider_two = aws_provider_pair
|
||||
now = datetime.now(UTC)
|
||||
|
||||
newest_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
|
||||
_latest_scan_fixture(tenant, provider_one, completed_at=now - timedelta(days=1))
|
||||
newest_two = _latest_scan_fixture(tenant, provider_two, completed_at=now)
|
||||
|
||||
assert set(
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()
|
||||
) == {newest_one.id, newest_two.id}
|
||||
|
||||
def test_caller_filters_are_preserved(self, tenants_fixture, aws_provider_pair):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider_one, provider_two = aws_provider_pair
|
||||
now = datetime.now(UTC)
|
||||
|
||||
scan_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
|
||||
_latest_scan_fixture(tenant, provider_two, completed_at=now)
|
||||
|
||||
assert Scan.all_objects.filter(
|
||||
tenant_id=tenant.id, provider__in=[provider_one]
|
||||
).latest_ids_per_provider() == [scan_one.id]
|
||||
|
||||
def test_latest_first_is_ordered_not_deduplicated(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
|
||||
older = _latest_scan_fixture(
|
||||
tenant, aws_provider, completed_at=now - timedelta(days=1)
|
||||
)
|
||||
|
||||
ordered = list(
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant.id, provider_id=aws_provider.id
|
||||
).latest_first()
|
||||
)
|
||||
assert [scan.id for scan in ordered] == [newest.id, older.id]
|
||||
|
||||
def test_tenant_isolation(self, tenants_fixture, aws_provider):
|
||||
tenant, other_tenant, *_ = tenants_fixture
|
||||
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
|
||||
|
||||
assert (
|
||||
Scan.all_objects.filter(tenant_id=other_tenant.id).latest_ids_per_provider()
|
||||
== []
|
||||
)
|
||||
|
||||
def test_empty_queryset_returns_empty_list(self, tenants_fixture):
|
||||
tenant, *_ = tenants_fixture
|
||||
assert (
|
||||
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetPerProviderQuerysetShape:
|
||||
def test_returns_a_queryset_not_a_list(self, tenants_fixture, aws_provider):
|
||||
tenant, *_ = tenants_fixture
|
||||
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
|
||||
|
||||
qs = Scan.all_objects.filter(tenant_id=tenant.id).latest_per_provider()
|
||||
# Callers chain .values(...) / .values_list(...) onto this.
|
||||
assert qs.values_list("provider_id", flat=True).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScanQuerySetRelatedManager:
|
||||
def test_reverse_relation_exposes_the_methods(self, tenants_fixture, aws_provider):
|
||||
tenant, *_ = tenants_fixture
|
||||
now = datetime.now(UTC)
|
||||
|
||||
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
|
||||
_latest_scan_fixture(tenant, aws_provider, completed_at=now - timedelta(days=1))
|
||||
|
||||
assert aws_provider.scans.latest_first().first().id == newest.id
|
||||
|
||||
@@ -30,6 +30,21 @@ def test_initialize_sentry_uses_configured_dsn():
|
||||
assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send
|
||||
|
||||
|
||||
def test_initialize_sentry_sends_no_personal_data():
|
||||
with (
|
||||
patch.object(
|
||||
sentry_settings.env,
|
||||
"str",
|
||||
return_value="https://fake-public-key@sentry.example.invalid/1",
|
||||
),
|
||||
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
|
||||
):
|
||||
sentry_settings.initialize_sentry()
|
||||
|
||||
assert mock_init.call_args.kwargs["send_default_pii"] is False
|
||||
assert mock_init.call_args.kwargs["max_request_body_size"] == "never"
|
||||
|
||||
|
||||
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
|
||||
"""Build a real LogRecord so getMessage() works like in production."""
|
||||
record = logging.LogRecord(
|
||||
|
||||
@@ -215,36 +215,34 @@ class TestOracleCloudProviderSecret:
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
def test_accepts_and_ignores_region_field(self):
|
||||
secret = self.valid_secret(region="us-phoenix-1")
|
||||
serializer = OracleCloudProviderSecret(data=secret)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_field, legacy_value",
|
||||
[
|
||||
("region", None),
|
||||
("region", ""),
|
||||
("region", {"name": "us-ashburn-1"}),
|
||||
],
|
||||
)
|
||||
def test_accepts_and_ignores_any_legacy_region_value(
|
||||
self, legacy_field, legacy_value
|
||||
):
|
||||
def test_keeps_region_as_home_region(self):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(**{legacy_field: legacy_value})
|
||||
data=self.valid_secret(region=" me-abudhabi-1 ")
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["region"] == "me-abudhabi-1"
|
||||
|
||||
assert legacy_field not in serializer.validated_data
|
||||
def test_rejects_unknown_region(self):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(region="mars-north-1")
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "region" in serializer.errors
|
||||
|
||||
@pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}])
|
||||
def test_drops_blank_or_non_string_region(self, legacy_value):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(region=legacy_value)
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
def test_oraclecloud_schema_region_is_not_deprecated(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
credential_schema
|
||||
@@ -253,7 +251,7 @@ class TestProviderSecretFieldSchema:
|
||||
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
|
||||
)
|
||||
|
||||
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
|
||||
assert "deprecated" not in oraclecloud_schema["properties"]["region"]
|
||||
|
||||
|
||||
class TestKubernetesProviderSecret:
|
||||
|
||||
@@ -172,7 +172,7 @@ class TestInitializeProwlerProvider:
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_removes_region_string(
|
||||
def test_initialize_oraclecloud_provider_passes_region_as_home_region(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
@@ -182,7 +182,7 @@ class TestInitializeProwlerProvider:
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"region": "us-ashburn-1",
|
||||
"region": "me-abudhabi-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
@@ -193,6 +193,7 @@ class TestInitializeProwlerProvider:
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
home_region="me-abudhabi-1",
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
@@ -254,11 +255,35 @@ class TestProwlerProviderConnectionTest:
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region=getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
),
|
||||
region=OraclecloudProvider._bootstrap_region,
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_oraclecloud_connection_test_uses_stored_region_for_identity(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
"region": "me-abudhabi-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
user="ocid1.user.oc1..aaaaaaaexample",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region="me-abudhabi-1",
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
@@ -434,7 +459,7 @@ class TestGetProwlerProviderKwargs:
|
||||
expected_result = {**secret_dict, **expected_extra_kwargs}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region(
|
||||
self,
|
||||
):
|
||||
secret_dict = {
|
||||
@@ -461,6 +486,7 @@ class TestGetProwlerProviderKwargs:
|
||||
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"pass_phrase": "fake-passphrase",
|
||||
"home_region": "us-ashburn-1",
|
||||
}
|
||||
|
||||
def test_get_prowler_provider_kwargs_with_mutelist(self):
|
||||
|
||||
@@ -60,6 +60,7 @@ from api.models import (
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.rbac.permissions import TASK_REVOKE_PERMISSIONS
|
||||
from api.rls import Tenant
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from api.v1.views import (
|
||||
@@ -82,7 +83,7 @@ from django.db import close_old_connections, connection, connections
|
||||
from django.db.models import Count
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
from django.test import RequestFactory, override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
@@ -3362,7 +3363,7 @@ current-context: test-context
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
|
||||
def test_provider_secrets_create_oraclecloud_stores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
@@ -3371,14 +3372,14 @@ current-context: test-context
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(
|
||||
key_content=" test-key-content ", region=" us-ashburn-1 "
|
||||
key_content=" test-key-content ", region=" me-abudhabi-1 "
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert provider_secret.secret["key_content"] == "test-key-content"
|
||||
assert "region" not in provider_secret.secret
|
||||
assert provider_secret.secret["region"] == "me-abudhabi-1"
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
@@ -3411,7 +3412,7 @@ current-context: test-context
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
|
||||
def test_provider_secrets_update_oraclecloud_stores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
@@ -3429,7 +3430,7 @@ current-context: test-context
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
|
||||
"secret": self._oraclecloud_secret(region=" me-abudhabi-1 ")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3442,7 +3443,7 @@ current-context: test-context
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
assert provider_secret.secret["region"] == "me-abudhabi-1"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
@@ -3951,6 +3952,43 @@ class TestScanViewSet:
|
||||
mock_enqueue_scan_execution.assert_called_once()
|
||||
# assert scan.scanner_args == expected_scanner_args
|
||||
|
||||
@patch("api.v1.views.enqueue_scan_execution_on_commit")
|
||||
def test_scans_create_returns_the_scan_id_in_task_args(
|
||||
self,
|
||||
mock_enqueue_scan_execution,
|
||||
authenticated_client,
|
||||
okta_provider,
|
||||
):
|
||||
"""The 202 is a task, so `task_args` is the only place the scan id is.
|
||||
|
||||
It is serialized before the on_commit publish that would otherwise fill
|
||||
the kwargs, so the record has to carry them from the start.
|
||||
"""
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {"name": "New Scan"},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {"type": "providers", "id": str(okta_provider.id)}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("scan-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
scan = Scan.objects.get()
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(okta_provider.id),
|
||||
}
|
||||
|
||||
@patch("tasks.tasks.perform_scan_task.apply_async")
|
||||
def test_scans_create_queues_scan_when_provider_has_active_scan(
|
||||
self,
|
||||
@@ -4540,6 +4578,52 @@ class TestScanViewSet:
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert response["Location"] == presigned_url
|
||||
|
||||
@override_settings(
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID="access-key",
|
||||
DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY="secret-key",
|
||||
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN="",
|
||||
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION="eu-west-1",
|
||||
)
|
||||
def test_report_s3_redirects_to_the_public_storage_host(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
"""The object is looked up internally but the redirect the browser follows is public."""
|
||||
scan = scans_fixture[0]
|
||||
bucket = "test-bucket"
|
||||
key = "report.zip"
|
||||
scan.output_location = f"s3://{bucket}/{key}"
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *_args, **_kwargs: bucket})(),
|
||||
)
|
||||
|
||||
head_calls = []
|
||||
|
||||
class InternalS3Client:
|
||||
def head_object(self, Bucket, Key):
|
||||
head_calls.append((Bucket, Key))
|
||||
return {}
|
||||
|
||||
def generate_presigned_url(self, *_args, **_kwargs):
|
||||
raise AssertionError("the internal client must not sign the redirect")
|
||||
|
||||
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: InternalS3Client())
|
||||
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert head_calls == [(bucket, key)]
|
||||
|
||||
location = urlparse(response["Location"])
|
||||
assert location.netloc == "storage.example.com"
|
||||
assert location.path == f"/{bucket}/{key}"
|
||||
assert "X-Amz-Signature" in parse_qs(location.query)
|
||||
|
||||
def test_report_s3_success_no_local_files(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
@@ -5156,6 +5240,7 @@ class TestTaskViewSet:
|
||||
@patch("api.v1.views.AsyncResult", return_value=Mock())
|
||||
def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture):
|
||||
_, task2 = tasks_fixture
|
||||
self._set_task_name(task2, "scan-perform")
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": task2.id})
|
||||
)
|
||||
@@ -5171,12 +5256,311 @@ class TestTaskViewSet:
|
||||
|
||||
def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture):
|
||||
task1, _ = tasks_fixture
|
||||
self._set_task_name(task1, "scan-perform")
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": task1.id})
|
||||
)
|
||||
# Task status is SUCCESS
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@staticmethod
|
||||
def _set_task_name(task, name):
|
||||
task.task_runner_task.task_name = name
|
||||
task.task_runner_task.save(update_fields=["task_name"])
|
||||
|
||||
@staticmethod
|
||||
def _set_task_kwargs(task, kwargs):
|
||||
task.task_runner_task.task_kwargs = json.dumps(repr(kwargs))
|
||||
task.task_runner_task.save(update_fields=["task_kwargs"])
|
||||
|
||||
@staticmethod
|
||||
def _client_with_role(tenant, factory, **permissions):
|
||||
user = User.objects.create_user(
|
||||
name=f"revoker-{uuid4()}",
|
||||
email=f"revoker-{uuid4()}@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER
|
||||
)
|
||||
flags = {
|
||||
"manage_users": False,
|
||||
"manage_account": False,
|
||||
"manage_billing": False,
|
||||
"manage_providers": False,
|
||||
"manage_integrations": False,
|
||||
"manage_scans": False,
|
||||
"unlimited_visibility": True,
|
||||
**permissions,
|
||||
}
|
||||
role = Role.objects.create(
|
||||
name=f"revoker-{uuid4()}", tenant_id=tenant.id, **flags
|
||||
)
|
||||
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
|
||||
return factory(user, tenant)
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_without_permission_is_forbidden(
|
||||
self, mock_async_result, authenticated_client_no_permissions_rbac, tasks_fixture
|
||||
):
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "provider-connection-check")
|
||||
|
||||
response = authenticated_client_no_permissions_rbac.delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"task_name, permissions, expected_status",
|
||||
[
|
||||
(
|
||||
"provider-connection-check",
|
||||
{"manage_providers": True},
|
||||
status.HTTP_202_ACCEPTED,
|
||||
),
|
||||
(
|
||||
"provider-connection-check",
|
||||
{"manage_scans": True},
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
),
|
||||
("scan-perform", {"manage_scans": True}, status.HTTP_202_ACCEPTED),
|
||||
(
|
||||
"scan-perform-scheduled",
|
||||
{"manage_providers": True},
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
),
|
||||
(
|
||||
"integration-jira",
|
||||
{"manage_integrations": True},
|
||||
status.HTTP_202_ACCEPTED,
|
||||
),
|
||||
("integration-jira", {"manage_providers": True}, status.HTTP_403_FORBIDDEN),
|
||||
("lighthouse-connection-check", {}, status.HTTP_202_ACCEPTED),
|
||||
],
|
||||
)
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_requires_originating_operation_permission(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_for_tenant_factory,
|
||||
tenants_fixture,
|
||||
tasks_fixture,
|
||||
task_name,
|
||||
permissions,
|
||||
expected_status,
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, task_name)
|
||||
client = self._client_with_role(
|
||||
tenant, authenticated_client_for_tenant_factory, **permissions
|
||||
)
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
if expected_status == status.HTTP_202_ACCEPTED:
|
||||
mock_async_result.return_value.revoke.assert_called_once()
|
||||
else:
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_provider_deletion_is_forbidden_even_for_admin(
|
||||
self, mock_async_result, authenticated_client, tasks_fixture
|
||||
):
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "provider-deletion")
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_unmapped_task_is_forbidden(
|
||||
self, mock_async_result, authenticated_client, tasks_fixture
|
||||
):
|
||||
_, pending_task = tasks_fixture
|
||||
assert pending_task.task_runner_task.task_name not in TASK_REVOKE_PERMISSIONS
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
def test_every_rls_task_has_revoke_permissions(self):
|
||||
from config.celery import RLSTask, celery_app
|
||||
|
||||
rls_task_names = {
|
||||
name for name, task in celery_app.tasks.items() if isinstance(task, RLSTask)
|
||||
}
|
||||
assert rls_task_names
|
||||
assert rls_task_names <= set(TASK_REVOKE_PERMISSIONS)
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_hidden_for_providers_outside_role_visibility(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_no_permissions_rbac,
|
||||
tasks_fixture,
|
||||
aws_provider_pair,
|
||||
):
|
||||
client = authenticated_client_no_permissions_rbac
|
||||
limited_user = client.user
|
||||
tenant = Membership.objects.filter(user=limited_user).first().tenant
|
||||
allowed_provider, denied_provider = aws_provider_pair
|
||||
allowed_task, denied_task = tasks_fixture
|
||||
self._set_task_kwargs(
|
||||
allowed_task,
|
||||
{"tenant_id": str(tenant.id), "provider_id": str(allowed_provider.id)},
|
||||
)
|
||||
self._set_task_name(denied_task, "provider-deletion")
|
||||
self._set_task_kwargs(
|
||||
denied_task,
|
||||
{"tenant_id": str(tenant.id), "provider_id": str(denied_provider.id)},
|
||||
)
|
||||
provider_group = ProviderGroup.objects.create(
|
||||
name="limited-task-group", tenant_id=tenant.id
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider_group=provider_group,
|
||||
provider=allowed_provider,
|
||||
)
|
||||
RoleProviderGroupRelationship.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
role=limited_user.roles.first(),
|
||||
provider_group=provider_group,
|
||||
)
|
||||
|
||||
response = client.get(reverse("task-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in response.json()["data"]] == [
|
||||
str(allowed_task.id)
|
||||
]
|
||||
|
||||
response = client.get(reverse("task-detail", kwargs={"pk": denied_task.id}))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": denied_task.id}))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_of_soft_deleted_provider_stay_visible_to_its_groups(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_for_tenant_factory,
|
||||
tenants_fixture,
|
||||
tasks_fixture,
|
||||
aws_provider_pair,
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider, _ = aws_provider_pair
|
||||
finished_task, pending_task = tasks_fixture
|
||||
client = self._client_with_role(
|
||||
tenant,
|
||||
authenticated_client_for_tenant_factory,
|
||||
manage_providers=True,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
provider_group = ProviderGroup.objects.create(
|
||||
name="own-group", tenant_id=tenant.id
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=tenant.id, provider_group=provider_group, provider=provider
|
||||
)
|
||||
RoleProviderGroupRelationship.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
role=client.user.roles.first(),
|
||||
provider_group=provider_group,
|
||||
)
|
||||
for task, name in (
|
||||
(finished_task, "provider-deletion"),
|
||||
(pending_task, "provider-connection-check"),
|
||||
):
|
||||
self._set_task_name(task, name)
|
||||
self._set_task_kwargs(
|
||||
task, {"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
|
||||
)
|
||||
provider.is_deleted = True
|
||||
provider.save()
|
||||
|
||||
response = client.get(reverse("task-detail", kwargs={"pk": finished_task.id}))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_async_result.return_value.revoke.assert_called_once()
|
||||
|
||||
def test_tasks_without_provider_stay_visible_for_limited_roles(
|
||||
self, authenticated_client_no_permissions_rbac, tasks_fixture, aws_provider_pair
|
||||
):
|
||||
response = authenticated_client_no_permissions_rbac.get(reverse("task-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(tasks_fixture)
|
||||
|
||||
def test_tasks_list_without_role_is_forbidden(
|
||||
self, authenticated_client_rbac_noroles, tasks_fixture
|
||||
):
|
||||
response = authenticated_client_rbac_noroles.get(reverse("task-list"))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_tasks_revoke_without_permission_hides_task_status(
|
||||
self, authenticated_client_no_permissions_rbac, tasks_fixture
|
||||
):
|
||||
finished_task, _ = tasks_fixture
|
||||
self._set_task_name(finished_task, "provider-connection-check")
|
||||
|
||||
response = authenticated_client_no_permissions_rbac.delete(
|
||||
reverse("task-detail", kwargs={"pk": finished_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_unauthenticated_returns_401(
|
||||
self, mock_async_result, tasks_fixture
|
||||
):
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "scan-perform")
|
||||
|
||||
response = APIClient().delete(
|
||||
reverse("task-detail", kwargs={"pk": pending_task.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.AsyncResult")
|
||||
def test_tasks_revoke_foreign_tenant_task_returns_404(
|
||||
self,
|
||||
mock_async_result,
|
||||
authenticated_client_for_tenant_factory,
|
||||
tenants_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
_, foreign_tenant, *_ = tenants_fixture
|
||||
_, pending_task = tasks_fixture
|
||||
self._set_task_name(pending_task, "scan-perform")
|
||||
client = self._client_with_role(
|
||||
foreign_tenant, authenticated_client_for_tenant_factory, manage_scans=True
|
||||
)
|
||||
|
||||
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_async_result.return_value.revoke.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAttackPathsScanViewSet:
|
||||
@@ -8784,6 +9168,190 @@ class TestInvitationViewSet:
|
||||
user.id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invitation_create_payload(email, role):
|
||||
return json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "invitations",
|
||||
"attributes": {"email": email},
|
||||
"relationships": {
|
||||
"roles": {"data": [{"type": "roles", "id": str(role.id)}]}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_lapsed_invitation(email, tenant, inviter):
|
||||
return Invitation.objects.create(
|
||||
email=email,
|
||||
state=Invitation.State.PENDING,
|
||||
expires_at=datetime.now(UTC) - timedelta(days=1),
|
||||
inviter=inviter,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
def test_invitations_create_with_lapsed_pending_invitation_for_same_email(
|
||||
self,
|
||||
authenticated_client,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
invitations_fixture,
|
||||
roles_fixture,
|
||||
):
|
||||
lapsed_invitation, expired_invitation = invitations_fixture
|
||||
lapsed_invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
|
||||
lapsed_invitation.save()
|
||||
other_email_lapsed_invitation = self._create_lapsed_invitation(
|
||||
"other@prowler.com", tenants_fixture[0], create_test_user
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-list"),
|
||||
data=self._invitation_create_payload(
|
||||
lapsed_invitation.email, roles_fixture[0]
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
new_invitation = Invitation.objects.get(id=response.json()["data"]["id"])
|
||||
assert new_invitation.email == lapsed_invitation.email
|
||||
assert new_invitation.state == Invitation.State.PENDING
|
||||
lapsed_invitation.refresh_from_db()
|
||||
assert lapsed_invitation.state == Invitation.State.EXPIRED
|
||||
expired_invitation.refresh_from_db()
|
||||
assert expired_invitation.state == Invitation.State.EXPIRED
|
||||
other_email_lapsed_invitation.refresh_from_db()
|
||||
assert other_email_lapsed_invitation.state == Invitation.State.PENDING
|
||||
|
||||
def test_invitations_create_with_active_pending_invitation_for_same_email(
|
||||
self,
|
||||
authenticated_client,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
invitations_fixture,
|
||||
roles_fixture,
|
||||
):
|
||||
active_invitation, _ = invitations_fixture
|
||||
self._create_lapsed_invitation(
|
||||
active_invitation.email, tenants_fixture[0], create_test_user
|
||||
)
|
||||
invitation_count = Invitation.objects.count()
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-list"),
|
||||
data=self._invitation_create_payload(
|
||||
active_invitation.email, roles_fixture[0]
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert (
|
||||
response.json()["errors"][0]["source"]["pointer"]
|
||||
== "/data/attributes/email"
|
||||
)
|
||||
assert Invitation.objects.count() == invitation_count
|
||||
active_invitation.refresh_from_db()
|
||||
assert active_invitation.state == Invitation.State.PENDING
|
||||
|
||||
def test_invitations_create_ignores_pending_invitations_from_other_tenants(
|
||||
self, authenticated_client, create_test_user, tenants_fixture, roles_fixture
|
||||
):
|
||||
email = "cross_tenant@prowler.com"
|
||||
other_tenant = tenants_fixture[1]
|
||||
other_tenant_lapsed_invitation = self._create_lapsed_invitation(
|
||||
email, other_tenant, create_test_user
|
||||
)
|
||||
Invitation.objects.create(
|
||||
email=email, inviter=create_test_user, tenant=other_tenant
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("invitation-list"),
|
||||
data=self._invitation_create_payload(email, roles_fixture[0]),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
other_tenant_lapsed_invitation.refresh_from_db()
|
||||
assert other_tenant_lapsed_invitation.state == Invitation.State.PENDING
|
||||
|
||||
def test_invitations_report_lapsed_pending_invitation_as_expired(
|
||||
self,
|
||||
authenticated_client,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
invitations_fixture,
|
||||
):
|
||||
active_invitation, expired_invitation = invitations_fixture
|
||||
lapsed_invitation = self._create_lapsed_invitation(
|
||||
"lapsed@prowler.com", tenants_fixture[0], create_test_user
|
||||
)
|
||||
|
||||
list_response = authenticated_client.get(reverse("invitation-list"))
|
||||
retrieve_response = authenticated_client.get(
|
||||
reverse("invitation-detail", kwargs={"pk": lapsed_invitation.id})
|
||||
)
|
||||
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
assert retrieve_response.status_code == status.HTTP_200_OK
|
||||
assert {
|
||||
invitation["id"]: invitation["attributes"]["state"]
|
||||
for invitation in list_response.json()["data"]
|
||||
} == {
|
||||
str(active_invitation.id): Invitation.State.PENDING.value,
|
||||
str(expired_invitation.id): Invitation.State.EXPIRED.value,
|
||||
str(lapsed_invitation.id): Invitation.State.EXPIRED.value,
|
||||
}
|
||||
assert (
|
||||
retrieve_response.json()["data"]["attributes"]["state"]
|
||||
== Invitation.State.EXPIRED.value
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_invitations",
|
||||
[
|
||||
("state", "pending", {"active"}),
|
||||
("state", "expired", {"expired", "lapsed"}),
|
||||
("state", "accepted", set()),
|
||||
("state__in", "pending", {"active"}),
|
||||
("state__in", "expired", {"expired", "lapsed"}),
|
||||
("state__in", "pending,expired", {"active", "expired", "lapsed"}),
|
||||
("state__in", "accepted,revoked", set()),
|
||||
],
|
||||
)
|
||||
def test_invitations_filter_state_treats_lapsed_pending_as_expired(
|
||||
self,
|
||||
authenticated_client,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
invitations_fixture,
|
||||
filter_name,
|
||||
filter_value,
|
||||
expected_invitations,
|
||||
):
|
||||
active_invitation, expired_invitation = invitations_fixture
|
||||
lapsed_invitation = self._create_lapsed_invitation(
|
||||
"lapsed@prowler.com", tenants_fixture[0], create_test_user
|
||||
)
|
||||
invitation_ids = {
|
||||
"active": str(active_invitation.id),
|
||||
"expired": str(expired_invitation.id),
|
||||
"lapsed": str(lapsed_invitation.id),
|
||||
}
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("invitation-list"), {f"filter[{filter_name}]": filter_value}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert {invitation["id"] for invitation in response.json()["data"]} == {
|
||||
invitation_ids[name] for name in expected_invitations
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email",
|
||||
[
|
||||
@@ -8791,8 +9359,10 @@ class TestInvitationViewSet:
|
||||
"invalid_email@",
|
||||
# There is a pending invitation with this email
|
||||
"testing@prowler.com",
|
||||
"TESTING@prowler.com",
|
||||
# User is already a member of the tenant
|
||||
TEST_USER,
|
||||
TEST_USER.upper(),
|
||||
],
|
||||
)
|
||||
def test_invitations_create_invalid_email(
|
||||
@@ -9047,6 +9617,56 @@ class TestInvitationViewSet:
|
||||
== "This invitation cannot be revoked."
|
||||
)
|
||||
|
||||
def test_invitations_delete_lapsed_invitation(
|
||||
self, authenticated_client, invitations_fixture
|
||||
):
|
||||
invitation, *_ = invitations_fixture
|
||||
invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
|
||||
invitation.save()
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse("invitation-detail", kwargs={"pk": str(invitation.id)})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert (
|
||||
response.json()["errors"][0]["detail"]
|
||||
== "This invitation cannot be revoked."
|
||||
)
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.state == Invitation.State.PENDING
|
||||
|
||||
def test_invitations_partial_update_lapsed_invitation(
|
||||
self, authenticated_client, invitations_fixture
|
||||
):
|
||||
invitation, *_ = invitations_fixture
|
||||
invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
|
||||
invitation.save()
|
||||
data = {
|
||||
"data": {
|
||||
"id": str(invitation.id),
|
||||
"type": "invitations",
|
||||
"attributes": {
|
||||
"email": invitation.email,
|
||||
"expires_at": self.TOMORROW_ISO,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("invitation-detail", kwargs={"pk": str(invitation.id)}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert (
|
||||
response.json()["errors"][0]["detail"]
|
||||
== "This invitation cannot be updated."
|
||||
)
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.is_lapsed
|
||||
|
||||
def test_invitations_accept_invitation_new_user(self, client, invitations_fixture):
|
||||
invitation, *_ = invitations_fixture
|
||||
|
||||
@@ -18333,19 +18953,14 @@ class TestMuteRuleViewSet:
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
|
||||
|
||||
@patch("api.v1.views.chain")
|
||||
@patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
|
||||
@patch("api.v1.views.mute_historical_findings_task.si")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
|
||||
def test_mute_rules_create_valid(
|
||||
self,
|
||||
_mock_on_commit,
|
||||
mock_mute_signature,
|
||||
mock_reaggregate_signature,
|
||||
mock_chain,
|
||||
mock_mute_task,
|
||||
authenticated_client,
|
||||
findings_fixture,
|
||||
create_test_user,
|
||||
):
|
||||
"""Test creating a valid mute rule."""
|
||||
finding_ids = [str(findings_fixture[0].id)]
|
||||
@@ -18372,24 +18987,20 @@ class TestMuteRuleViewSet:
|
||||
assert response_data["attributes"]["name"] == "New Mute Rule"
|
||||
assert response_data["attributes"]["reason"] == "Security exception approved"
|
||||
|
||||
# Verify the finding was immediately muted
|
||||
from api.models import Finding
|
||||
|
||||
finding = Finding.objects.get(id=findings_fixture[0].id)
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at is not None
|
||||
assert finding.muted_reason == "Security exception approved"
|
||||
assert finding.muted is False
|
||||
assert finding.muted_at is None
|
||||
assert finding.muted_reason is None
|
||||
|
||||
# Verify background task chain was called: mute → reaggregate all
|
||||
mock_mute_signature.assert_called_once()
|
||||
mock_reaggregate_signature.assert_called_once()
|
||||
mock_chain.assert_called_once_with(
|
||||
mock_mute_signature.return_value,
|
||||
mock_reaggregate_signature.return_value,
|
||||
mock_mute_task.assert_called_once_with(
|
||||
kwargs={
|
||||
"tenant_id": str(finding.tenant_id),
|
||||
"mute_rule_id": response_data["id"],
|
||||
"provider_ids": [str(finding.scan.provider_id)],
|
||||
}
|
||||
)
|
||||
mock_chain.return_value.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_converts_finding_ids_to_uids(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18425,7 +19036,7 @@ class TestMuteRuleViewSet:
|
||||
]
|
||||
assert set(mute_rule.finding_uids) == set(expected_uids)
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_deduplicates_uids(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18492,10 +19103,10 @@ class TestMuteRuleViewSet:
|
||||
|
||||
finding1.refresh_from_db()
|
||||
finding2.refresh_from_db()
|
||||
assert finding1.muted is True
|
||||
assert finding2.muted is True
|
||||
assert finding1.muted is False
|
||||
assert finding2.muted is False
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_overlap_detection_active(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18528,7 +19139,7 @@ class TestMuteRuleViewSet:
|
||||
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
|
||||
)
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_no_overlap_with_inactive(
|
||||
self,
|
||||
mock_task,
|
||||
@@ -18584,7 +19195,7 @@ class TestMuteRuleViewSet:
|
||||
== "/data/attributes/finding_ids"
|
||||
)
|
||||
|
||||
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
|
||||
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
|
||||
def test_mute_rules_create_invalid_finding_ids(
|
||||
self, mock_task, authenticated_client
|
||||
):
|
||||
|
||||
@@ -302,17 +302,26 @@ def get_prowler_provider_kwargs(
|
||||
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into SDK provider kwargs."""
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
|
||||
if home_region:
|
||||
prowler_provider_kwargs["home_region"] = home_region
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _oraclecloud_home_region(region) -> str | None:
|
||||
"""Return the stored OCI region as a home region, ignoring blank or non-string legacy values."""
|
||||
if isinstance(region, str) and region.strip():
|
||||
return region.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into test_connection kwargs."""
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
|
||||
|
||||
if (
|
||||
prowler_provider_kwargs.get("user")
|
||||
@@ -323,11 +332,9 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
or prowler_provider_kwargs.get("key_file")
|
||||
)
|
||||
):
|
||||
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
|
||||
prowler_provider_kwargs["region"] = getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
# Identity calls only succeed in a region the tenancy is subscribed to.
|
||||
prowler_provider_kwargs["region"] = (
|
||||
home_region or OraclecloudProvider._bootstrap_region
|
||||
)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
@@ -301,8 +301,7 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"deprecated": True,
|
||||
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
|
||||
"description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.",
|
||||
},
|
||||
},
|
||||
"required": ["user", "fingerprint", "tenancy"],
|
||||
|
||||
@@ -71,6 +71,7 @@ from django.db import IntegrityError, transaction
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from jwt.exceptions import InvalidKeyError
|
||||
from prowler.lib.mutelist.mutelist import Mutelist
|
||||
from prowler.providers.oraclecloud.config import OCI_REGIONS
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
from rest_framework_json_api import serializers
|
||||
@@ -1917,9 +1918,16 @@ class IacProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class LegacyOCIRegionField(serializers.Field):
|
||||
class OCIHomeRegionField(serializers.Field):
|
||||
"""Optional OCI home region; blank or non-string legacy values are dropped."""
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
if not isinstance(data, str) or not data.strip():
|
||||
return None
|
||||
region = data.strip()
|
||||
if region not in OCI_REGIONS:
|
||||
raise serializers.ValidationError(f"Invalid OCI region: {region}")
|
||||
return region
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
@@ -1932,10 +1940,11 @@ class OracleCloudProviderSecret(serializers.Serializer):
|
||||
key_content = serializers.CharField(required=False)
|
||||
tenancy = serializers.CharField()
|
||||
pass_phrase = serializers.CharField(required=False)
|
||||
region = LegacyOCIRegionField(required=False, allow_null=True)
|
||||
region = OCIHomeRegionField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs.pop("region", None)
|
||||
if not attrs.get("region"):
|
||||
attrs.pop("region", None)
|
||||
|
||||
if "key_file" not in attrs and "key_content" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
@@ -2149,6 +2158,12 @@ class InvitationSerializer(RLSSerializer):
|
||||
if tenant_id is not None:
|
||||
self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id)
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if instance.is_lapsed:
|
||||
data["state"] = Invitation.State.EXPIRED.value
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = Invitation
|
||||
fields = [
|
||||
@@ -2175,6 +2190,7 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer):
|
||||
self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id)
|
||||
|
||||
def validate_email(self, value):
|
||||
value = value.strip().lower()
|
||||
user = User.objects.filter(email=value).first()
|
||||
tenant_id = self.context["tenant_id"]
|
||||
if user and Membership.objects.filter(user=user, tenant=tenant_id).exists():
|
||||
@@ -2182,9 +2198,13 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer):
|
||||
"The user may already be a member of the tenant or there was an issue with the "
|
||||
"email provided."
|
||||
)
|
||||
if Invitation.objects.filter(
|
||||
email=value, state=Invitation.State.PENDING
|
||||
).exists():
|
||||
pending_invitations = Invitation.objects.filter(
|
||||
tenant_id=tenant_id, email=value, state=Invitation.State.PENDING
|
||||
)
|
||||
pending_invitations.filter(Invitation.lapsed_q()).update(
|
||||
state=Invitation.State.EXPIRED
|
||||
)
|
||||
if pending_invitations.filter(expires_at__gt=datetime.now(UTC)).exists():
|
||||
raise ValidationError(
|
||||
"Unable to process your request. Please check the information provided and "
|
||||
"try again."
|
||||
|
||||
@@ -125,10 +125,14 @@ from api.models import (
|
||||
)
|
||||
from api.pagination import ComplianceOverviewPagination
|
||||
from api.rbac.permissions import (
|
||||
TASK_REVOKE_PERMISSIONS,
|
||||
Permissions,
|
||||
get_integrations,
|
||||
get_providers,
|
||||
get_role,
|
||||
get_tasks,
|
||||
get_user_roles,
|
||||
roles_have_permissions,
|
||||
)
|
||||
from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.rls import Tenant
|
||||
@@ -244,7 +248,6 @@ from api.v1.serializers import (
|
||||
UserUpdateSerializer,
|
||||
)
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery import chain
|
||||
from celery.result import AsyncResult
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -327,7 +330,7 @@ from rest_framework_simplejwt.token_blacklist.models import (
|
||||
)
|
||||
from tasks.beat import schedule_provider_scan
|
||||
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
|
||||
from tasks.jobs.export import get_s3_client
|
||||
from tasks.jobs.export import get_s3_client, get_s3_presign_client
|
||||
from tasks.tasks import (
|
||||
QUEUED_SCAN_TASK_STATE,
|
||||
backfill_compliance_summaries_task,
|
||||
@@ -342,8 +345,7 @@ from tasks.tasks import (
|
||||
enqueue_scan_execution_on_commit,
|
||||
get_active_provider_scan,
|
||||
jira_integration_task,
|
||||
mute_historical_findings_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
)
|
||||
|
||||
@@ -2409,7 +2411,8 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
}
|
||||
if content_type:
|
||||
params["ResponseContentType"] = content_type
|
||||
url = client.generate_presigned_url(
|
||||
# The browser follows this URL, so it is signed against the public host.
|
||||
url = (get_s3_presign_client() or client).generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=300,
|
||||
@@ -2823,6 +2826,15 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
tenant_id=self.request.tenant_id,
|
||||
task_id=pre_task_id,
|
||||
task_status=(QUEUED_SCAN_TASK_STATE if active_scan else None),
|
||||
# This response is serialized before the on_commit publish,
|
||||
# so without these the caller gets a task id and no scan id.
|
||||
# Kept in step with what `enqueue_scan_execution_on_commit`
|
||||
# publishes below.
|
||||
task_kwargs={
|
||||
"tenant_id": str(self.request.tenant_id),
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
},
|
||||
)
|
||||
|
||||
if not active_scan:
|
||||
@@ -2850,17 +2862,29 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
list=extend_schema(
|
||||
tags=["Task"],
|
||||
summary="List all tasks",
|
||||
description="Retrieve a list of all tasks with options for filtering by name, state, and other criteria.",
|
||||
description=(
|
||||
"Retrieve a list of all tasks with options for filtering by name, state, and other "
|
||||
"criteria. Tasks that reference a provider are only returned when the role can "
|
||||
"access it; tasks without a provider reference are returned for every role."
|
||||
),
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["Task"],
|
||||
summary="Retrieve data from a specific task",
|
||||
description="Fetch detailed information about a specific task by its ID.",
|
||||
description=(
|
||||
"Fetch detailed information about a specific task by its ID. Tasks tied to a provider "
|
||||
"outside the visibility of the role are not found."
|
||||
),
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["Task"],
|
||||
summary="Revoke a task",
|
||||
description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.",
|
||||
description=(
|
||||
"Try to revoke a task using its ID. Only tasks that are not yet in progress can be "
|
||||
"revoked, and the caller needs the same permission as the operation that queued "
|
||||
"the task (for example MANAGE_SCANS for a scan). Provider deletions cannot be "
|
||||
"revoked."
|
||||
),
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
),
|
||||
)
|
||||
@@ -2876,13 +2900,26 @@ class TaskViewSet(BaseRLSViewSet):
|
||||
required_permissions = []
|
||||
|
||||
def get_queryset(self):
|
||||
return Task.objects.annotate(
|
||||
name=F("task_runner_task__task_name"),
|
||||
state=F("task_runner_task__status"),
|
||||
).select_related("task_runner_task")
|
||||
return (
|
||||
get_tasks(self.user_role)
|
||||
.annotate(
|
||||
name=F("task_runner_task__task_name"),
|
||||
state=F("task_runner_task__status"),
|
||||
)
|
||||
.select_related("task_runner_task")
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, pk=None, **kwargs):
|
||||
task = get_object_or_404(Task, pk=pk)
|
||||
task = self.get_object()
|
||||
required_permissions = TASK_REVOKE_PERMISSIONS.get(
|
||||
task.task_runner_task.task_name
|
||||
)
|
||||
# Same multi-role semantics as HasPermissions.
|
||||
if required_permissions is None or not roles_have_permissions(
|
||||
get_user_roles(request.user, request.tenant_id), required_permissions
|
||||
):
|
||||
raise PermissionDenied("You do not have permission to revoke this task.")
|
||||
|
||||
if task.task_runner_task.status not in ["PENDING", "RECEIVED"]:
|
||||
serializer = TaskSerializer(task)
|
||||
return Response(
|
||||
@@ -3479,12 +3516,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
latest_scans = (
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
Scan.all_objects.filter(tenant_id=tenant_id)
|
||||
.latest_per_provider()
|
||||
.values("provider_id")
|
||||
)
|
||||
|
||||
@@ -3616,11 +3649,9 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
query_params = request.query_params
|
||||
|
||||
latest_scans_queryset = (
|
||||
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
)
|
||||
latest_scans_queryset = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_per_provider()
|
||||
|
||||
queryset = ResourceScanSummary.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
@@ -4207,12 +4238,9 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
latest_scan_ids = list(
|
||||
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
latest_scan_ids = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_ids_per_provider()
|
||||
filtered_queryset = filtered_queryset.filter(
|
||||
tenant_id=tenant_id, scan_id__in=latest_scan_ids
|
||||
)
|
||||
@@ -4235,11 +4263,9 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
query_params = request.query_params
|
||||
|
||||
latest_scans_queryset = (
|
||||
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
)
|
||||
latest_scans_queryset = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_per_provider()
|
||||
raw_latest_scans_ids = list(
|
||||
latest_scans_queryset.values_list("id", "unique_resource_count")
|
||||
)
|
||||
@@ -4473,7 +4499,7 @@ class InvitationViewSet(BaseRLSViewSet):
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.state != Invitation.State.PENDING:
|
||||
if instance.state != Invitation.State.PENDING or instance.is_lapsed:
|
||||
raise ValidationError(detail="This invitation cannot be updated.")
|
||||
serializer = self.get_serializer(
|
||||
instance,
|
||||
@@ -4487,7 +4513,7 @@ class InvitationViewSet(BaseRLSViewSet):
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.state != Invitation.State.PENDING:
|
||||
if instance.state != Invitation.State.PENDING or instance.is_lapsed:
|
||||
raise ValidationError(detail="This invitation cannot be revoked.")
|
||||
instance.state = Invitation.State.REVOKED
|
||||
instance.save()
|
||||
@@ -4980,11 +5006,7 @@ class ComplianceOverviewViewSet(
|
||||
if provider_filters:
|
||||
scans = scans.filter(**provider_filters)
|
||||
|
||||
return list(
|
||||
scans.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
return scans.latest_ids_per_provider()
|
||||
|
||||
def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None):
|
||||
if latest_scan_ids is None:
|
||||
@@ -5726,14 +5748,9 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
else {}
|
||||
)
|
||||
|
||||
latest_scan_ids = (
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
|
||||
)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
latest_scan_ids = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, **provider_filter
|
||||
).latest_ids_per_provider()
|
||||
|
||||
return filtered_queryset.filter(
|
||||
tenant_id=tenant_id, scan_id__in=latest_scan_ids
|
||||
@@ -5760,16 +5777,10 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
|
||||
def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None):
|
||||
provider_filter = self._get_provider_filter()
|
||||
queryset = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
|
||||
)
|
||||
queryset = Scan.all_objects.filter(tenant_id=tenant_id, **provider_filter)
|
||||
if provider_filters:
|
||||
queryset = queryset.filter(**provider_filters)
|
||||
return (
|
||||
queryset.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
return queryset.latest_ids_per_provider()
|
||||
|
||||
@action(detail=False, methods=["get"], url_name="providers")
|
||||
def providers(self, request):
|
||||
@@ -5781,14 +5792,9 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
else {}
|
||||
)
|
||||
|
||||
latest_scan_ids = (
|
||||
Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
|
||||
)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
latest_scan_ids = Scan.all_objects.filter(
|
||||
tenant_id=tenant_id, **provider_filter
|
||||
).latest_ids_per_provider()
|
||||
|
||||
findings_aggregated = (
|
||||
queryset.filter(scan_id__in=latest_scan_ids)
|
||||
@@ -7551,35 +7557,28 @@ class MuteRuleViewSet(BaseRLSViewSet):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Create the mute rule
|
||||
tenant_id = str(request.tenant_id)
|
||||
finding_ids = serializer.validated_data["finding_ids"]
|
||||
provider_ids = list(
|
||||
dict.fromkeys(
|
||||
Finding.all_objects.filter(
|
||||
id__in=finding_ids, tenant_id=tenant_id
|
||||
).values_list("scan__provider_id", flat=True)
|
||||
)
|
||||
)
|
||||
|
||||
mute_rule = serializer.save()
|
||||
|
||||
tenant_id = str(request.tenant_id)
|
||||
finding_ids = request.data.get("finding_ids", [])
|
||||
|
||||
# Immediately mute the selected findings
|
||||
Finding.all_objects.filter(
|
||||
id__in=finding_ids, tenant_id=tenant_id, muted=False
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=mute_rule.inserted_at,
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
|
||||
# Launch background task for historical muting + reaggregation
|
||||
transaction.on_commit(
|
||||
lambda: chain(
|
||||
mute_historical_findings_task.si(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=str(mute_rule.id),
|
||||
),
|
||||
reaggregate_all_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id,
|
||||
),
|
||||
).apply_async()
|
||||
lambda: mute_findings_in_latest_scans_task.apply_async(
|
||||
kwargs={
|
||||
"tenant_id": tenant_id,
|
||||
"mute_rule_id": str(mute_rule.id),
|
||||
"provider_ids": [str(provider_id) for provider_id in provider_ids],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Return the created mute rule
|
||||
serializer = self.get_serializer(mute_rule)
|
||||
return Response(
|
||||
data=serializer.data,
|
||||
@@ -7942,18 +7941,13 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
|
||||
|
||||
def _get_latest_findings_per_provider(self, filtered_queryset):
|
||||
"""Keep only findings from each provider's most recent completed scan."""
|
||||
# Materialize to a literal IN list. Left as a subquery, Postgres can't
|
||||
# estimate the match count and picks a serial nested loop on
|
||||
# resource_finding_mappings when one scan dominates findings
|
||||
latest_scan_ids = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=self.request.tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
# `latest_ids_per_provider` materializes to a literal IN list.
|
||||
# Left as a subquery, Postgres can't estimate the match count and picks
|
||||
# a serial nested loop on resource_finding_mappings when one scan
|
||||
# dominates findings
|
||||
latest_scan_ids = Scan.objects.filter(
|
||||
tenant_id=self.request.tenant_id
|
||||
).latest_ids_per_provider()
|
||||
return filtered_queryset.filter(scan_id__in=latest_scan_ids)
|
||||
|
||||
def _post_process_aggregation(self, aggregated_data):
|
||||
@@ -8899,16 +8893,14 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
queryset = self._get_finding_queryset()
|
||||
|
||||
# Order by -completed_at (matching the /latest summary path and the
|
||||
# daily summary upsert keyed on midnight(completed_at)) so that
|
||||
# overlapping scans do not make /resources and /latest read from
|
||||
# different scans and report diverging counts.
|
||||
latest_scan_ids = (
|
||||
Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
# The shared selector orders by -completed_at (matching the /latest
|
||||
# summary path and the daily summary upsert keyed on
|
||||
# midnight(completed_at)) so that overlapping scans do not make
|
||||
# /resources and /latest read from different scans and report
|
||||
# diverging counts.
|
||||
latest_scan_ids = Scan.objects.filter(
|
||||
tenant_id=tenant_id
|
||||
).latest_ids_per_provider()
|
||||
|
||||
normalized_params = self._normalize_jsonapi_params(request.query_params)
|
||||
# Remove date filters since we're using latest
|
||||
|
||||
@@ -231,6 +231,62 @@ LOGGING = {
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
# Celery loggers must be declared explicitly because
|
||||
# disable_existing_loggers=True silences any logger that exists at
|
||||
# dictConfig time but is not named here. Without these, fatal worker
|
||||
# errors (e.g. celery.worker CRITICAL) produce no output.
|
||||
# "celery" must keep propagating: get_task_logger() parents task
|
||||
# loggers under celery.task, so blocking here hides them from root.
|
||||
"celery": {
|
||||
"level": LEVEL,
|
||||
"propagate": True,
|
||||
},
|
||||
"celery.worker": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker.consumer": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker.consumer.consumer": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"kombu": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"kombu.transport.redis": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"billiard": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"amqp": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
# WARNING keeps task failures but skips one "succeeded" line per task.
|
||||
"celery.app.trace": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": "WARNING",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.beat": {
|
||||
"handlers": ["tasks_console"],
|
||||
"level": LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
# Gunicorn required configuration
|
||||
"root": {
|
||||
|
||||
@@ -7,6 +7,7 @@ from config.settings.eventstream import * # noqa
|
||||
from config.settings.partitions import * # noqa
|
||||
from config.settings.sentry import * # noqa
|
||||
from config.settings.social_login import * # noqa
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
SECRET_KEY = env("SECRET_KEY", default="secret")
|
||||
DEBUG = env.bool("DJANGO_DEBUG", default=False)
|
||||
@@ -295,6 +296,14 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY = env.str(
|
||||
)
|
||||
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN = env.str("DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN", "")
|
||||
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION = env.str("DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION", "")
|
||||
# Storage endpoint the API and Celery workers use to talk to S3-compatible object storage
|
||||
# such as MinIO. Empty means the real AWS S3 endpoint, which is unaffected.
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = env.str("DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL", "")
|
||||
# Browser-reachable storage host used to sign download URLs. Empty means sign against the
|
||||
# same endpoint the API talks to, which is what Prowler Cloud on S3 does.
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL = env.str(
|
||||
"DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL", ""
|
||||
)
|
||||
|
||||
# HTTP Security Headers
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
@@ -303,6 +312,11 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
|
||||
DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000)
|
||||
|
||||
# Public base URL of the Prowler UI (for example https://cloud.prowler.com). Used to
|
||||
# build links back to findings in outbound integrations such as Jira. Empty by
|
||||
# default, so self-hosted deployments emit no links unless they configure it.
|
||||
UI_BASE_URL = env.str("DJANGO_UI_BASE_URL", "").rstrip("/")
|
||||
|
||||
# SAML requirement
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
@@ -315,6 +329,17 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
|
||||
) # 16h
|
||||
|
||||
# Minimum age (of the scan row, or of the scan id itself when the row is gone) before
|
||||
# the periodic reaper will drop an orphaned temp Neo4j database. Keeps a scan that is
|
||||
# still legitimately in flight from ever losing its staging database mid-run.
|
||||
ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS = env.int(
|
||||
"ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS", 6
|
||||
)
|
||||
if ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS <= 0:
|
||||
raise ImproperlyConfigured(
|
||||
"ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS must be a positive number of hours"
|
||||
)
|
||||
|
||||
# Selects where the persistent attack-paths graph is stored. The scan
|
||||
# temporary database is always Neo4j; only the sink is configurable.
|
||||
# Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted).
|
||||
|
||||
@@ -193,10 +193,10 @@ def initialize_sentry():
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=sentry_dsn,
|
||||
# Add data like request headers and IP for users,
|
||||
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
|
||||
before_send=before_send,
|
||||
send_default_pii=True,
|
||||
# No user identity, IPs, cookies, headers or request bodies: bodies carry emails and provider details.
|
||||
send_default_pii=False,
|
||||
max_request_body_size="never",
|
||||
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
|
||||
_experiments={
|
||||
# Set continuous_profiling_auto_start to True
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Periodic reaper for orphaned temp Neo4j scan databases.
|
||||
|
||||
`scan.py` creates a throw-away `db-tmp-scan-<attack_paths_scan_id>` database per
|
||||
scan and drops it once the scan finishes, success or failure. When the worker
|
||||
or Neo4j itself dies mid-scan, that drop never runs and nothing else ever
|
||||
revisits the database - it sits there forever. This sweep lists every temp
|
||||
database on the ingest cluster and drops the ones whose scan is gone or has
|
||||
been finished for longer than the configured safety margin.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_router import MainRouter
|
||||
from api.models import AttackPathsScan, StateChoices
|
||||
from api.uuid_utils import datetime_from_uuid7
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS
|
||||
from uuid6 import UUID as UUID7
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
TERMINAL_STATES = (
|
||||
StateChoices.COMPLETED,
|
||||
StateChoices.FAILED,
|
||||
StateChoices.CANCELLED,
|
||||
)
|
||||
|
||||
|
||||
def reap_orphaned_tmp_databases() -> dict:
|
||||
"""Drop temp Neo4j scan databases whose scan is gone or long finished.
|
||||
|
||||
A failure listing databases aborts the whole sweep (nothing to iterate).
|
||||
A failure reaping one database is logged and skipped so the rest of the
|
||||
sweep still runs.
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
safety_margin = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS)
|
||||
|
||||
try:
|
||||
databases = graph_database.list_databases()
|
||||
except Exception:
|
||||
logger.exception("Failed to list ingest Neo4j databases for temp-db reap")
|
||||
return {"dropped_count": 0, "databases": []}
|
||||
|
||||
tmp_databases = [
|
||||
name for name in databases if name.startswith(graph_database.TEMP_DB_PREFIX)
|
||||
]
|
||||
|
||||
dropped: list[str] = []
|
||||
for database in tmp_databases:
|
||||
try:
|
||||
if _is_orphaned(database, now, safety_margin):
|
||||
graph_database.drop_database(database)
|
||||
dropped.append(database)
|
||||
logger.info(f"Dropped orphaned temp Neo4j database `{database}`")
|
||||
except Exception:
|
||||
logger.exception(f"Failed to reap temp Neo4j database `{database}`")
|
||||
|
||||
logger.info(f"Temp Neo4j database reap: {len(dropped)} dropped")
|
||||
return {"dropped_count": len(dropped), "databases": dropped}
|
||||
|
||||
|
||||
def _is_orphaned(database: str, now: datetime, safety_margin: timedelta) -> bool:
|
||||
"""Decide whether a temp database is safe to drop.
|
||||
|
||||
No scan row: the row was hard-deleted (tenant/provider cleanup) or was
|
||||
never created. Falls back to the scan id's own UUIDv7 timestamp so a
|
||||
database created moments ago is never touched even without a row to check.
|
||||
|
||||
Scan row present: only reapable once it reached a terminal state and has
|
||||
been finished for longer than the safety margin, so a scan still
|
||||
legitimately executing is never touched.
|
||||
"""
|
||||
scan_id = database[len(graph_database.TEMP_DB_PREFIX) :]
|
||||
|
||||
try:
|
||||
scan_uuid = UUID7(scan_id)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Temp database `{database}` has an unparseable scan id, skipping"
|
||||
)
|
||||
return False
|
||||
|
||||
# Global sweep with no tenant context: admin_db bypasses RLS on purpose, the same
|
||||
# way cleanup_stale_attack_paths_scans finds stale scans across every tenant.
|
||||
scan = (
|
||||
AttackPathsScan.all_objects.using(MainRouter.admin_db)
|
||||
.filter(id=scan_uuid)
|
||||
.first()
|
||||
)
|
||||
|
||||
if scan is None:
|
||||
if scan_uuid.version != 7:
|
||||
logger.warning(
|
||||
f"Temp database `{database}` has no scan row and a non-UUIDv7 id, "
|
||||
"skipping"
|
||||
)
|
||||
return False
|
||||
return now - datetime_from_uuid7(scan_uuid) >= safety_margin
|
||||
|
||||
if scan.state not in TERMINAL_STATES:
|
||||
return False
|
||||
|
||||
# `mark_scan_finished` does not touch `updated_at`, so prefer `completed_at`
|
||||
finished_at = scan.completed_at or scan.updated_at
|
||||
return now - finished_at >= safety_margin
|
||||
@@ -499,10 +499,13 @@ def backfill_provider_compliance_scores(tenant_id: str) -> dict:
|
||||
provider_id__in=existing_providers
|
||||
)
|
||||
|
||||
# `completed_scans` keeps its own `completed_at__isnull=False`: this
|
||||
# task writes a *dated* ProviderComplianceScore row, so unlike the read
|
||||
# paths it genuinely cannot use a scan without a `completed_at`.
|
||||
scan_info = list(
|
||||
completed_scans.order_by("provider_id", "-completed_at")
|
||||
.distinct("provider_id")
|
||||
.values("id", "provider_id", "completed_at")
|
||||
completed_scans.latest_per_provider().values(
|
||||
"id", "provider_id", "completed_at"
|
||||
)
|
||||
)
|
||||
|
||||
if not scan_info:
|
||||
|
||||
@@ -13,6 +13,7 @@ from api.models import (
|
||||
Tenant,
|
||||
)
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
from tasks.jobs.queries import (
|
||||
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
|
||||
@@ -106,9 +107,19 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
try:
|
||||
if attack_paths_sink_backends:
|
||||
for sink_backend in attack_paths_sink_backends:
|
||||
sink_module.get_backend_for_name(sink_backend).drop_subgraph(
|
||||
tenant_database_name, str(pk)
|
||||
)
|
||||
try:
|
||||
backend = sink_module.get_backend_for_name(sink_backend)
|
||||
|
||||
except RuntimeError as sink_error:
|
||||
# A retired sink has no connection settings left, and no graph left to drop
|
||||
if sink_backend == settings.ATTACK_PATHS_SINK_DATABASE.lower():
|
||||
raise
|
||||
logger.warning(
|
||||
f"Skipping graph cleanup on unconfigured sink {sink_backend}: {sink_error}"
|
||||
)
|
||||
continue
|
||||
|
||||
backend.drop_subgraph(tenant_database_name, str(pk))
|
||||
else:
|
||||
graph_database.drop_subgraph(tenant_database_name, str(pk))
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import boto3
|
||||
import config.django.base as base
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Scan
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
@@ -206,15 +207,21 @@ def get_s3_client():
|
||||
This function attempts to initialize an S3 client by reading the AWS access key, secret key,
|
||||
session token, and region from environment variables. It then validates the client by listing
|
||||
available S3 buckets. If an error occurs during this process (for example, due to missing or
|
||||
invalid credentials), it falls back to creating an S3 client without explicitly provided credentials,
|
||||
which may rely on other configuration sources (e.g., IAM roles).
|
||||
invalid credentials), it falls back to creating an S3 client without explicitly provided
|
||||
credentials, which may rely on other configuration sources (e.g., IAM roles).
|
||||
|
||||
That fallback is only safe when no explicit endpoint is configured: with an endpoint set, the
|
||||
explicit client already targets the intended S3-compatible storage, and the fallback client
|
||||
would go to the AWS default provider chain instead, an unrelated real-AWS account reachable
|
||||
from the host. So when an endpoint is configured, the original error propagates instead.
|
||||
|
||||
Returns:
|
||||
boto3.client: A configured S3 client instance.
|
||||
|
||||
Raises:
|
||||
ClientError, NoCredentialsError, or ParamValidationError if both attempts to create a client fail.
|
||||
ClientError, NoCredentialsError, or ParamValidationError if the client cannot be created.
|
||||
"""
|
||||
endpoint = settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL
|
||||
s3_client = None
|
||||
try:
|
||||
s3_client = boto3.client(
|
||||
@@ -222,16 +229,68 @@ def get_s3_client():
|
||||
aws_access_key_id=settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY,
|
||||
aws_session_token=settings.DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN,
|
||||
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION,
|
||||
# Storage that has no meaningful region, MinIO among it, is usually configured
|
||||
# without one, and botocore rejects an empty region before any request is made.
|
||||
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1",
|
||||
endpoint_url=endpoint or None,
|
||||
)
|
||||
s3_client.list_buckets()
|
||||
except (ClientError, NoCredentialsError, ParamValidationError, ValueError):
|
||||
if endpoint:
|
||||
raise
|
||||
s3_client = boto3.client("s3")
|
||||
s3_client.list_buckets()
|
||||
|
||||
return s3_client
|
||||
|
||||
|
||||
def get_s3_presign_client():
|
||||
"""Return a client that signs download URLs with SigV4.
|
||||
|
||||
It is used when a public or internal storage host is configured, or when the bucket's
|
||||
region is: boto3 otherwise presigns S3 URLs with SigV2, which S3 rejects for SSE-KMS
|
||||
objects. None means none of those is set and the caller should presign with its own
|
||||
client, which leaves those deployments with the URL they get today.
|
||||
|
||||
The public endpoint wins when both are set: the internal endpoint may only be reachable
|
||||
from inside the cluster, and a URL signed against it would not open in a browser.
|
||||
"""
|
||||
endpoint = (
|
||||
settings.DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL
|
||||
or settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL
|
||||
)
|
||||
if not endpoint and not settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION:
|
||||
return None
|
||||
|
||||
# Blank keys are signed as-is (empty credential scope) instead of deferring to the
|
||||
# provider chain, so static credentials are only passed when they are set.
|
||||
credentials = {}
|
||||
if (
|
||||
settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID
|
||||
and settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY
|
||||
):
|
||||
credentials = {
|
||||
"aws_access_key_id": settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID,
|
||||
"aws_secret_access_key": settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY,
|
||||
# An empty string is a token as far as botocore is concerned: it appends an
|
||||
# empty X-Amz-Security-Token that storage counts when it recomputes the signature.
|
||||
"aws_session_token": settings.DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN or None,
|
||||
}
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
**credentials,
|
||||
# SigV4 puts the region in the credential scope, and MinIO answers to us-east-1
|
||||
# unless it was told otherwise, so an empty region would sign an unusable URL.
|
||||
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1",
|
||||
endpoint_url=endpoint or None,
|
||||
# The signature covers the host, so the addressing style has to be pinned rather
|
||||
# than guessed from the endpoint: MinIO serves path-style, and on AWS it keeps the
|
||||
# regional host instead of the global one, which redirects for new buckets.
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
def _upload_to_s3(
|
||||
tenant_id: str, scan_id: str, local_path: str, relative_key: str
|
||||
) -> str | None:
|
||||
|
||||
@@ -2,13 +2,16 @@ import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from glob import glob
|
||||
from urllib.parse import quote
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
|
||||
from api.models import Finding, Integration, Provider
|
||||
from api.rls import Tenant
|
||||
from api.utils import initialize_prowler_integration, initialize_prowler_provider
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError
|
||||
from prowler.lib.outputs.asff.asff import ASFF
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
@@ -16,6 +19,7 @@ from prowler.lib.outputs.csv.csv import CSV
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException
|
||||
from prowler.lib.outputs.jira.jira import Jira
|
||||
from prowler.lib.outputs.ocsf.ocsf import OCSF
|
||||
from prowler.providers.aws.aws_provider import AwsProvider
|
||||
from prowler.providers.aws.lib.s3.s3 import S3
|
||||
@@ -477,6 +481,55 @@ def upload_security_hub_integration(
|
||||
return False
|
||||
|
||||
|
||||
JIRA_LABEL_PREFIX = "prowler"
|
||||
|
||||
|
||||
def build_jira_finding_url(finding_uid: str) -> str:
|
||||
"""Build the Prowler UI link for a finding, or "" when no UI base URL is set.
|
||||
|
||||
The link filters by the finding ``uid`` rather than the per-scan record id so
|
||||
it keeps resolving after the finding is seen again in later scans.
|
||||
"""
|
||||
base_url = getattr(settings, "UI_BASE_URL", "")
|
||||
if not base_url or not finding_uid:
|
||||
return ""
|
||||
return f"{base_url}/findings?filter[uid]={quote(finding_uid, safe='')}"
|
||||
|
||||
|
||||
def build_jira_issue_labels(
|
||||
finding_uid: str, provider: str, severity: str, check_id: str
|
||||
) -> list[str]:
|
||||
"""Build the deterministic label set written to every Jira issue.
|
||||
|
||||
Labels are prefixed to avoid colliding with customer labels and sanitized so
|
||||
Jira never rejects them; the finding-uid label is what lets a ticket be traced
|
||||
back (or JQL-filtered) to its finding.
|
||||
"""
|
||||
raw_labels = [
|
||||
JIRA_LABEL_PREFIX,
|
||||
f"{JIRA_LABEL_PREFIX}-{provider}" if provider else "",
|
||||
f"{JIRA_LABEL_PREFIX}-{severity}" if severity else "",
|
||||
f"{JIRA_LABEL_PREFIX}-{check_id}" if check_id else "",
|
||||
Jira.build_finding_label(finding_uid),
|
||||
]
|
||||
return Jira.sanitize_labels(raw_labels)
|
||||
|
||||
|
||||
def get_tenant_name(tenant_id: str) -> str:
|
||||
"""Return the tenant name for the Jira issue "Tenant Info" row, or "" if unknown.
|
||||
|
||||
The name is informational only, so a lookup failure must never block the send.
|
||||
"""
|
||||
try:
|
||||
return (
|
||||
Tenant.objects.filter(id=tenant_id).values_list("name", flat=True).first()
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Could not resolve tenant name for %s", tenant_id)
|
||||
return ""
|
||||
|
||||
|
||||
def send_findings_to_jira(
|
||||
tenant_id: str,
|
||||
integration_id: str,
|
||||
@@ -487,6 +540,7 @@ def send_findings_to_jira(
|
||||
with rls_transaction(tenant_id):
|
||||
integration = Integration.objects.get(id=integration_id)
|
||||
jira_integration = initialize_prowler_integration(integration)
|
||||
tenant_info = get_tenant_name(tenant_id)
|
||||
|
||||
num_tickets_created = 0
|
||||
error_messages = []
|
||||
@@ -519,6 +573,15 @@ def send_findings_to_jira(
|
||||
recommendation = remediation.get("recommendation", {})
|
||||
remediation_code = remediation.get("code", {})
|
||||
|
||||
provider_type = finding_instance.scan.provider.provider
|
||||
issue_labels = build_jira_issue_labels(
|
||||
finding_uid=finding_instance.uid,
|
||||
provider=provider_type,
|
||||
severity=finding_instance.severity,
|
||||
check_id=finding_instance.check_id,
|
||||
)
|
||||
finding_url = build_jira_finding_url(finding_instance.uid)
|
||||
|
||||
try:
|
||||
# Send the individual finding to Jira
|
||||
result = jira_integration.send_finding(
|
||||
@@ -527,7 +590,7 @@ def send_findings_to_jira(
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
provider=provider_type,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
@@ -542,6 +605,9 @@ def send_findings_to_jira(
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
issue_labels=issue_labels,
|
||||
finding_url=finding_url,
|
||||
tenant_info=tenant_info,
|
||||
)
|
||||
except JiraBaseException as error:
|
||||
error_message = error.message or JIRA_GENERIC_SEND_ERROR
|
||||
@@ -557,6 +623,11 @@ def send_findings_to_jira(
|
||||
|
||||
if result:
|
||||
num_tickets_created += 1
|
||||
logger.info(
|
||||
"Finding %s sent to Jira as %s",
|
||||
finding_id,
|
||||
result.get("key") if isinstance(result, dict) else result,
|
||||
)
|
||||
else:
|
||||
error_message = JIRA_GENERIC_SEND_ERROR
|
||||
logger.error(error_message)
|
||||
|
||||
@@ -1,63 +1,96 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, MuteRule
|
||||
from api.models import Finding, MuteRule, Scan
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from tasks.utils import batched
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def mute_historical_findings(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Mute historical findings that match the given mute rule.
|
||||
def _mute_findings_for_rule(
|
||||
*,
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
finding_uids: Iterable[str],
|
||||
muted_at,
|
||||
muted_reason: str,
|
||||
) -> int:
|
||||
finding_uids = list(finding_uids)
|
||||
if not finding_uids:
|
||||
return 0
|
||||
|
||||
This function processes findings in batches, updating their muted status
|
||||
and adding the mute reason.
|
||||
return Finding.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
uid__in=finding_uids,
|
||||
muted=False,
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=muted_reason,
|
||||
)
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context
|
||||
mute_rule_id (str): The ID of the mute rule to apply
|
||||
|
||||
Returns:
|
||||
dict: Summary of the muting operation with findings_muted count
|
||||
"""
|
||||
findings_muted_count = 0
|
||||
def mute_findings_in_latest_scans(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
) -> dict:
|
||||
"""Apply a mute rule to the latest completed scan of each provider."""
|
||||
provider_ids = list(dict.fromkeys(provider_ids))
|
||||
|
||||
# Get the list of UIDs to mute and the reason
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
|
||||
finding_uids = mute_rule.finding_uids
|
||||
mute_reason = mute_rule.reason
|
||||
muted_at = mute_rule.inserted_at
|
||||
latest_scans = Scan.objects.filter(
|
||||
tenant_id=tenant_id, provider_id__in=provider_ids
|
||||
).latest_ids_per_provider()
|
||||
|
||||
# Query findings that match the UIDs and are not already muted
|
||||
with rls_transaction(tenant_id):
|
||||
findings_to_mute = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=finding_uids, muted=False
|
||||
)
|
||||
total_findings = findings_to_mute.count()
|
||||
|
||||
logger.info(
|
||||
f"Processing {total_findings} findings for mute rule {mute_rule_id}"
|
||||
)
|
||||
|
||||
if total_findings > 0:
|
||||
for batch, is_last in batched(
|
||||
findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE
|
||||
):
|
||||
batch_ids = [f.id for f in batch]
|
||||
updated_count = Finding.all_objects.filter(
|
||||
id__in=batch_ids, tenant_id=tenant_id
|
||||
).update(
|
||||
muted=True,
|
||||
muted_at=muted_at,
|
||||
muted_reason=mute_reason,
|
||||
)
|
||||
findings_muted_count += updated_count
|
||||
|
||||
logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}")
|
||||
changed_scan_ids = []
|
||||
findings_muted = 0
|
||||
for scan_id in latest_scans:
|
||||
updated = _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=str(scan_id),
|
||||
finding_uids=mute_rule.finding_uids,
|
||||
muted_at=mute_rule.inserted_at,
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
if updated:
|
||||
findings_muted += updated
|
||||
changed_scan_ids.append(str(scan_id))
|
||||
|
||||
logger.info(
|
||||
"Muted %d findings in %d latest scans for rule %s",
|
||||
findings_muted,
|
||||
len(changed_scan_ids),
|
||||
mute_rule_id,
|
||||
)
|
||||
return {
|
||||
"findings_muted": findings_muted_count,
|
||||
"findings_muted": findings_muted,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": changed_scan_ids,
|
||||
}
|
||||
|
||||
|
||||
def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict:
|
||||
"""Apply the current enabled mute rules to one completed scan."""
|
||||
findings_muted = 0
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values(
|
||||
"finding_uids", "reason", "inserted_at"
|
||||
)
|
||||
|
||||
for mute_rule in mute_rules:
|
||||
findings_muted += _mute_findings_for_rule(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
finding_uids=mute_rule["finding_uids"],
|
||||
muted_at=mute_rule["inserted_at"],
|
||||
muted_reason=mute_rule["reason"],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Reconciled mute rules for scan %s; muted %d findings",
|
||||
scan_id,
|
||||
findings_muted,
|
||||
)
|
||||
return {"findings_muted": findings_muted, "scan_id": str(scan_id)}
|
||||
|
||||
@@ -84,6 +84,7 @@ _SKIP_RECOVERY = {
|
||||
"scan-perform-scheduled",
|
||||
"attack-paths-scan-perform",
|
||||
"attack-paths-cleanup-stale-scans",
|
||||
"attack-paths-reap-orphaned-tmp-databases",
|
||||
"reconcile-orphan-tasks",
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import UTC, datetime
|
||||
@@ -73,6 +72,7 @@ from tasks.jobs.queries import (
|
||||
COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
|
||||
)
|
||||
from tasks.utils import CustomEncoder, batched
|
||||
from uuid6 import uuid7
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -1756,32 +1756,27 @@ def aggregate_findings(tenant_id: str, scan_id: str):
|
||||
|
||||
|
||||
def _aggregate_findings_by_region(
|
||||
tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
normalized_threatscore_id: str,
|
||||
threatscore_requirements_by_check: dict[str, list[str]],
|
||||
) -> tuple[dict, dict]:
|
||||
"""
|
||||
Aggregate findings by region using streaming, column-scoped ORM reads.
|
||||
|
||||
Reads only the consumed columns as tuples via ``values_list`` and streams
|
||||
them with ``.iterator()``, using the denormalized ``resource_regions`` array
|
||||
instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the
|
||||
regions of a finding's related resources, so it yields the same per-region
|
||||
tally without joining the resource table.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
scan_id: Scan UUID
|
||||
modeled_threatscore_compliance_id: ID for ThreatScore compliance framework
|
||||
instead of ``prefetch_related("resources")``. ThreatScore requirement ids
|
||||
are resolved per ``check_id`` from ``threatscore_requirements_by_check``.
|
||||
|
||||
Returns:
|
||||
tuple: (check_status_by_region, findings_count_by_compliance)
|
||||
- check_status_by_region: {region: {check_id: status}}
|
||||
- findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}}
|
||||
- findings_count_by_compliance: {region: {normalized_threatscore_id: {requirement_id: {total, pass}}}}
|
||||
"""
|
||||
check_status_by_region: dict = {}
|
||||
findings_count_by_compliance: dict = {}
|
||||
|
||||
normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower())
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
findings = (
|
||||
Finding.all_objects.filter(
|
||||
@@ -1790,14 +1785,12 @@ def _aggregate_findings_by_region(
|
||||
muted=False,
|
||||
status__in=["PASS", "FAIL"],
|
||||
)
|
||||
.values_list("check_id", "status", "resource_regions", "compliance")
|
||||
.values_list("check_id", "status", "resource_regions")
|
||||
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
|
||||
)
|
||||
|
||||
for check_id, status, resource_regions, compliance in findings:
|
||||
threatscore_requirements = (compliance or {}).get(
|
||||
modeled_threatscore_compliance_id
|
||||
)
|
||||
for check_id, status, resource_regions in findings:
|
||||
threatscore_requirements = threatscore_requirements_by_check.get(check_id)
|
||||
|
||||
for region in resource_regions or ():
|
||||
# Priority: FAIL > any other status
|
||||
@@ -1809,7 +1802,7 @@ def _aggregate_findings_by_region(
|
||||
if threatscore_requirements:
|
||||
compliance_key = findings_count_by_compliance.setdefault(
|
||||
region, {}
|
||||
).setdefault(normalized_id, {})
|
||||
).setdefault(normalized_threatscore_id, {})
|
||||
|
||||
for requirement_id in threatscore_requirements:
|
||||
requirement_stats = compliance_key.setdefault(
|
||||
@@ -1848,15 +1841,28 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
|
||||
provider_instance.provider
|
||||
]
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_threatscore_id = _normalized_compliance_key(
|
||||
"ProwlerThreatScore", "1.0"
|
||||
)
|
||||
|
||||
requirement_lookup: dict[str, list[tuple[str, str]]] = {}
|
||||
threatscore_requirements_by_check: dict[str, list[str]] = {}
|
||||
for compliance_id, compliance in compliance_template.items():
|
||||
is_threatscore = (
|
||||
_normalized_compliance_key(
|
||||
compliance["framework"], compliance["version"]
|
||||
)
|
||||
== normalized_threatscore_id
|
||||
)
|
||||
for requirement_id, requirement in compliance["requirements"].items():
|
||||
for check_id in requirement["checks"].keys():
|
||||
requirement_lookup.setdefault(check_id, []).append(
|
||||
(compliance_id, requirement_id)
|
||||
)
|
||||
if is_threatscore:
|
||||
threatscore_requirements_by_check.setdefault(
|
||||
check_id, []
|
||||
).append(requirement_id)
|
||||
|
||||
regions = []
|
||||
requirements_created = 0
|
||||
@@ -1869,7 +1875,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
# Aggregate findings by region using SQL for optimal performance
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_threatscore_id,
|
||||
threatscore_requirements_by_check,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1934,23 +1943,35 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
|
||||
# stays bounded; tally requirement_statuses in the same pass. The
|
||||
# ORM fallback re-iterates from scratch, so the tally resets first.
|
||||
# Region is the innermost loop so consecutive rows share the leading
|
||||
# columns of the table's secondary indexes.
|
||||
def _iter_compliance_requirement_rows():
|
||||
requirement_statuses.clear()
|
||||
for region in regions:
|
||||
region_stats = region_requirement_stats.get(region, {})
|
||||
region_findings = findings_count_by_compliance.get(region, {})
|
||||
for (
|
||||
compliance_id,
|
||||
framework,
|
||||
version,
|
||||
modeled_compliance_id,
|
||||
requirements,
|
||||
) in compliance_plan:
|
||||
compliance_stats = region_stats.get(compliance_id, {})
|
||||
compliance_findings = region_findings.get(
|
||||
modeled_compliance_id, {}
|
||||
for (
|
||||
compliance_id,
|
||||
framework,
|
||||
version,
|
||||
modeled_compliance_id,
|
||||
requirements,
|
||||
) in compliance_plan:
|
||||
stats_by_region = [
|
||||
(
|
||||
region,
|
||||
region_requirement_stats.get(region, {}).get(
|
||||
compliance_id, {}
|
||||
),
|
||||
findings_count_by_compliance.get(region, {}).get(
|
||||
modeled_compliance_id, {}
|
||||
),
|
||||
)
|
||||
for requirement_id, description, total_checks in requirements:
|
||||
for region in regions
|
||||
]
|
||||
for requirement_id, description, total_checks in requirements:
|
||||
for (
|
||||
region,
|
||||
compliance_stats,
|
||||
compliance_findings,
|
||||
) in stats_by_region:
|
||||
stats = compliance_stats.get(requirement_id)
|
||||
if stats:
|
||||
passed_checks = stats["passed_checks"]
|
||||
@@ -1981,7 +2002,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
requirement_statuses[key]["pass_count"] += 1
|
||||
|
||||
yield {
|
||||
"id": uuid.uuid4(),
|
||||
"id": uuid7(),
|
||||
"tenant_id": tenant_id_str,
|
||||
"inserted_at": utc_datetime_now,
|
||||
"compliance_id": compliance_id,
|
||||
@@ -2092,7 +2113,9 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
|
||||
.annotate(
|
||||
total=Count("id"),
|
||||
failed=Count("id", filter=Q(status="FAIL", muted=False)),
|
||||
muted=Count("id", filter=Q(status="FAIL", muted=True)),
|
||||
# Not `muted`: an annotation named after a model field comes
|
||||
# back as `muted_new` from the psqlextra queryset.
|
||||
muted_count=Count("id", filter=Q(status="FAIL", muted=True)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2103,7 +2126,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
|
||||
|
||||
aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0
|
||||
aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0
|
||||
aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0
|
||||
aggregated_counts[attack_surface_type]["muted"] += stats["muted_count"] or 0
|
||||
|
||||
overview_objects = []
|
||||
for attack_surface_type, counts in aggregated_counts.items():
|
||||
@@ -2679,20 +2702,37 @@ def reset_ephemeral_resource_findings_count(tenant_id: str, scan_id: str) -> dic
|
||||
# refreshed). Wiping based on the older scan would zero counts the newer
|
||||
# scan just set. Skip and let the newer scan's reset task do the work; if
|
||||
# this task was delayed in the queue, that's the correct outcome.
|
||||
# `completed_at__isnull=False` is required: Postgres orders NULL first in
|
||||
# DESC, so a sibling COMPLETED scan with a missing completed_at would sort
|
||||
# as "newest" and incorrectly cause us to skip.
|
||||
#
|
||||
# The comparison must be against the newest *full-scope* scan, which is
|
||||
# what this variable has always been named after but did not use to be:
|
||||
# the query filtered nothing about scope, so any newer scan that is not
|
||||
# full-scope (an imported one, for instance) made the full-scope scan
|
||||
# skip its own cleanup and leave ephemeral resources with a stale
|
||||
# failed_findings_count permanently.
|
||||
#
|
||||
# `is_full_scope()` reads `trigger` plus the scoping keys inside
|
||||
# `scanner_args`, which is not expressible as a WHERE clause, so the
|
||||
# candidates are walked newest-first in Python until the first full-scope
|
||||
# one. The walk needs no cap: `scan` is itself a full-scope candidate, so
|
||||
# it stops at `scan` at the latest, after reading only the scans newer than
|
||||
# it. A fixed window would return None once more newer scoped scans had
|
||||
# landed than it inspected, and skip the cleanup exactly like the bug above.
|
||||
#
|
||||
# NULL `completed_at` no longer needs an explicit filter here: the shared
|
||||
# ordering in `ScanQuerySet.LATEST_ORDER_BY` sorts NULLs last
|
||||
# rather than excluding them, which also fixes the case where a provider
|
||||
# whose completed scans all have a NULL `completed_at` resolved to None and
|
||||
# therefore never ran the reset at all.
|
||||
with rls_transaction(tenant_id):
|
||||
latest_full_scope_scan_id = (
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=scan.provider_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("-completed_at", "-inserted_at")
|
||||
.values_list("id", flat=True)
|
||||
.first()
|
||||
candidates = (
|
||||
Scan.objects.filter(tenant_id=tenant_id, provider_id=scan.provider_id)
|
||||
.latest_first()
|
||||
.only("id", "trigger", "scanner_args")
|
||||
.iterator(chunk_size=100)
|
||||
)
|
||||
latest_full_scope_scan_id = next(
|
||||
(candidate.id for candidate in candidates if candidate.is_full_scope()),
|
||||
None,
|
||||
)
|
||||
if latest_full_scope_scan_id != scan.id:
|
||||
logger.info(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -42,6 +43,7 @@ from tasks.jobs.attack_paths import (
|
||||
)
|
||||
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
from tasks.jobs.backfill import (
|
||||
aggregate_scan_category_summaries,
|
||||
aggregate_scan_resource_group_summaries,
|
||||
@@ -73,7 +75,10 @@ from tasks.jobs.lighthouse_providers import (
|
||||
check_lighthouse_provider_connection,
|
||||
refresh_lighthouse_provider_models,
|
||||
)
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
from tasks.jobs.orphan_recovery import reconcile_orphans
|
||||
from tasks.jobs.report import (
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
@@ -160,13 +165,28 @@ def create_scan_task_record(
|
||||
task_id: str,
|
||||
task_name: str = "scan-perform",
|
||||
task_status: str | None = states.PENDING,
|
||||
task_kwargs: dict | None = None,
|
||||
) -> Task:
|
||||
"""Pre-create the TaskResult + Task rows for a pre-generated task id.
|
||||
|
||||
Pass ``task_kwargs`` when the response built from this record is serialized
|
||||
before the broker publish. ``task_kwargs`` is otherwise only written by the
|
||||
``before_task_publish`` signal (``api/signals.py``), and the scan publish is
|
||||
deferred to ``on_commit``, so the 202 would carry an empty ``task_args`` and
|
||||
the caller would have no way to learn the scan id it was just handed a task
|
||||
for. The publish later overwrites the field with the same kwargs as a Python
|
||||
repr; both forms decode to the same dict (``decode_celery_field``).
|
||||
"""
|
||||
if task_status is None:
|
||||
task_status = states.PENDING
|
||||
|
||||
defaults = {"status": task_status, "task_name": task_name}
|
||||
if task_kwargs is not None:
|
||||
defaults["task_kwargs"] = json.dumps(task_kwargs)
|
||||
|
||||
task_result, _ = TaskResult.objects.update_or_create(
|
||||
task_id=str(task_id),
|
||||
defaults={"status": task_status, "task_name": task_name},
|
||||
defaults=defaults,
|
||||
)
|
||||
prowler_task, _ = Task.objects.update_or_create(
|
||||
id=str(task_id),
|
||||
@@ -526,6 +546,7 @@ def perform_scan_task(
|
||||
provider_id=provider_id,
|
||||
checks_to_execute=checks_to_execute,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, scan_id)
|
||||
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -635,6 +656,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
|
||||
scan_id=str(scan_instance.id),
|
||||
provider_id=provider_id,
|
||||
)
|
||||
reconcile_scan_mute_rules(tenant_id, str(scan_instance.id))
|
||||
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
|
||||
return result
|
||||
finally:
|
||||
@@ -706,6 +728,13 @@ def cleanup_stale_attack_paths_scans_task():
|
||||
return cleanup_stale_attack_paths_scans()
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="attack-paths-reap-orphaned-tmp-databases", queue="attack-paths-scans"
|
||||
)
|
||||
def reap_orphaned_attack_paths_tmp_databases_task():
|
||||
return reap_orphaned_tmp_databases()
|
||||
|
||||
|
||||
@shared_task(name="reconcile-orphan-tasks", queue="celery")
|
||||
def reconcile_orphan_tasks_task():
|
||||
"""Periodic watchdog: recover tasks whose worker is gone (deploys, crashes)."""
|
||||
@@ -1188,85 +1217,48 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
|
||||
)
|
||||
@set_tenant(keep_tenant=True)
|
||||
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
|
||||
"""Reaggregate every pre-aggregated summary table for this tenant.
|
||||
def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None:
|
||||
if not scan_ids:
|
||||
return
|
||||
|
||||
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
|
||||
rewrites every Finding row whose UID matches a mute rule, with no time
|
||||
limit. To keep the pre-aggregated tables consistent with that update,
|
||||
this task re-runs the same per-scan aggregation pipeline that scan
|
||||
completion runs on the latest completed scan of every (provider, day)
|
||||
pair, rebuilding the tables that power the read endpoints:
|
||||
|
||||
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
|
||||
`/overviews/findings-severity`, `/overviews/services`.
|
||||
- `FindingGroupDailySummary` -> `/finding-groups` and
|
||||
`/finding-groups/latest`.
|
||||
- `ScanGroupSummary` -> `/overviews/resource-groups` (resource
|
||||
inventory).
|
||||
- `ScanCategorySummary` -> `/overviews/categories`.
|
||||
- `AttackSurfaceOverview` -> `/overviews/attack-surfaces`.
|
||||
|
||||
Per-scan pipelines are dispatched in parallel via a Celery group so
|
||||
wallclock scales with the worker pool.
|
||||
"""
|
||||
completed_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("-completed_at")
|
||||
.values("id", "completed_at", "provider_id")
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d latest scans",
|
||||
len(scan_ids),
|
||||
)
|
||||
|
||||
# Keep the latest scan per (provider, day) pair so the daily summary row
|
||||
# the aggregator writes is the most recent snapshot of that day for that
|
||||
# provider. Iterating from most recent to oldest means the first scan we
|
||||
# see for a given key wins.
|
||||
latest_scans: dict[tuple, str] = {}
|
||||
for scan in completed_scans:
|
||||
key = (scan["provider_id"], scan["completed_at"].date())
|
||||
if key not in latest_scans:
|
||||
latest_scans[key] = str(scan["id"])
|
||||
|
||||
scan_ids = list(latest_scans.values())
|
||||
if scan_ids:
|
||||
logger.info(
|
||||
"Reaggregating overview/finding summaries for %d scans (provider x day)",
|
||||
len(scan_ids),
|
||||
)
|
||||
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
|
||||
# recomputed first; the other aggregators read Finding directly and
|
||||
# can run in parallel with the severity step.
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
group(
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
return {"scans_reaggregated": len(scan_ids)}
|
||||
aggregate_scan_resource_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_scan_category_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview")
|
||||
@set_tenant(keep_tenant=True)
|
||||
def mute_findings_in_latest_scans_task(
|
||||
tenant_id: str, mute_rule_id: str, provider_ids: list[str]
|
||||
):
|
||||
"""Apply a mute rule to current scans and rebuild only changed summaries."""
|
||||
result = mute_findings_in_latest_scans(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
_dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"])
|
||||
return result
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="lighthouse-connection-check")
|
||||
@@ -1467,25 +1459,3 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
|
||||
generate_csa=True,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="findings-mute-historical")
|
||||
def mute_historical_findings_task(tenant_id: str, mute_rule_id: str):
|
||||
"""
|
||||
Background task to mute all historical findings matching a mute rule.
|
||||
|
||||
This task processes findings in batches to avoid memory issues with large datasets.
|
||||
It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields
|
||||
for all findings whose UID is in the mute rule's finding_uids list.
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant ID for RLS context.
|
||||
mute_rule_id (str): The primary key of the MuteRule to apply.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing:
|
||||
- 'findings_muted' (int): Total number of findings muted.
|
||||
- 'rule_id' (str): The mute rule ID.
|
||||
- 'status' (str): Final status ('completed').
|
||||
"""
|
||||
return mute_historical_findings(tenant_id, mute_rule_id)
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import TEMP_DB_PREFIX
|
||||
from api.models import AttackPathsScan, StateChoices
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS
|
||||
|
||||
MARGIN = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS)
|
||||
|
||||
|
||||
def _tmp_db_name(scan_uuid) -> str:
|
||||
return f"{TEMP_DB_PREFIX}{scan_uuid}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReapOrphanedTmpDatabases:
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_ignores_databases_without_the_temp_prefix(self, mock_list, mock_drop):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
mock_list.return_value = ["db-tenant-abc123", "system", "neo4j"]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_temp_db_with_no_scan_row_past_safety_margin(
|
||||
self, mock_list, mock_drop
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
old_scan_id = datetime_to_uuid7(
|
||||
datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
)
|
||||
database = _tmp_db_name(old_scan_id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [database]}
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_temp_db_with_no_scan_row_inside_safety_margin(
|
||||
self, mock_list, mock_drop
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
recent_scan_id = datetime_to_uuid7(datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
database = _tmp_db_name(recent_scan_id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_temp_db_with_unparseable_scan_id(self, mock_list, mock_drop):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
database = f"{TEMP_DB_PREFIX}not-a-uuid"
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_terminal_scan_past_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [database]}
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"state",
|
||||
[StateChoices.FAILED, StateChoices.CANCELLED],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_other_terminal_states_past_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider, state
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=state,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result["dropped_count"] == 1
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_terminal_scan_inside_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_margin_counts_from_completed_at_not_updated_at(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(
|
||||
updated_at=old, completed_at=datetime.now(tz=UTC)
|
||||
)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_drops_scan_completed_past_safety_margin(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.FAILED,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(
|
||||
updated_at=old, completed_at=old
|
||||
)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [database]}
|
||||
mock_drop.assert_called_once_with(database)
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_never_drops_an_executing_scan_regardless_of_age(
|
||||
self, mock_list, mock_drop, tenants_fixture, aws_provider
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
very_old = datetime.now(tz=UTC) - timedelta(days=30)
|
||||
scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=aws_provider,
|
||||
state=StateChoices.EXECUTING,
|
||||
)
|
||||
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=very_old)
|
||||
|
||||
database = _tmp_db_name(scan.id)
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_one_failed_drop_does_not_stop_the_rest_of_the_sweep(
|
||||
self, mock_list, mock_drop
|
||||
):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
old_time = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
|
||||
failing_scan_id = datetime_to_uuid7(old_time)
|
||||
succeeding_scan_id = datetime_to_uuid7(old_time)
|
||||
failing_db = _tmp_db_name(failing_scan_id)
|
||||
succeeding_db = _tmp_db_name(succeeding_scan_id)
|
||||
mock_list.return_value = [failing_db, succeeding_db]
|
||||
mock_drop.side_effect = [Exception("boom"), None]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 1, "databases": [succeeding_db]}
|
||||
assert mock_drop.call_count == 2
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_returns_empty_result_when_listing_databases_fails(self, mock_list):
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
mock_list.side_effect = Exception("neo4j unreachable")
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
|
||||
def test_preserves_temp_db_with_random_uuid_and_no_row(self, mock_list, mock_drop):
|
||||
"""A non-UUIDv7 id with no matching row has no reliable timestamp, so it
|
||||
must be left alone rather than guessed at."""
|
||||
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
|
||||
|
||||
database = _tmp_db_name(uuid4())
|
||||
mock_list.return_value = [database]
|
||||
|
||||
result = reap_orphaned_tmp_databases()
|
||||
|
||||
assert result == {"dropped_count": 0, "databases": []}
|
||||
mock_drop.assert_not_called()
|
||||
|
||||
|
||||
class TestReapOrphanedTmpDatabasesTask:
|
||||
@patch(
|
||||
"tasks.tasks.reap_orphaned_tmp_databases",
|
||||
return_value={"dropped_count": 2, "databases": ["db-tmp-scan-a"]},
|
||||
)
|
||||
def test_task_invokes_the_reaper(self, mock_reap):
|
||||
from tasks.tasks import reap_orphaned_attack_paths_tmp_databases_task
|
||||
|
||||
result = reap_orphaned_attack_paths_tmp_databases_task.run()
|
||||
|
||||
assert result == {"dropped_count": 2, "databases": ["db-tmp-scan-a"]}
|
||||
mock_reap.assert_called_once_with()
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.models import Provider, Tenant, TenantComplianceSummary
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.test import override_settings
|
||||
from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
|
||||
|
||||
@@ -123,6 +124,60 @@ class TestDeleteProvider:
|
||||
"tenant-db", str(instance.id)
|
||||
)
|
||||
|
||||
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
|
||||
def test_delete_provider_skips_unconfigured_retired_sink(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
):
|
||||
instance = aws_provider
|
||||
tenant_id = str(instance.tenant_id)
|
||||
create_attack_paths_scan(instance, sink_backend="neo4j")
|
||||
create_attack_paths_scan(instance, sink_backend="neptune")
|
||||
neo4j_backend = MagicMock()
|
||||
|
||||
def get_backend_for_name(name):
|
||||
if name == "neptune":
|
||||
raise RuntimeError("NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set")
|
||||
return neo4j_backend
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.deletion.sink_module.get_backend_for_name",
|
||||
side_effect=get_backend_for_name,
|
||||
),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_database"),
|
||||
):
|
||||
result = delete_provider(tenant_id, instance.id)
|
||||
|
||||
assert result
|
||||
assert not Provider.all_objects.filter(pk=instance.id).exists()
|
||||
neo4j_backend.drop_subgraph.assert_called_once_with(
|
||||
"tenant-db", str(instance.id)
|
||||
)
|
||||
|
||||
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
|
||||
def test_delete_provider_raises_when_active_sink_unconfigured(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
):
|
||||
instance = aws_provider
|
||||
tenant_id = str(instance.tenant_id)
|
||||
create_attack_paths_scan(instance, sink_backend="neo4j")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.sink_module.get_backend_for_name",
|
||||
side_effect=RuntimeError("NEO4J_HOST / NEO4J_PORT must be set"),
|
||||
),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_database"),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
delete_provider(tenant_id, instance.id)
|
||||
|
||||
assert Provider.all_objects.filter(pk=instance.id).exists()
|
||||
|
||||
def test_delete_provider_continues_when_temp_db_drop_fails(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
):
|
||||
@@ -149,10 +204,10 @@ class TestDeleteProvider:
|
||||
assert result
|
||||
assert not Provider.all_objects.filter(pk=instance.id).exists()
|
||||
|
||||
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
|
||||
def test_delete_provider_recalculates_tenant_compliance_summary(
|
||||
self,
|
||||
aws_provider_pair,
|
||||
provider_compliance_scores_fixture,
|
||||
):
|
||||
instance = aws_provider_pair[0]
|
||||
tenant_id = instance.tenant_id
|
||||
|
||||
@@ -3,16 +3,20 @@ import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from django.test import override_settings
|
||||
from tasks.jobs.export import (
|
||||
_compress_output_files,
|
||||
_generate_compliance_output_directory,
|
||||
_generate_output_directory,
|
||||
_upload_to_s3,
|
||||
get_s3_client,
|
||||
get_s3_presign_client,
|
||||
)
|
||||
|
||||
|
||||
@@ -47,15 +51,59 @@ class TestOutputs:
|
||||
assert client is not None
|
||||
client_mock.list_buckets.assert_called()
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@override_settings(
|
||||
DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID="access-key",
|
||||
DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY="secret-key",
|
||||
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN="",
|
||||
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION="",
|
||||
)
|
||||
def test_get_s3_client_without_a_region_uses_a_default(self, mock_boto_client):
|
||||
"""botocore rejects an empty region up front, and the download views do not catch it."""
|
||||
get_s3_client()
|
||||
|
||||
assert mock_boto_client.call_args.kwargs["region_name"] == "us-east-1"
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000")
|
||||
def test_get_s3_client_passes_the_endpoint_when_set(self, mock_boto_client):
|
||||
get_s3_client()
|
||||
|
||||
assert mock_boto_client.call_args.kwargs["endpoint_url"] == "http://minio:9000"
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="")
|
||||
def test_get_s3_client_endpoint_empty_by_default(self, mock_boto_client):
|
||||
"""Empty keeps today's behavior: no endpoint override, real S3 is used."""
|
||||
get_s3_client()
|
||||
|
||||
assert mock_boto_client.call_args.kwargs["endpoint_url"] is None
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000")
|
||||
def test_get_s3_client_does_not_fall_back_when_endpoint_set(self, mock_boto_client):
|
||||
"""A configured endpoint means the explicit client failed talking to it. The fallback
|
||||
goes to the default provider chain (e.g. an EC2 instance role) against real AWS, so it
|
||||
must not be used: the original error propagates instead."""
|
||||
error = ClientError({"Error": {"Code": "403"}}, "ListBuckets")
|
||||
mock_boto_client.side_effect = error
|
||||
|
||||
with pytest.raises(ClientError):
|
||||
get_s3_client()
|
||||
|
||||
mock_boto_client.assert_called_once()
|
||||
|
||||
@patch("tasks.jobs.export.boto3.client")
|
||||
@patch("tasks.jobs.export.settings")
|
||||
def test_get_s3_client_fallback(self, mock_settings, mock_boto_client):
|
||||
mock_settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = ""
|
||||
mock_boto_client.side_effect = [
|
||||
ClientError({"Error": {"Code": "403"}}, "ListBuckets"),
|
||||
MagicMock(),
|
||||
]
|
||||
client = get_s3_client()
|
||||
assert client is not None
|
||||
assert mock_boto_client.call_args_list[1] == call("s3")
|
||||
|
||||
@patch("tasks.jobs.export.get_s3_client")
|
||||
@patch("tasks.jobs.export.base")
|
||||
@@ -243,3 +291,167 @@ class TestOutputs:
|
||||
assert os.path.isdir(os.path.dirname(ens))
|
||||
assert threatscore.endswith(f"aws-test-check-{expected_timestamp}")
|
||||
assert ens.endswith(f"aws-test-check-{expected_timestamp}")
|
||||
|
||||
|
||||
PRESIGN_SETTINGS = {
|
||||
"DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "access-key",
|
||||
"DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "secret-key",
|
||||
"DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN": "",
|
||||
"DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": "eu-west-1",
|
||||
}
|
||||
|
||||
|
||||
def _presign(client):
|
||||
return client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": "output-bucket", "Key": "tenant/scan/report.zip"},
|
||||
ExpiresIn=300,
|
||||
)
|
||||
|
||||
|
||||
class TestS3PresignClient:
|
||||
@override_settings(
|
||||
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
|
||||
)
|
||||
def test_no_public_endpoint_and_no_region_returns_none(self):
|
||||
# Without a region, SigV4 would have to guess one and break other regions.
|
||||
assert get_s3_presign_client() is None
|
||||
|
||||
@override_settings(**PRESIGN_SETTINGS, DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="")
|
||||
def test_region_without_public_endpoint_signs_sigv4_on_the_regional_host(self):
|
||||
# SSE-KMS objects reject the SigV2 URLs boto3 presigns by default, and the
|
||||
# global host redirects for new buckets, which breaks a SigV4 signature.
|
||||
url = urlparse(_presign(get_s3_presign_client()))
|
||||
query = parse_qs(url.query)
|
||||
|
||||
assert url.netloc == "s3.eu-west-1.amazonaws.com"
|
||||
assert url.path == "/output-bucket/tenant/scan/report.zip"
|
||||
assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
|
||||
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
|
||||
@override_settings(
|
||||
**{
|
||||
**PRESIGN_SETTINGS,
|
||||
"DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "",
|
||||
"DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "",
|
||||
},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
|
||||
)
|
||||
def test_region_without_static_keys_signs_with_the_default_chain(self, monkeypatch):
|
||||
# An ECS task role reaches boto3 through the default chain, like the env here.
|
||||
# A fresh default session keeps these keys from being cached for later tests.
|
||||
monkeypatch.setattr(boto3, "DEFAULT_SESSION", None)
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "role-access-key")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "role-secret-key")
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
|
||||
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
assert query["X-Amz-Credential"][0].startswith("role-access-key/")
|
||||
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
|
||||
@override_settings(
|
||||
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000",
|
||||
)
|
||||
def test_internal_endpoint_without_public_endpoint_signs_against_it(self):
|
||||
# No browser-reachable host was configured, so the internal one is the best
|
||||
# available target instead of falling through to the real AWS host.
|
||||
url = urlparse(_presign(get_s3_presign_client()))
|
||||
|
||||
assert url.netloc == "minio:9000"
|
||||
assert url.path == "/output-bucket/tenant/scan/report.zip"
|
||||
|
||||
@override_settings(
|
||||
**PRESIGN_SETTINGS,
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000",
|
||||
)
|
||||
def test_public_endpoint_wins_over_the_internal_endpoint(self):
|
||||
url = urlparse(_presign(get_s3_presign_client()))
|
||||
|
||||
assert url.netloc == "storage.example.com"
|
||||
|
||||
@override_settings(
|
||||
**PRESIGN_SETTINGS,
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
)
|
||||
def test_url_targets_the_public_host_in_path_style(self):
|
||||
url = urlparse(_presign(get_s3_presign_client()))
|
||||
|
||||
assert url.netloc == "storage.example.com"
|
||||
assert url.path == "/output-bucket/tenant/scan/report.zip"
|
||||
|
||||
@override_settings(
|
||||
**PRESIGN_SETTINGS,
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
)
|
||||
def test_signature_covers_the_public_host(self):
|
||||
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
assert query["X-Amz-SignedHeaders"] == ["host"]
|
||||
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
|
||||
@override_settings(
|
||||
**PRESIGN_SETTINGS,
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
)
|
||||
def test_signature_is_bound_to_the_host_it_was_signed_against(self):
|
||||
"""Rewriting the host afterwards cannot work, which is why the endpoint is a setting."""
|
||||
public = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
with override_settings(
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="http://minio:9000"
|
||||
):
|
||||
internal = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
assert public["X-Amz-Signature"] != internal["X-Amz-Signature"]
|
||||
|
||||
@override_settings(
|
||||
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
)
|
||||
def test_region_falls_back_to_the_minio_default(self):
|
||||
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
assert "/us-east-1/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
|
||||
@override_settings(
|
||||
**PRESIGN_SETTINGS,
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
)
|
||||
def test_unset_session_token_is_left_out_of_the_url(self):
|
||||
"""An empty token still reaches the URL as a blank param that storage signs over."""
|
||||
url = _presign(get_s3_presign_client())
|
||||
query = parse_qs(urlparse(url).query, keep_blank_values=True)
|
||||
|
||||
assert "X-Amz-Security-Token" not in query
|
||||
|
||||
@override_settings(
|
||||
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN": "session-token"},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
)
|
||||
def test_session_token_is_forwarded_when_set(self):
|
||||
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
assert query["X-Amz-Security-Token"] == ["session-token"]
|
||||
|
||||
@override_settings(
|
||||
**{
|
||||
**PRESIGN_SETTINGS,
|
||||
"DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "",
|
||||
"DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "",
|
||||
},
|
||||
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
|
||||
)
|
||||
def test_blank_static_credentials_defer_to_the_provider_chain(self, monkeypatch):
|
||||
"""Empty keys would otherwise be signed as-is, yielding a blank credential scope."""
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "chain-key")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "chain-secret")
|
||||
monkeypatch.delenv("AWS_SESSION_TOKEN", raising=False)
|
||||
|
||||
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
|
||||
|
||||
assert query["X-Amz-Credential"][0].startswith("chain-key/")
|
||||
|
||||
@@ -6,15 +6,20 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.models import Integration
|
||||
from api.utils import prowler_integration_connection_test
|
||||
from django.db import OperationalError
|
||||
from django.test import override_settings
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
JiraRefreshTokenError,
|
||||
JiraRequiredCustomFieldsError,
|
||||
)
|
||||
from prowler.lib.outputs.jira.jira import Jira
|
||||
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
|
||||
from prowler.providers.common.models import Connection
|
||||
from tasks.jobs.integrations import (
|
||||
build_jira_finding_url,
|
||||
build_jira_issue_labels,
|
||||
get_s3_client_from_integration,
|
||||
get_security_hub_client_from_integration,
|
||||
get_tenant_name,
|
||||
send_findings_to_jira,
|
||||
upload_s3_integration,
|
||||
upload_security_hub_integration,
|
||||
@@ -1696,6 +1701,7 @@ class TestJiraIntegration:
|
||||
|
||||
finding1 = MagicMock()
|
||||
finding1.id = "finding-1"
|
||||
finding1.uid = "prowler-aws-check_001-123456789012-us-east-1-my bucket"
|
||||
finding1.check_id = "check_001"
|
||||
finding1.severity = "high"
|
||||
finding1.status = "FAIL"
|
||||
@@ -1724,6 +1730,7 @@ class TestJiraIntegration:
|
||||
|
||||
finding2 = MagicMock()
|
||||
finding2.id = "finding-2"
|
||||
finding2.uid = "prowler-azure-check_002-sub/resource"
|
||||
finding2.check_id = "check_002"
|
||||
finding2.severity = "medium"
|
||||
finding2.status = "PASS"
|
||||
@@ -1748,9 +1755,13 @@ class TestJiraIntegration:
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
with (
|
||||
override_settings(UI_BASE_URL="https://cloud.example.com"),
|
||||
patch("tasks.jobs.integrations.get_tenant_name", return_value="Acme"),
|
||||
):
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 2, "failed_count": 0}
|
||||
@@ -1773,12 +1784,36 @@ class TestJiraIntegration:
|
||||
assert first_call.kwargs["provider"] == "aws"
|
||||
assert first_call.kwargs["project_key"] == project_key
|
||||
assert first_call.kwargs["issue_type"] == issue_type
|
||||
# Finding reference: labels, link back and tenant info
|
||||
assert first_call.kwargs["issue_labels"] == [
|
||||
"prowler",
|
||||
"prowler-aws",
|
||||
"prowler-high",
|
||||
"prowler-check_001",
|
||||
"prowler-finding-prowler-aws-check_001-123456789012-us-east-1-my_bucket",
|
||||
]
|
||||
assert first_call.kwargs["finding_url"] == (
|
||||
"https://cloud.example.com/findings?filter[uid]="
|
||||
"prowler-aws-check_001-123456789012-us-east-1-my%20bucket"
|
||||
)
|
||||
assert first_call.kwargs["tenant_info"] == "Acme"
|
||||
|
||||
# Verify second call
|
||||
second_call = mock_jira_integration.send_finding.call_args_list[1]
|
||||
assert second_call.kwargs["check_id"] == "check_002"
|
||||
assert second_call.kwargs["severity"] == "medium"
|
||||
assert second_call.kwargs["status"] == "PASS"
|
||||
assert second_call.kwargs["issue_labels"] == [
|
||||
"prowler",
|
||||
"prowler-azure",
|
||||
"prowler-medium",
|
||||
"prowler-check_002",
|
||||
"prowler-finding-prowler-azure-check_002-sub/resource",
|
||||
]
|
||||
assert second_call.kwargs["finding_url"] == (
|
||||
"https://cloud.example.com/findings?filter[uid]="
|
||||
"prowler-azure-check_002-sub%2Fresource"
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@@ -2200,3 +2235,101 @@ class TestJiraIntegration:
|
||||
assert call_kwargs["remediation_code_cli"] == ""
|
||||
assert call_kwargs["remediation_code_other"] == ""
|
||||
assert call_kwargs["compliance"] == {}
|
||||
|
||||
|
||||
class TestJiraFindingReference:
|
||||
"""Helpers that give Jira issues a stable reference back to the finding."""
|
||||
|
||||
def test_build_jira_issue_labels(self):
|
||||
assert build_jira_issue_labels(
|
||||
finding_uid="prowler-aws-check-123-eu-west-1-hub/unknown",
|
||||
provider="aws",
|
||||
severity="critical",
|
||||
check_id="iam_root_mfa",
|
||||
) == [
|
||||
"prowler",
|
||||
"prowler-aws",
|
||||
"prowler-critical",
|
||||
"prowler-iam_root_mfa",
|
||||
"prowler-finding-prowler-aws-check-123-eu-west-1-hub/unknown",
|
||||
]
|
||||
|
||||
def test_build_jira_issue_labels_skips_empty_parts(self):
|
||||
assert build_jira_issue_labels(
|
||||
finding_uid="", provider="", severity="", check_id=""
|
||||
) == ["prowler"]
|
||||
|
||||
def test_build_jira_issue_labels_sanitizes_metadata(self):
|
||||
assert build_jira_issue_labels(
|
||||
finding_uid=" uid\x00 with spaces ",
|
||||
provider="aws cloud",
|
||||
severity="high severity",
|
||||
check_id="check id",
|
||||
) == [
|
||||
"prowler",
|
||||
"prowler-aws_cloud",
|
||||
"prowler-high_severity",
|
||||
"prowler-check_id",
|
||||
"prowler-finding-uid_with_spaces",
|
||||
]
|
||||
|
||||
def test_build_jira_issue_labels_preserves_maximum_length_uid(self):
|
||||
finding_uid = "u" * (Jira.LABEL_MAX_LENGTH - len(Jira.FINDING_LABEL_PREFIX) - 1)
|
||||
finding_label = build_jira_issue_labels(
|
||||
finding_uid=finding_uid,
|
||||
provider="gcp",
|
||||
severity="low",
|
||||
check_id="check",
|
||||
)[-1]
|
||||
|
||||
assert finding_label == f"{Jira.FINDING_LABEL_PREFIX}-{finding_uid}"
|
||||
assert len(finding_label) == Jira.LABEL_MAX_LENGTH
|
||||
|
||||
def test_build_jira_issue_labels_distinguishes_long_uids(self):
|
||||
common_prefix = "u" * 300
|
||||
first_uid = f"{common_prefix}-first"
|
||||
second_uid = f"{common_prefix}-second"
|
||||
|
||||
first_label = build_jira_issue_labels(
|
||||
finding_uid=first_uid,
|
||||
provider="gcp",
|
||||
severity="low",
|
||||
check_id="check",
|
||||
)[-1]
|
||||
second_label = build_jira_issue_labels(
|
||||
finding_uid=second_uid,
|
||||
provider="gcp",
|
||||
severity="low",
|
||||
check_id="check",
|
||||
)[-1]
|
||||
|
||||
assert first_label == Jira.build_finding_label(first_uid)
|
||||
assert second_label == Jira.build_finding_label(second_uid)
|
||||
assert first_label != second_label
|
||||
assert len(first_label) == Jira.LABEL_MAX_LENGTH
|
||||
assert len(second_label) == Jira.LABEL_MAX_LENGTH
|
||||
|
||||
@override_settings(UI_BASE_URL="")
|
||||
def test_build_jira_finding_url_without_base_url(self):
|
||||
assert build_jira_finding_url("prowler-aws-check-1") == ""
|
||||
|
||||
@override_settings(UI_BASE_URL="https://cloud.example.com")
|
||||
def test_build_jira_finding_url_with_base_url(self):
|
||||
assert build_jira_finding_url("prowler-aws-check-1") == (
|
||||
"https://cloud.example.com/findings?filter[uid]=prowler-aws-check-1"
|
||||
)
|
||||
# uid characters that would break the query string are encoded
|
||||
assert build_jira_finding_url("a/b c&d") == (
|
||||
"https://cloud.example.com/findings?filter[uid]=a%2Fb%20c%26d"
|
||||
)
|
||||
assert build_jira_finding_url("") == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_tenant_name(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
assert get_tenant_name(str(tenant.id)) == tenant.name
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_tenant_name_unknown_or_invalid(self):
|
||||
assert get_tenant_name("00000000-0000-0000-0000-000000000000") == ""
|
||||
assert get_tenant_name("not-a-uuid") == ""
|
||||
|
||||
@@ -1,531 +1,205 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.models import Finding, MuteRule
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from api.models import Finding, MuteRule, Scan, StateChoices
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.muting import (
|
||||
mute_findings_in_latest_scans,
|
||||
reconcile_scan_mute_rules,
|
||||
)
|
||||
|
||||
|
||||
def _create_finding(scan: Scan, uid: str) -> Finding:
|
||||
return Finding.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
uid=uid,
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended="Test finding",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={},
|
||||
check_id="test_check",
|
||||
check_metadata={"CheckId": "test_check"},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
|
||||
def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule:
|
||||
return MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name=f"Mute rule {uuid4()}",
|
||||
reason="Approved exception",
|
||||
enabled=enabled,
|
||||
created_by=user,
|
||||
finding_uids=finding_uids,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMuteHistoricalFindings:
|
||||
"""
|
||||
Test suite for the mute_historical_findings function.
|
||||
class TestMuteFindingsInLatestScans:
|
||||
def test_mutes_latest_scan_and_leaves_older_scan_unchanged(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
latest_scan = scans_fixture[0]
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=latest_scan.tenant_id,
|
||||
provider=latest_scan.provider,
|
||||
name="Older scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
uid = "latest-scan-only"
|
||||
older_finding = _create_finding(older_scan, uid)
|
||||
latest_finding = _create_finding(latest_scan, uid)
|
||||
mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid])
|
||||
|
||||
This class tests the batch processing of findings to update their muted status
|
||||
based on MuteRule criteria.
|
||||
"""
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(latest_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(latest_scan.provider_id)],
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def test_user(self, create_test_user):
|
||||
"""Create a test user for mute rule creation."""
|
||||
return create_test_user
|
||||
older_finding.refresh_from_db()
|
||||
latest_finding.refresh_from_db()
|
||||
assert older_finding.muted is False
|
||||
assert latest_finding.muted is True
|
||||
assert latest_finding.muted_at == mute_rule.inserted_at
|
||||
assert latest_finding.muted_reason == mute_rule.reason
|
||||
assert result == {
|
||||
"findings_muted": 1,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [str(latest_scan.id)],
|
||||
}
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets the first finding in the fixture.
|
||||
"""
|
||||
def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user):
|
||||
first_scan, second_scan, _ = scans_fixture
|
||||
uid = "shared-selected-uid"
|
||||
first_finding = _create_finding(first_scan, uid)
|
||||
second_finding = _create_finding(second_scan, uid)
|
||||
mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(first_scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(first_scan.provider_id), str(second_scan.provider_id)],
|
||||
)
|
||||
|
||||
first_finding.refresh_from_db()
|
||||
second_finding.refresh_from_db()
|
||||
assert first_finding.muted is True
|
||||
assert second_finding.muted is True
|
||||
assert result["findings_muted"] == 2
|
||||
assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)}
|
||||
|
||||
def test_provider_without_completed_scan_does_nothing(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
finding = findings_fixture[0]
|
||||
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
name="Test Mute Rule",
|
||||
reason="Testing mute functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[finding.uid],
|
||||
provider = provider_factory()
|
||||
mute_rule = _create_mute_rule(
|
||||
tenant.id, create_test_user, ["future-scan-finding"]
|
||||
)
|
||||
|
||||
return mute_rule
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(provider.id)]
|
||||
)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_multiple_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create multiple unmuted findings and a mute rule targeting all of them.
|
||||
"""
|
||||
assert result == {
|
||||
"findings_muted": 0,
|
||||
"rule_id": str(mute_rule.id),
|
||||
"scan_ids": [],
|
||||
}
|
||||
|
||||
def test_retry_does_not_report_changed_scans_twice(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 5 unmuted findings
|
||||
finding_uids = []
|
||||
for i in range(5):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"test_finding_uid_mute_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Test status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"test_check_id_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"test_check_id_{i}",
|
||||
"Description": f"Test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Multiple Findings Mute Rule",
|
||||
reason="Testing batch muting",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
finding = _create_finding(scan, "idempotent-mute")
|
||||
mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid])
|
||||
args = (
|
||||
str(scan.tenant_id),
|
||||
str(mute_rule.id),
|
||||
[str(scan.provider_id)],
|
||||
)
|
||||
|
||||
return mute_rule, finding_uids
|
||||
first_result = mute_findings_in_latest_scans(*args)
|
||||
second_result = mute_findings_in_latest_scans(*args)
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_already_muted(self, findings_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule that targets an already-muted finding.
|
||||
"""
|
||||
tenant_id = findings_fixture[1].tenant_id
|
||||
already_muted_finding = findings_fixture[1]
|
||||
assert first_result["scan_ids"] == [str(scan.id)]
|
||||
assert second_result["findings_muted"] == 0
|
||||
assert second_result["scan_ids"] == []
|
||||
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Already Muted Rule",
|
||||
reason="Testing already muted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[already_muted_finding.uid],
|
||||
def test_does_not_cross_tenant_boundary(
|
||||
self, tenants_fixture, provider_factory, create_test_user
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
other_tenant = tenants_fixture[2]
|
||||
other_provider = provider_factory(tenant=other_tenant)
|
||||
other_scan = Scan.objects.create(
|
||||
tenant_id=other_tenant.id,
|
||||
provider=other_provider,
|
||||
name="Other tenant scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
other_finding = _create_finding(other_scan, "tenant-isolated-uid")
|
||||
mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid])
|
||||
|
||||
result = mute_findings_in_latest_scans(
|
||||
str(tenant.id), str(mute_rule.id), [str(other_provider.id)]
|
||||
)
|
||||
|
||||
return mute_rule
|
||||
other_finding.refresh_from_db()
|
||||
assert other_finding.muted is False
|
||||
assert result["scan_ids"] == []
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_mixed_findings(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create a mute rule with a mix of muted and unmuted findings.
|
||||
"""
|
||||
def test_nonexistent_rule_raises(self, tenants_fixture):
|
||||
with pytest.raises(MuteRule.DoesNotExist):
|
||||
mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), [])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReconcileScanMuteRules:
|
||||
def test_applies_only_enabled_rules_to_requested_scan(
|
||||
self, scans_fixture, create_test_user
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 3 unmuted findings
|
||||
unmuted_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"unmuted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Unmuted status {i}",
|
||||
impact=Severity.medium,
|
||||
severity=Severity.medium,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.medium,
|
||||
"severity": Severity.medium,
|
||||
},
|
||||
check_id=f"unmuted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"unmuted_check_{i}",
|
||||
"Description": f"Unmuted description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
unmuted_uids.append(finding.uid)
|
||||
|
||||
# Create 2 already muted findings
|
||||
muted_uids = []
|
||||
for i in range(2):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"muted_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Muted status {i}",
|
||||
impact=Severity.low,
|
||||
severity=Severity.low,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.low,
|
||||
"severity": Severity.low,
|
||||
},
|
||||
check_id=f"muted_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"muted_check_{i}",
|
||||
"Description": f"Muted description {i}",
|
||||
},
|
||||
muted=True,
|
||||
muted_at=datetime.now(UTC),
|
||||
muted_reason="Already muted",
|
||||
)
|
||||
muted_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
all_uids = unmuted_uids + muted_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Mixed Findings Rule",
|
||||
reason="Testing mixed muted/unmuted findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
active_finding = _create_finding(scan, "active-rule-uid")
|
||||
disabled_finding = _create_finding(scan, "disabled-rule-uid")
|
||||
active_rule = _create_mute_rule(
|
||||
scan.tenant_id, create_test_user, [active_finding.uid]
|
||||
)
|
||||
|
||||
return mute_rule, unmuted_uids, muted_uids
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mute_rule_batch_test(self, scans_fixture, test_user):
|
||||
"""
|
||||
Create enough findings to test batch processing (>1000 for default batch size).
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = scan.tenant_id
|
||||
|
||||
# Create 1500 findings to exceed default batch size of 1000
|
||||
finding_uids = []
|
||||
for i in range(1500):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"batch_test_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Batch test status {i}",
|
||||
impact=Severity.critical,
|
||||
severity=Severity.critical,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.critical,
|
||||
"severity": Severity.critical,
|
||||
},
|
||||
check_id=f"batch_test_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"batch_test_check_{i}",
|
||||
"Description": f"Batch test description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
finding_uids.append(finding.uid)
|
||||
|
||||
# Create mute rule targeting all findings
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Batch Processing Rule",
|
||||
reason="Testing batch processing functionality",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=finding_uids,
|
||||
_create_mute_rule(
|
||||
scan.tenant_id,
|
||||
create_test_user,
|
||||
[disabled_finding.uid],
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
return mute_rule, finding_uids
|
||||
|
||||
def test_mute_historical_findings_single_finding(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test muting a single historical finding.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Ensure the finding is not muted before execution
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is False
|
||||
assert finding.muted_at is None
|
||||
assert finding.muted_reason is None
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding was muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_multiple_findings(
|
||||
self, mute_rule_multiple_findings
|
||||
):
|
||||
"""
|
||||
Test muting multiple historical findings.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_multiple_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 5
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 5
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_already_muted(
|
||||
self, mute_rule_already_muted, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that already-muted findings are not counted or updated.
|
||||
"""
|
||||
mute_rule = mute_rule_already_muted
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[1]
|
||||
|
||||
# Verify the finding is already muted
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
original_muted_at = finding.muted_at
|
||||
original_muted_reason = finding.muted_reason
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the finding's mute status did not change
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == original_muted_at
|
||||
assert finding.muted_reason == original_muted_reason
|
||||
|
||||
def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings):
|
||||
"""
|
||||
Test muting when some findings are already muted and others are not.
|
||||
"""
|
||||
mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only unmuted findings were counted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify unmuted findings are now muted
|
||||
unmuted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=unmuted_uids
|
||||
older_scan = Scan.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
provider=scan.provider,
|
||||
name="Older matching scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
started_at=datetime.now(UTC) - timedelta(days=1),
|
||||
completed_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
for finding in unmuted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
older_finding = _create_finding(older_scan, active_finding.uid)
|
||||
|
||||
# Verify already-muted findings remained unchanged
|
||||
already_muted_findings = Finding.objects.filter(
|
||||
tenant_id=tenant_id, uid__in=muted_uids
|
||||
)
|
||||
for finding in already_muted_findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_reason == "Already muted"
|
||||
result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id))
|
||||
|
||||
def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture):
|
||||
"""
|
||||
Test that a nonexistent mute rule raises ObjectDoesNotExist.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
nonexistent_rule_id = str(uuid4())
|
||||
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
mute_historical_findings(tenant_id, nonexistent_rule_id)
|
||||
|
||||
def test_mute_historical_findings_no_matching_findings(
|
||||
self, tenants_fixture, test_user
|
||||
):
|
||||
"""
|
||||
Test muting when no findings match the rule's UIDs.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with non-existent finding UIDs
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test No Match Rule",
|
||||
reason="Testing no matching findings",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
"nonexistent_uid_3",
|
||||
],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test):
|
||||
"""
|
||||
Test that large numbers of findings are processed in batches correctly.
|
||||
"""
|
||||
mute_rule, finding_uids = mute_rule_batch_test
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
# Verify all findings exist and are unmuted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
assert findings.count() == 1500
|
||||
for finding in findings:
|
||||
assert finding.muted is False
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value
|
||||
assert result["findings_muted"] == 1500
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify all findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids)
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_preserves_muted_at_timestamp(
|
||||
self, mute_rule_with_findings, findings_fixture
|
||||
):
|
||||
"""
|
||||
Test that muted_at is set to the rule's inserted_at, not the current time.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
finding = findings_fixture[0]
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify the finding was muted
|
||||
assert result["findings_muted"] == 1
|
||||
|
||||
# Verify muted_at matches the rule's inserted_at timestamp
|
||||
finding.refresh_from_db()
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_at is not None
|
||||
|
||||
def test_mute_historical_findings_partial_match(self, scans_fixture, test_user):
|
||||
"""
|
||||
Test muting when only some of the rule's UIDs exist as findings.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
tenant_id = str(scan.tenant_id)
|
||||
|
||||
# Create 3 findings
|
||||
existing_uids = []
|
||||
for i in range(3):
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"partial_match_finding_{i}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended=f"Partial match status {i}",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={
|
||||
"status": Status.FAIL,
|
||||
"impact": Severity.high,
|
||||
"severity": Severity.high,
|
||||
},
|
||||
check_id=f"partial_match_check_{i}",
|
||||
check_metadata={
|
||||
"CheckId": f"partial_match_check_{i}",
|
||||
"Description": f"Partial match description {i}",
|
||||
},
|
||||
muted=False,
|
||||
)
|
||||
existing_uids.append(finding.uid)
|
||||
|
||||
# Create a mute rule with both existing and non-existing UIDs
|
||||
all_uids = existing_uids + [
|
||||
"nonexistent_uid_1",
|
||||
"nonexistent_uid_2",
|
||||
]
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Partial Match Rule",
|
||||
reason="Testing partial matching",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=all_uids,
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify only existing findings were muted
|
||||
assert result["findings_muted"] == 3
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
# Verify the existing findings were muted
|
||||
findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids)
|
||||
assert findings.count() == 3
|
||||
for finding in findings:
|
||||
assert finding.muted is True
|
||||
assert finding.muted_at == mute_rule.inserted_at
|
||||
assert finding.muted_reason == mute_rule.reason
|
||||
|
||||
def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user):
|
||||
"""
|
||||
Test muting when the rule has an empty finding_uids array.
|
||||
"""
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
|
||||
# Create a mute rule with empty finding_uids
|
||||
mute_rule = MuteRule.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
name="Test Empty UIDs Rule",
|
||||
reason="Testing empty UIDs",
|
||||
enabled=True,
|
||||
created_by=test_user,
|
||||
finding_uids=[],
|
||||
)
|
||||
|
||||
# Execute the muting function
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify no findings were muted
|
||||
assert result["findings_muted"] == 0
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
|
||||
def test_mute_historical_findings_return_format(self, mute_rule_with_findings):
|
||||
"""
|
||||
Test that the return value has the correct format and fields.
|
||||
"""
|
||||
mute_rule = mute_rule_with_findings
|
||||
tenant_id = str(mute_rule.tenant_id)
|
||||
|
||||
result = mute_historical_findings(tenant_id, str(mute_rule.id))
|
||||
|
||||
# Verify return value structure
|
||||
assert isinstance(result, dict)
|
||||
assert "findings_muted" in result
|
||||
assert "rule_id" in result
|
||||
assert isinstance(result["findings_muted"], int)
|
||||
assert isinstance(result["rule_id"], str)
|
||||
assert result["rule_id"] == str(mute_rule.id)
|
||||
active_finding.refresh_from_db()
|
||||
disabled_finding.refresh_from_db()
|
||||
older_finding.refresh_from_db()
|
||||
assert active_finding.muted is True
|
||||
assert active_finding.muted_at == active_rule.inserted_at
|
||||
assert disabled_finding.muted is False
|
||||
assert older_finding.muted is False
|
||||
assert result == {"findings_muted": 1, "scan_id": str(scan.id)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from contextlib import contextmanager
|
||||
@@ -10,8 +9,10 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import ProviderConnectionError, ProviderDeletedException
|
||||
from api.models import (
|
||||
AttackSurfaceOverview,
|
||||
Finding,
|
||||
MuteRule,
|
||||
Provider,
|
||||
@@ -2795,6 +2796,167 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
assert count_after_first > 0
|
||||
assert count_after_second == count_after_first
|
||||
with rls_transaction(tenant_id):
|
||||
row_versions = {
|
||||
row_id.version
|
||||
for row_id in ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).values_list("id", flat=True)
|
||||
}
|
||||
assert row_versions == {7}
|
||||
|
||||
def test_create_compliance_requirements_threatscore_counts_from_template(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
findings_fixture,
|
||||
):
|
||||
"""ThreatScore finding counts are derived from the template mapping,
|
||||
not from each finding's stored ``compliance`` payload."""
|
||||
from api.models import ComplianceRequirementOverview
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template:
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
scan_id = str(scans_fixture[0].id)
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"prowler_threatscore_aws": {
|
||||
"framework": "ProwlerThreatScore",
|
||||
"version": "1.0",
|
||||
"requirements": {
|
||||
"1.1.1": {
|
||||
"description": "ThreatScore requirement",
|
||||
"checks": {"test_check_id": None},
|
||||
},
|
||||
"1.1.2": {
|
||||
"description": "Unrelated requirement",
|
||||
"checks": {"other_check_id": None},
|
||||
},
|
||||
},
|
||||
},
|
||||
"other_framework": {
|
||||
"framework": "Other",
|
||||
"version": "2.0",
|
||||
"requirements": {
|
||||
"a": {
|
||||
"description": "Same check, other framework",
|
||||
"checks": {"test_check_id": None},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
create_compliance_requirements(tenant_id, scan_id)
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
counted = sum(
|
||||
len(finding.resource_regions or [])
|
||||
for finding in Finding.all_objects.filter(
|
||||
scan_id=scan_id,
|
||||
muted=False,
|
||||
status__in=["PASS", "FAIL"],
|
||||
check_id="test_check_id",
|
||||
)
|
||||
)
|
||||
rows = list(
|
||||
ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).values_list("compliance_id", "requirement_id", "total_findings")
|
||||
)
|
||||
assert counted > 0
|
||||
assert (
|
||||
sum(
|
||||
total
|
||||
for compliance_id, requirement_id, total in rows
|
||||
if (compliance_id, requirement_id)
|
||||
== ("prowler_threatscore_aws", "1.1.1")
|
||||
)
|
||||
== counted
|
||||
)
|
||||
assert all(
|
||||
total == 0
|
||||
for compliance_id, requirement_id, total in rows
|
||||
if (compliance_id, requirement_id) == ("prowler_threatscore_aws", "1.1.2")
|
||||
)
|
||||
assert all(
|
||||
total == 0
|
||||
for compliance_id, _, total in rows
|
||||
if compliance_id == "other_framework"
|
||||
)
|
||||
|
||||
def test_create_compliance_requirements_rows_across_regions_and_frameworks(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
):
|
||||
from api.models import ComplianceRequirementOverview
|
||||
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
scan_id = str(scans_fixture[0].id)
|
||||
check_status_by_region = {
|
||||
"us-east-1": {"check_a": "FAIL", "check_b": "PASS"},
|
||||
"eu-west-1": {"check_a": "PASS"},
|
||||
}
|
||||
template = {
|
||||
"fw_one": {
|
||||
"framework": "One",
|
||||
"version": "1",
|
||||
"requirements": {
|
||||
"r1": {"description": "a", "checks": {"check_a": None}},
|
||||
"r2": {
|
||||
"description": "a+b",
|
||||
"checks": {"check_a": None, "check_b": None},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fw_two": {
|
||||
"framework": "Two",
|
||||
"version": "2",
|
||||
"requirements": {
|
||||
"m1": {"description": "manual", "checks": {}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
patch(
|
||||
"tasks.jobs.scan._aggregate_findings_by_region",
|
||||
return_value=(check_status_by_region, {}),
|
||||
),
|
||||
):
|
||||
mock_compliance_template.__getitem__.return_value = template
|
||||
result = create_compliance_requirements(tenant_id, scan_id)
|
||||
|
||||
assert result["requirements_created"] == 6
|
||||
with rls_transaction(tenant_id):
|
||||
rows = set(
|
||||
ComplianceRequirementOverview.objects.filter(
|
||||
scan_id=scan_id
|
||||
).values_list(
|
||||
"compliance_id",
|
||||
"requirement_id",
|
||||
"region",
|
||||
"requirement_status",
|
||||
"passed_checks",
|
||||
"failed_checks",
|
||||
"total_checks",
|
||||
)
|
||||
)
|
||||
assert rows == {
|
||||
("fw_one", "r1", "us-east-1", "FAIL", 0, 1, 1),
|
||||
("fw_one", "r1", "eu-west-1", "PASS", 1, 0, 1),
|
||||
("fw_one", "r2", "us-east-1", "FAIL", 1, 1, 2),
|
||||
("fw_one", "r2", "eu-west-1", "PASS", 1, 0, 2),
|
||||
("fw_two", "m1", "us-east-1", "MANUAL", 0, 0, 0),
|
||||
("fw_two", "m1", "eu-west-1", "MANUAL", 0, 0, 0),
|
||||
}
|
||||
|
||||
def test_create_compliance_requirements_kubernetes_provider(
|
||||
self,
|
||||
@@ -4723,17 +4885,11 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test function returns correct data structure."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
# (check_id, status, resource_regions, compliance) tuples
|
||||
finding_rows = [
|
||||
(
|
||||
"check1",
|
||||
"FAIL",
|
||||
["us-east-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1", "req2"]},
|
||||
)
|
||||
]
|
||||
# (check_id, status, resource_regions) tuples
|
||||
finding_rows = [("check1", "FAIL", ["us-east-1"])]
|
||||
threatscore_by_check = {"check1": ["req1", "req2"]}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -4747,13 +4903,16 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -4774,13 +4933,14 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test that FAIL status takes priority over other statuses."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
# Same check/region: PASS first, then FAIL — FAIL must win
|
||||
finding_rows = [
|
||||
("check1", "PASS", ["us-east-1"], {}),
|
||||
("check1", "FAIL", ["us-east-1"], {}),
|
||||
("check1", "PASS", ["us-east-1"]),
|
||||
("check1", "FAIL", ["us-east-1"]),
|
||||
]
|
||||
threatscore_by_check = {}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -4793,12 +4953,15 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
check_status_by_region, _ = _aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -4813,8 +4976,9 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test that muted findings are filtered out (muted=False in query)."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
threatscore_by_check = {}
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
mock_queryset.iterator.return_value = []
|
||||
@@ -4826,12 +4990,15 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -4851,23 +5018,14 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test that ThreatScore compliance counts are processed correctly."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
# PASS and FAIL findings mapped to the same ThreatScore requirement
|
||||
finding_rows = [
|
||||
(
|
||||
"check1",
|
||||
"PASS",
|
||||
["us-east-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1"]},
|
||||
),
|
||||
(
|
||||
"check2",
|
||||
"FAIL",
|
||||
["us-east-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1"]},
|
||||
),
|
||||
("check1", "PASS", ["us-east-1"]),
|
||||
("check2", "FAIL", ["us-east-1"]),
|
||||
]
|
||||
threatscore_by_check = {"check1": ["req1"], "check2": ["req1"]}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -4880,19 +5038,19 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
_, findings_count_by_compliance = _aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
# Verify compliance counts
|
||||
normalized_id = re.sub(
|
||||
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
|
||||
)
|
||||
assert "us-east-1" in findings_count_by_compliance
|
||||
assert normalized_id in findings_count_by_compliance["us-east-1"]
|
||||
assert "req1" in findings_count_by_compliance["us-east-1"][normalized_id]
|
||||
@@ -4909,13 +5067,14 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test aggregation across multiple regions."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
# One finding per region
|
||||
finding_rows = [
|
||||
("check1", "FAIL", ["us-east-1"], {}),
|
||||
("check1", "PASS", ["us-west-2"], {}),
|
||||
("check1", "FAIL", ["us-east-1"]),
|
||||
("check1", "PASS", ["us-west-2"]),
|
||||
]
|
||||
threatscore_by_check = {}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -4928,12 +5087,15 @@ class TestAggregateFindingsByRegion:
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
check_status_by_region, _ = _aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -4951,16 +5113,10 @@ class TestAggregateFindingsByRegion:
|
||||
"""A finding with multiple resource_regions is tallied in every region."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
finding_rows = [
|
||||
(
|
||||
"check1",
|
||||
"FAIL",
|
||||
["us-east-1", "eu-west-1"],
|
||||
{modeled_threatscore_compliance_id: ["req1"]},
|
||||
)
|
||||
]
|
||||
finding_rows = [("check1", "FAIL", ["us-east-1", "eu-west-1"])]
|
||||
threatscore_by_check = {"check1": ["req1"]}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -4974,19 +5130,19 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
normalized_id = re.sub(
|
||||
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
|
||||
)
|
||||
for region in ("us-east-1", "eu-west-1"):
|
||||
assert check_status_by_region[region]["check1"] == "FAIL"
|
||||
req_stats = findings_count_by_compliance[region][normalized_id]["req1"]
|
||||
@@ -5000,12 +5156,13 @@ class TestAggregateFindingsByRegion:
|
||||
"""A finding with no denormalized regions contributes nothing."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
finding_rows = [
|
||||
("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}),
|
||||
("check2", "PASS", None, {}),
|
||||
("check1", "FAIL", []),
|
||||
("check2", "PASS", None),
|
||||
]
|
||||
threatscore_by_check = {"check1": ["req1"]}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
@@ -5019,13 +5176,16 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -5040,8 +5200,9 @@ class TestAggregateFindingsByRegion:
|
||||
"""Test with no findings - should return empty dicts."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
|
||||
normalized_id = "prowlerthreatscore10"
|
||||
|
||||
threatscore_by_check = {}
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values_list.return_value = mock_queryset
|
||||
mock_queryset.iterator.return_value = []
|
||||
@@ -5054,13 +5215,16 @@ class TestAggregateFindingsByRegion:
|
||||
|
||||
check_status_by_region, findings_count_by_compliance = (
|
||||
_aggregate_findings_by_region(
|
||||
tenant_id, scan_id, modeled_threatscore_compliance_id
|
||||
tenant_id,
|
||||
scan_id,
|
||||
normalized_id,
|
||||
threatscore_by_check,
|
||||
)
|
||||
)
|
||||
|
||||
# Streaming query contract: column-scoped values_list + iterator
|
||||
mock_queryset.values_list.assert_called_once_with(
|
||||
"check_id", "status", "resource_regions", "compliance"
|
||||
"check_id", "status", "resource_regions"
|
||||
)
|
||||
mock_queryset.iterator.assert_called_once()
|
||||
|
||||
@@ -5164,8 +5328,13 @@ class TestAggregateAttackSurface:
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values.return_value = mock_queryset
|
||||
mock_queryset.annotate.return_value = [
|
||||
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
|
||||
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0},
|
||||
{
|
||||
"check_id": "check_internet_1",
|
||||
"total": 10,
|
||||
"failed": 3,
|
||||
"muted_count": 1,
|
||||
},
|
||||
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted_count": 0},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
@@ -5214,7 +5383,7 @@ class TestAggregateAttackSurface:
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values.return_value = mock_queryset
|
||||
mock_queryset.annotate.return_value = [
|
||||
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0},
|
||||
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted_count": 0},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
@@ -5297,8 +5466,13 @@ class TestAggregateAttackSurface:
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values.return_value = mock_queryset
|
||||
mock_queryset.annotate.return_value = [
|
||||
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
|
||||
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0},
|
||||
{
|
||||
"check_id": "check_internet_1",
|
||||
"total": 10,
|
||||
"failed": 3,
|
||||
"muted_count": 1,
|
||||
},
|
||||
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted_count": 0},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
@@ -5319,6 +5493,62 @@ class TestAggregateAttackSurface:
|
||||
assert overview.failed_findings == 5 # 3 + 2
|
||||
assert overview.muted_failed_findings == 1 # 1 + 0
|
||||
|
||||
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
|
||||
def test_aggregate_attack_surface_counts_real_findings(
|
||||
self, mock_get_mapping, tenants_fixture, scans_fixture
|
||||
):
|
||||
"""Run the aggregation query against real Finding rows.
|
||||
|
||||
The other tests mock the queryset, so they never execute the real
|
||||
`annotate`. This one guards the row keys the query returns."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
mock_get_mapping.return_value = {
|
||||
"privilege-escalation": {"check_privesc_1"},
|
||||
"secrets": {"check_secrets_1"},
|
||||
}
|
||||
|
||||
def create_finding(uid, check_id, status, muted):
|
||||
Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
uid=uid,
|
||||
scan=scan,
|
||||
status=status,
|
||||
status_extended="status extended",
|
||||
impact=Severity.high,
|
||||
severity=Severity.high,
|
||||
raw_result={"status": status},
|
||||
check_id=check_id,
|
||||
check_metadata={"CheckId": check_id},
|
||||
muted=muted,
|
||||
first_seen_at="2024-01-02T00:00:00Z",
|
||||
)
|
||||
|
||||
create_finding("privesc_fail", "check_privesc_1", Status.FAIL, False)
|
||||
create_finding("privesc_fail_2", "check_privesc_1", Status.FAIL, False)
|
||||
create_finding("privesc_fail_muted", "check_privesc_1", Status.FAIL, True)
|
||||
create_finding("privesc_pass", "check_privesc_1", Status.PASS, False)
|
||||
create_finding("secrets_pass_muted", "check_secrets_1", Status.PASS, True)
|
||||
create_finding("unmapped_fail", "check_unmapped", Status.FAIL, False)
|
||||
|
||||
aggregate_attack_surface(str(tenant.id), str(scan.id))
|
||||
|
||||
overviews = {
|
||||
overview.attack_surface_type: overview
|
||||
for overview in AttackSurfaceOverview.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id
|
||||
)
|
||||
}
|
||||
|
||||
assert set(overviews) == {"privilege-escalation", "secrets"}
|
||||
assert overviews["privilege-escalation"].total_findings == 4
|
||||
assert overviews["privilege-escalation"].failed_findings == 2
|
||||
assert overviews["privilege-escalation"].muted_failed_findings == 1
|
||||
assert overviews["secrets"].total_findings == 1
|
||||
assert overviews["secrets"].failed_findings == 0
|
||||
assert overviews["secrets"].muted_failed_findings == 0
|
||||
|
||||
@patch("tasks.jobs.scan.Scan.all_objects.select_related")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
def test_aggregate_attack_surface_uses_select_related(
|
||||
@@ -5804,6 +6034,112 @@ class TestResetEphemeralResourceFindingsCount:
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 5
|
||||
|
||||
def test_runs_when_newer_scan_is_not_full_scope(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
"""A newer scoped scan must not block the full-scope scan's cleanup.
|
||||
|
||||
The race guard used to pick the newest COMPLETED scan of any scope
|
||||
despite being named after full-scope ones, so a single scoped scan
|
||||
landing after a complete one made the complete scan skip its cleanup
|
||||
and leave ephemeral resources with a stale count permanently.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
newer_completed_at = scan1.completed_at + timedelta(minutes=5)
|
||||
Scan.objects.create(
|
||||
name="Newer scoped scan",
|
||||
provider=aws_provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
tenant_id=tenant.id,
|
||||
started_at=newer_completed_at,
|
||||
completed_at=newer_completed_at,
|
||||
scanner_args={"checks": ["check1"]},
|
||||
)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
def test_runs_when_many_newer_scans_are_not_full_scope(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
"""The walk must not give up before it reaches the full-scope scan.
|
||||
|
||||
A fixed look-back window returned None once more newer scoped scans
|
||||
had landed than it inspected, and skipped the cleanup exactly like the
|
||||
original bug did with one.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
for minutes in range(1, 41):
|
||||
newer_completed_at = scan1.completed_at + timedelta(minutes=minutes)
|
||||
Scan.objects.create(
|
||||
name=f"Newer scoped scan {minutes}",
|
||||
provider=aws_provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
tenant_id=tenant.id,
|
||||
started_at=newer_completed_at,
|
||||
completed_at=newer_completed_at,
|
||||
scanner_args={"checks": ["check1"]},
|
||||
)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
def test_runs_when_completed_at_is_null(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
"""NULL `completed_at` used to make the reset never run at all.
|
||||
|
||||
The old guard filtered `completed_at__isnull=False`, so a provider
|
||||
whose completed scans all had a NULL `completed_at` resolved the
|
||||
"latest" scan to None, which never equals `scan.id`.
|
||||
"""
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Scan.all_objects.filter(id=scan1.id).update(completed_at=None)
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
def test_does_not_touch_other_providers_resources(
|
||||
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
|
||||
):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -14,6 +15,7 @@ from api.models import (
|
||||
StateChoices,
|
||||
Task,
|
||||
)
|
||||
from api.v1.serializers import TaskSerializer
|
||||
from botocore.exceptions import ClientError
|
||||
from celery import states
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||
@@ -32,11 +34,12 @@ from tasks.tasks import (
|
||||
_scan_tmp_output_directory,
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
create_scan_task_record,
|
||||
generate_outputs_task,
|
||||
mute_findings_in_latest_scans_task,
|
||||
perform_attack_paths_scan_task,
|
||||
perform_scan_task,
|
||||
perform_scheduled_scan_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
s3_integration_task,
|
||||
security_hub_integration_task,
|
||||
@@ -2959,6 +2962,7 @@ class TestPerformScheduledScanTask:
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile,
|
||||
self._override_task_request(perform_scheduled_scan_task, id=task_id),
|
||||
):
|
||||
perform_scheduled_scan_task.run(
|
||||
@@ -2982,6 +2986,13 @@ class TestPerformScheduledScanTask:
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
completed_scan = Scan.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.SCHEDULED,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id))
|
||||
assert (
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant.id,
|
||||
@@ -3176,7 +3187,10 @@ class TestPerformScanTask:
|
||||
task=queued_task,
|
||||
)
|
||||
|
||||
events = []
|
||||
|
||||
def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
|
||||
events.append("scan")
|
||||
scan_instance = Scan.objects.get(id=scan_id)
|
||||
scan_instance.state = StateChoices.COMPLETED
|
||||
scan_instance.save()
|
||||
@@ -3184,7 +3198,14 @@ class TestPerformScanTask:
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan),
|
||||
patch("tasks.tasks._perform_scan_complete_tasks"),
|
||||
patch(
|
||||
"tasks.tasks.reconcile_scan_mute_rules",
|
||||
side_effect=lambda *_args: events.append("reconcile"),
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks._perform_scan_complete_tasks",
|
||||
side_effect=lambda *_args: events.append("summaries"),
|
||||
),
|
||||
patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
|
||||
):
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
@@ -3196,6 +3217,7 @@ class TestPerformScanTask:
|
||||
|
||||
queued_task_result.refresh_from_db()
|
||||
assert result == {"status": "ok"}
|
||||
assert events == ["scan", "reconcile", "summaries"]
|
||||
assert queued_task_result.status == states.PENDING
|
||||
mock_apply_async.assert_called_once_with(
|
||||
kwargs={
|
||||
@@ -3241,10 +3263,7 @@ class TestPerformScanTask:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReaggregateAllFindingGroupSummaries:
|
||||
def setup_method(self):
|
||||
self.tenant_id = str(uuid.uuid4())
|
||||
|
||||
class TestMuteFindingsInLatestScansTask:
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@@ -3253,10 +3272,10 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dispatches_subtasks_for_each_provider_per_day(
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_reaggregates_only_changed_scans(
|
||||
self,
|
||||
mock_scan_filter,
|
||||
mock_mute_findings,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
@@ -3265,119 +3284,36 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
tenants_fixture,
|
||||
):
|
||||
provider_id_1 = uuid.uuid4()
|
||||
provider_id_2 = uuid.uuid4()
|
||||
scan_id_today_p1 = uuid.uuid4()
|
||||
scan_id_yesterday_p1 = uuid.uuid4()
|
||||
scan_id_today_p2 = uuid.uuid4()
|
||||
today = datetime.now(tz=UTC)
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
mock_outer_group_result = MagicMock()
|
||||
# The first `group()` call wraps the inner parallel step; subsequent
|
||||
# calls wrap the outer per-scan generator.
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": scan_id_today_p1,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
{
|
||||
"id": scan_id_today_p2,
|
||||
"completed_at": today,
|
||||
"provider_id": provider_id_2,
|
||||
},
|
||||
{
|
||||
"id": scan_id_yesterday_p1,
|
||||
"completed_at": yesterday,
|
||||
"provider_id": provider_id_1,
|
||||
},
|
||||
]
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 3}
|
||||
expected_scan_ids = {
|
||||
str(scan_id_today_p1),
|
||||
str(scan_id_today_p2),
|
||||
str(scan_id_yesterday_p1),
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
result = {
|
||||
"findings_muted": 2,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": scan_ids,
|
||||
}
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
assert task_mock.si.call_count == 3
|
||||
dispatched = {
|
||||
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
|
||||
}
|
||||
assert dispatched == expected_scan_ids
|
||||
for call in task_mock.si.call_args_list:
|
||||
assert call.kwargs["tenant_id"] == self.tenant_id
|
||||
assert mock_chain.call_count == 3
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task")
|
||||
@patch("tasks.tasks.aggregate_scan_category_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_scan_resource_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
|
||||
@patch("tasks.tasks.aggregate_daily_severity_task")
|
||||
@patch("tasks.tasks.perform_scan_summary_task")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_dedupes_scans_to_latest_per_provider_per_day(
|
||||
self,
|
||||
mock_scan_filter,
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
mock_finding_group_task,
|
||||
mock_resource_group_task,
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
mock_group,
|
||||
mock_chain,
|
||||
):
|
||||
"""When several scans run on the same day for the same provider, only
|
||||
the latest one is dispatched (matching the daily summary unique key)."""
|
||||
provider_id = uuid.uuid4()
|
||||
latest_scan_today = uuid.uuid4()
|
||||
earlier_scan_today = uuid.uuid4()
|
||||
today_late = datetime.now(tz=UTC)
|
||||
today_early = today_late - timedelta(hours=4)
|
||||
|
||||
mock_mute_findings.return_value = result
|
||||
mock_outer_group_result = MagicMock()
|
||||
mock_group.side_effect = lambda *args, **kwargs: (
|
||||
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
|
||||
mock_outer_group_result,
|
||||
)[1]
|
||||
|
||||
# Returned ordered by `-completed_at`, so the most recent comes first.
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
|
||||
{
|
||||
"id": latest_scan_today,
|
||||
"completed_at": today_late,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
{
|
||||
"id": earlier_scan_today,
|
||||
"completed_at": today_early,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
]
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 1}
|
||||
assert task_result == result
|
||||
mock_mute_findings.assert_called_once_with(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=provider_ids,
|
||||
)
|
||||
for task_mock in (
|
||||
mock_scan_summary_task,
|
||||
mock_daily_severity_task,
|
||||
@@ -3386,23 +3322,35 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
mock_category_task,
|
||||
mock_attack_surface_task,
|
||||
):
|
||||
task_mock.si.assert_called_once_with(
|
||||
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
|
||||
)
|
||||
mock_chain.assert_called_once()
|
||||
assert task_mock.si.call_count == 2
|
||||
assert {
|
||||
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
|
||||
} == set(scan_ids)
|
||||
assert mock_chain.call_count == 2
|
||||
mock_outer_group_result.apply_async.assert_called_once()
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
@patch("tasks.tasks.Scan.objects.filter")
|
||||
def test_no_completed_scans_skips_dispatch(
|
||||
self, mock_scan_filter, mock_group, mock_chain
|
||||
@patch("tasks.tasks.mute_findings_in_latest_scans")
|
||||
def test_skips_reaggregation_when_no_scan_changed(
|
||||
self, mock_mute_findings, mock_group, mock_chain, tenants_fixture
|
||||
):
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
mute_rule_id = str(uuid.uuid4())
|
||||
result = {
|
||||
"findings_muted": 0,
|
||||
"rule_id": mute_rule_id,
|
||||
"scan_ids": [],
|
||||
}
|
||||
mock_mute_findings.return_value = result
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
task_result = mute_findings_in_latest_scans_task(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=mute_rule_id,
|
||||
provider_ids=[],
|
||||
)
|
||||
|
||||
assert result == {"scans_reaggregated": 0}
|
||||
assert task_result == result
|
||||
mock_group.assert_not_called()
|
||||
mock_chain.assert_not_called()
|
||||
|
||||
@@ -3439,3 +3387,79 @@ class TestTaskTimeLimits:
|
||||
"lighthouse-provider-connection-check",
|
||||
):
|
||||
assert celery_app.tasks[name].time_limit < default
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCreateScanTaskRecord:
|
||||
"""`task_kwargs` is what a response built before the publish can report."""
|
||||
|
||||
def _scan(self, tenant, provider):
|
||||
"""A manual scan, like the one `POST /api/v1/scans` creates."""
|
||||
return Scan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
name="Manual scan",
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.AVAILABLE,
|
||||
)
|
||||
|
||||
def _publish_kwargs(self, tenant, scan):
|
||||
"""What `enqueue_scan_execution_on_commit` publishes for this scan."""
|
||||
return {
|
||||
"tenant_id": str(tenant.id),
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
}
|
||||
|
||||
def _task_args(self, task):
|
||||
"""Read the record back the way `TaskSerializer` does."""
|
||||
return TaskSerializer(task).data["task_args"]
|
||||
|
||||
def test_the_stored_kwargs_are_the_ones_the_publish_would_send(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""The 202 reports what is stored here, so it has to be the dispatch kwargs."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = self._scan(tenant, aws_provider)
|
||||
|
||||
task = create_scan_task_record(
|
||||
tenant_id=str(tenant.id),
|
||||
task_id=str(uuid.uuid4()),
|
||||
task_kwargs=self._publish_kwargs(tenant, scan),
|
||||
)
|
||||
|
||||
assert self._task_args(task) == {
|
||||
"scan_id": str(scan.id),
|
||||
"provider_id": str(aws_provider.id),
|
||||
}
|
||||
|
||||
def test_a_record_created_without_kwargs_reports_none(self, tenants_fixture):
|
||||
"""The argument is optional, so the other callers keep their behaviour."""
|
||||
task = create_scan_task_record(
|
||||
tenant_id=str(tenants_fixture[0].id),
|
||||
task_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
assert self._task_args(task) == {}
|
||||
|
||||
def test_the_publish_can_overwrite_the_stored_kwargs(
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""django-celery-results stores a Python repr; both must decode alike."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = self._scan(tenant, aws_provider)
|
||||
task_id = str(uuid.uuid4())
|
||||
kwargs = self._publish_kwargs(tenant, scan)
|
||||
|
||||
task = create_scan_task_record(
|
||||
tenant_id=str(tenant.id), task_id=task_id, task_kwargs=kwargs
|
||||
)
|
||||
before = self._task_args(task)
|
||||
|
||||
# What `before_task_publish` writes once the task reaches the broker.
|
||||
task_result = TaskResult.objects.get(task_id=task_id)
|
||||
task_result.task_kwargs = json.dumps(repr(kwargs))
|
||||
task_result.save(update_fields=["task_kwargs"])
|
||||
task.refresh_from_db()
|
||||
|
||||
assert self._task_args(task) == before
|
||||
|
||||
@@ -53,7 +53,7 @@ constraints = [
|
||||
{ name = "aliyun-log-fastpb", specifier = "==0.2.0" },
|
||||
{ name = "amqp", specifier = "==5.3.1" },
|
||||
{ name = "annotated-types", specifier = "==0.7.0" },
|
||||
{ name = "anyio", specifier = "==4.12.1" },
|
||||
{ name = "anyio", specifier = "==4.14.2" },
|
||||
{ name = "applicationinsights", specifier = "==0.11.10" },
|
||||
{ name = "apscheduler", specifier = "==3.11.2" },
|
||||
{ name = "argcomplete", specifier = "==3.5.3" },
|
||||
@@ -291,7 +291,7 @@ constraints = [
|
||||
{ name = "pydantic-core", specifier = "==2.41.5" },
|
||||
{ name = "pygithub", specifier = "==2.8.0" },
|
||||
{ name = "pygments", specifier = "==2.20.0" },
|
||||
{ name = "pyjwt", specifier = "==2.13.0" },
|
||||
{ name = "pyjwt", specifier = "==2.14.0" },
|
||||
{ name = "pylint", specifier = "==3.2.5" },
|
||||
{ name = "pymsalruntime", specifier = "==0.18.1" },
|
||||
{ name = "pynacl", specifier = "==1.6.2" },
|
||||
@@ -387,7 +387,7 @@ overrides = [
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
|
||||
{ name = "okta", specifier = "==3.4.2" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.14.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -969,15 +969,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4835,8 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
|
||||
version = "5.41.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4928,14 +4928,17 @@ dependencies = [
|
||||
{ name = "stackit-iaas" },
|
||||
{ name = "stackit-objectstorage" },
|
||||
{ name = "stackit-resourcemanager" },
|
||||
{ name = "stackit-ske" },
|
||||
{ name = "tabulate" },
|
||||
{ name = "truststore" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uuid6" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.42.0"
|
||||
version = "1.46.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -5329,11 +5332,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.13.0"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -6117,6 +6120,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-ske"
|
||||
version = "1.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/9e/df3ad585cb96d028354f4253568e9879d81bb9395d5ebfa268fa9350e2df/stackit_ske-1.12.0.tar.gz", hash = "sha256:62814279f3b7fb2387648f92d14453a8905ad60115c07579f2741ddb7d1fcc94", size = 37239, upload-time = "2026-06-30T11:18:49.39Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/37/dc54fb7185a2d4da37308322ea1a7b992312030b2e37262de4eb4003f5c7/stackit_ske-1.12.0-py3-none-any.whl", hash = "sha256:45bd8084d87f14f818b3d7e824450248c8784ed204ca1b2dc108f491dcbdb1a3", size = 93142, upload-time = "2026-06-30T11:18:48.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statsd"
|
||||
version = "4.0.1"
|
||||
@@ -6225,6 +6243,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.21.1"
|
||||
@@ -6621,6 +6648,48 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.2"
|
||||
|
||||
@@ -212,6 +212,14 @@ mainConfig:
|
||||
# MEDIUM
|
||||
ecr_repository_vulnerability_minimum_severity: "MEDIUM"
|
||||
|
||||
# AWS Inspector2
|
||||
# aws.inspector2_coverage_recently_scanned
|
||||
# Maximum days since Inspector2 last scanned an actively covered resource
|
||||
inspector2_max_days_since_last_scan: 3
|
||||
# aws.inspector2_active_findings_within_max_age
|
||||
# Maximum days an Inspector2 finding can stay active since it was first observed
|
||||
inspector2_active_finding_max_age_days: 192
|
||||
|
||||
# AWS Trusted Advisor
|
||||
# aws.trustedadvisor_premium_support_plan_subscribed
|
||||
verify_premium_support_plans: True
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_cis
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
].copy()
|
||||
|
||||
# Shorten the long FedRAMP KSI descriptions for better display
|
||||
ksi_short_names = {
|
||||
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
|
||||
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
|
||||
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
|
||||
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
|
||||
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
|
||||
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
|
||||
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
|
||||
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
|
||||
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
|
||||
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
|
||||
}
|
||||
|
||||
# Replace long descriptions with short names - use contains for partial matching
|
||||
if not aux.empty:
|
||||
for long_desc, short_name in ksi_short_names.items():
|
||||
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
|
||||
long_desc, na=False, regex=False
|
||||
)
|
||||
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
|
||||
|
||||
return get_section_containers_cis(
|
||||
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_cis
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
].copy()
|
||||
|
||||
# Shorten the long FedRAMP KSI descriptions for better display
|
||||
ksi_short_names = {
|
||||
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
|
||||
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
|
||||
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
|
||||
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
|
||||
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
|
||||
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
|
||||
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
|
||||
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
|
||||
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
|
||||
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
|
||||
}
|
||||
|
||||
# Replace long descriptions with short names - use contains for partial matching
|
||||
if not aux.empty:
|
||||
for long_desc, short_name in ksi_short_names.items():
|
||||
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
|
||||
long_desc, na=False, regex=False
|
||||
)
|
||||
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
|
||||
|
||||
return get_section_containers_cis(
|
||||
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from dashboard.common_methods import get_section_containers_cis
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def get_table(data):
|
||||
aux = data[
|
||||
[
|
||||
"REQUIREMENTS_ID",
|
||||
"REQUIREMENTS_DESCRIPTION",
|
||||
"REQUIREMENTS_ATTRIBUTES_SECTION",
|
||||
"CHECKID",
|
||||
"STATUS",
|
||||
"REGION",
|
||||
"ACCOUNTID",
|
||||
"RESOURCEID",
|
||||
]
|
||||
].copy()
|
||||
|
||||
# Shorten the long FedRAMP KSI descriptions for better display
|
||||
ksi_short_names = {
|
||||
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
|
||||
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
|
||||
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
|
||||
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
|
||||
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
|
||||
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
|
||||
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
|
||||
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
|
||||
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
|
||||
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
|
||||
}
|
||||
|
||||
# Replace long descriptions with short names - use contains for partial matching
|
||||
if not aux.empty:
|
||||
for long_desc, short_name in ksi_short_names.items():
|
||||
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
|
||||
long_desc, na=False, regex=False
|
||||
)
|
||||
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
|
||||
|
||||
return get_section_containers_cis(
|
||||
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
api-dev-init:
|
||||
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
|
||||
image: busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
|
||||
volumes:
|
||||
- ./_data/api:/data
|
||||
command: ["sh", "-c", "chown -R 1000:1000 /data"]
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
hostname: "postgres-db"
|
||||
volumes:
|
||||
- ./_data/postgres:/var/lib/postgresql/data
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
|
||||
image: valkey/valkey:8-alpine@sha256:081c2f5cb575efc901aa80ff9cdbd1ec6a301682fd35e1ebb4b0990a4a4a8507
|
||||
hostname: "valkey"
|
||||
volumes:
|
||||
- ./_data/valkey:/data
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#
|
||||
services:
|
||||
api-init:
|
||||
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
|
||||
image: busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
|
||||
volumes:
|
||||
- ./_data/api:/data
|
||||
command: ["sh", "-c", "chown -R 1000:1000 /data"]
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
api:
|
||||
hostname: "prowler-api"
|
||||
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
@@ -44,6 +45,7 @@ services:
|
||||
|
||||
ui:
|
||||
image: prowlercloud/prowler-ui:${PROWLER_UI_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
@@ -60,7 +62,8 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
|
||||
restart: unless-stopped
|
||||
hostname: "postgres-db"
|
||||
volumes:
|
||||
- ./_data/postgres:/var/lib/postgresql/data
|
||||
@@ -80,7 +83,8 @@ services:
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
|
||||
image: valkey/valkey:8-alpine@sha256:081c2f5cb575efc901aa80ff9cdbd1ec6a301682fd35e1ebb4b0990a4a4a8507
|
||||
restart: unless-stopped
|
||||
hostname: "valkey"
|
||||
volumes:
|
||||
- ./_data/valkey:/data
|
||||
@@ -97,6 +101,7 @@ services:
|
||||
|
||||
neo4j:
|
||||
image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0
|
||||
restart: unless-stopped
|
||||
hostname: "neo4j"
|
||||
volumes:
|
||||
- ./_data/neo4j:/data
|
||||
@@ -129,6 +134,7 @@ services:
|
||||
|
||||
worker:
|
||||
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
# Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop.
|
||||
stop_grace_period: 120s
|
||||
env_file:
|
||||
@@ -149,6 +155,7 @@ services:
|
||||
|
||||
worker-beat:
|
||||
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
@@ -165,6 +172,7 @@ services:
|
||||
|
||||
mcp-server:
|
||||
image: prowlercloud/prowler-mcp:${PROWLER_MCP_VERSION:-stable}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PROWLER_MCP_TRANSPORT_MODE=http
|
||||
env_file:
|
||||
|
||||
@@ -4,6 +4,283 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.44.0" description="September 29, 2026">
|
||||
### 🔁 Findings — Re-check a Resource with a Partial Scan
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
A resource that has just been fixed can be confirmed from the Findings page without waiting for the next full scan. **Re-check resource** is available in the actions menu of every resource row and in the resource detail drawer, and a hint icon next to **Last seen** opens it directly. It launches a partial scan that runs again only the checks that last reported on that resource; its findings update when the scan completes, and every other resource keeps the results of the latest full scan. Roles need the Manage Scans permission, and a re-check is refused while the provider has a scan running or queued.
|
||||
|
||||
Re-checked resources that now pass drop out of the finding groups list and its drill-down instead of opening a detail panel that still reports `FAIL`. Partial scans do not change overviews or compliance until the next full scan and produce no report files, so the Scans table marks them as **Partial**, offers no report download for them, and the per-scan Compliance selector leaves them out.
|
||||
|
||||
Partial scans can also be launched outside the Findings page:
|
||||
|
||||
- **API:** `POST /api/v1/scans` accepts up to 10 resources in `resource_uids`, and scans expose `is_partial` with a `filter[is_partial]` filter.
|
||||
- **MCP Server:** `prowler_trigger_scan` takes a `resource_uids` argument, and `prowler_list_scans` and `prowler_get_scan` return `is_partial`, with an `is_partial` filter on `prowler_list_scans`.
|
||||
- **Lighthouse AI:** can launch a partial scan to re-check specific resources, such as confirming a remediation.
|
||||
|
||||
### ☁️ AWS — Connect an Account in One Step
|
||||
|
||||
The Add Provider wizard connects an AWS account in a single step. The account ID is read from the role ARN (or typed when using static access keys), the role is assumed with Prowler's own credentials, and the account, its credentials and the connection test are handled by one submit. A confirmed connection goes straight to the launch step. A refused connection stays on the form with the reason the API returned, so the fields can be fixed and retried without registering the account twice.
|
||||
|
||||
New tenants without providers now land on this wizard on their first sign-in instead of a welcome modal, and the sidebar action reads **Add Provider** until the first provider is connected.
|
||||
|
||||
Read more in the [Getting Started with AWS documentation](https://docs.prowler.com/user-guide/providers/aws/getting-started-aws).
|
||||
|
||||
### 🔌 Connection Tests No Longer Give Up Early
|
||||
|
||||
The provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable. The UI now waits for the full time limit of the backend task, and if that is still exhausted it shows the provider's current connection state instead of a failure.
|
||||
|
||||
On the SDK side, STS calls after a role assumption reuse the region that answered, so an unreachable partition region is waited on once instead of twice. The new `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable sets the Boto3 retries for deployments that build the AWS provider without CLI flags, next to the existing timeout variables; `0` disables retries.
|
||||
|
||||
Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration#retries-configuration).
|
||||
|
||||
### 🗄️ Self-Hosted — S3-Compatible Storage and Air-Gapped Deployments
|
||||
|
||||
- `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL` points scan output uploads and downloads at S3-compatible object storage such as MinIO. Previously the only way to reach it was exporting process-wide AWS environment variables, which also hijacked unrelated AWS API calls such as role assumption for AWS providers.
|
||||
- Report downloads from a bucket with default SSE-KMS encryption no longer fail with `InvalidArgument`: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, download URLs are signed with Signature Version 4 for that region.
|
||||
- Icons ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly without internet access.
|
||||
- Celery worker fatal errors are logged instead of silenced, and every long-running service in `docker-compose.yml` restarts automatically after an unexpected crash.
|
||||
|
||||
### 📊 Consistent Latest Scan Across Endpoints
|
||||
|
||||
Every endpoint now resolves a provider's latest completed scan the same way, so overlapping scans no longer make findings, compliance and mute rules read from different scans. Providers whose latest completed scan has no `completed_at` timestamp are no longer missing from those endpoints, and resources no longer keep a stale failed findings count when a scoped or imported scan completes after a full scan.
|
||||
|
||||
### 🛠️ Prowler App Fixes
|
||||
|
||||
- API key authentication no longer locks the key row on every request, so a heavily used key no longer serializes all its requests; `last_used_at` is updated at most once per minute.
|
||||
- `POST /api/v1/scans` returns the new scan ID in `task_args` again.
|
||||
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j.
|
||||
- A periodic sweep drops orphaned Attack Paths temporary Neo4j databases left behind when a worker or Neo4j crashes mid-scan.
|
||||
- **Prowler Cloud:** imported findings no longer stay stuck in `pending` when the ingestion worker picks up the job before it is committed, and a failed enqueue marks the ingestion as failed.
|
||||
- **Prowler Cloud:** the Lighthouse AI connection check reports a network failure as one, naming the endpoint it could not reach, instead of hitting a time limit that looked the same as a bad key.
|
||||
- **Prowler Cloud:** the finding groups endpoints no longer query Manual Pass triages once per finding, and skip that overlay for tenants with no active Manual Pass.
|
||||
- The Findings page renders a skeleton at once and streams the table before the filters, and the **Finding Group** options load when the dropdown opens.
|
||||
- Mute rule creation errors show the API error message instead of the raw response body.
|
||||
- The sidebar no longer throws a hydration error on full page loads for users who last used the chat mode.
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the role's visibility.
|
||||
- The UI E2E workflow receives its AWS credentials through environment variables instead of template expansion.
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.44.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.43.0" description="September 21, 2026">
|
||||
### 🏛️ Compliance — FedRAMP 20x Consolidated Rules 2026
|
||||
|
||||
The FedRAMP 20x Phase One pilot frameworks (`fedramp_20x_ksi_low_aws`, `fedramp_20x_ksi_low_azure` and `fedramp_20x_ksi_low_gcp`) are replaced by two universal frameworks built from the FedRAMP Consolidated Rules for 2026, each covering AWS, Azure, GCP, Kubernetes and Microsoft 365 from a single definition:
|
||||
|
||||
- **FedRAMP 20x KSI** (`fedramp_20x_ksi_2026`): the 46 Key Security Indicators across 10 themes. There is one indicator catalog for every class instead of a separate Low fork; each indicator carries its class applicability and NIST SP 800-53 controls.
|
||||
- **FedRAMP 20x Class C FRR** (`fedramp_20x_frr_class_c_2026`): the 158 FedRAMP Rules of the Class C ruleset that bind cloud service providers. Most are program obligations (reports, notifications, certification package) and stay manual; checks are mapped only where they evidence part of the rule text. Configurable checks carry configuration requirements, so a relaxed `audit_config` cannot turn a requirement green.
|
||||
|
||||
Automation or stored results that reference the pilot framework IDs need to move to `fedramp_20x_ksi_2026`. In Prowler App, both frameworks show the per-provider breakdown in the cross-provider compliance view and can be downloaded as OCSF.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔎 AWS — Inspector Coverage, CISA KEV and FIPS Checks
|
||||
|
||||
Seven new AWS checks back the vulnerability detection and cryptography rules of FedRAMP 20x Class C:
|
||||
|
||||
- `inspector2_coverage_scan_status_active` and `inspector2_coverage_recently_scanned` report resources Amazon Inspector is not scanning, or last scanned more than `inspector2_max_days_since_last_scan` days ago (default 3).
|
||||
- `inspector2_active_findings_no_known_exploited_vulnerabilities` and `inspector2_active_findings_kev_within_due_date` report active findings whose CVE is in the CISA Known Exploited Vulnerabilities catalog, and those still open past the CISA due date. The KEV data comes from Inspector itself through `inspector2:BatchGetFindingDetails`, so no external feed is needed.
|
||||
- `inspector2_active_findings_within_max_age` reports active findings first observed more than `inspector2_active_finding_max_age_days` days ago (default 192).
|
||||
- `elbv2_listener_fips_tls_enabled` and `transfer_server_fips_security_policy_enabled` report HTTPS/TLS load balancer listeners and Transfer Family servers without a FIPS security policy.
|
||||
|
||||
`inspector2:BatchGetFindingDetails` is not part of `SecurityAudit`, so it is now included in the Prowler additions policy and the CloudFormation scan role. Without it, the KEV checks report `MANUAL` naming the missing permission instead of a false `FAIL`.
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### ☁️ AWS — Partition Bootstrap Failover
|
||||
|
||||
When `PROWLER_AWS_PARTITION` is set, the bootstrap STS calls (validating credentials, assuming a role and getting an MFA session token) now try up to two more regions of the partition if the first one cannot be reached. A GovCloud host whose configured region belongs to another partition was still sent to `us-gov-east-1`, and on a network that routes only to `us-gov-west-1` the connection check and the scan failed on perfectly valid credentials. Only connection errors and timeouts move on to the next region; credential errors are reported from the first one as before. Later STS calls reuse the region that answered, and nothing changes when `PROWLER_AWS_PARTITION` is unset.
|
||||
|
||||
Read more in the [AWS Regions and Partitions documentation](https://docs.prowler.com/user-guide/providers/aws/regions-and-partitions).
|
||||
|
||||
### 🌐 Azure — Sovereign Cloud Endpoints for Defender and Key Vault
|
||||
|
||||
Defender security contacts and Key Vault key rotation policies now use the endpoints of the cloud selected with `--azure-region` instead of the hardcoded `management.azure.com` and `vault.azure.net` hosts, so both work on `AzureUSGovernment` and `AzureChinaCloud`. Key Vault clients are built from the vault URI that Azure returns for each vault.
|
||||
|
||||
Read more in the [Azure non-default cloud documentation](https://docs.prowler.com/user-guide/providers/azure/use-non-default-cloud).
|
||||
|
||||
### ✉️ Invitations — Expired Invitations No Longer Block Re-Invites
|
||||
|
||||
A pending invitation past its expiry date is now reported as expired, and inviting the same email again marks it as expired and creates the new invitation instead of returning a generic error. The Invitations table disables Edit and Revoke on expired and revoked invitations, and `filter[state__in]` on the invitations endpoint no longer returns a server error.
|
||||
|
||||
In Prowler Cloud, new organizations are offered an **Invite your team** step once the first provider is connected, and Prowler Private Cloud deployments can set `UI_SELF_REGISTRATION_ENABLED=false` to make sign-up invitation-only.
|
||||
|
||||
Read more in the [Invitations documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-rbac#invitations).
|
||||
|
||||
### 📄 Reports — Downloads on Self-Hosted Storage
|
||||
|
||||
Report downloads no longer depend on the storage host being the same inside and outside the container network. `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL` signs the download URL against a browser-reachable host, so a deployment whose object storage answers only on an internal address serves the file instead of a link the browser cannot open. Leaving `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` unset, common on S3-compatible storage with no meaningful region, no longer makes the download fail with a server error.
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
- **Huawei Cloud:** new `smn_topic_subscriptions` check reports SMN topics without any subscription.
|
||||
- **Cloudflare:** the API token links in the provider wizard request the SSL and Certificates, Bot Management and Zone WAF read permissions the checks need, so a token created from the wizard no longer produces failures on permissions it was never granted.
|
||||
- **Microsoft 365:** five Defender malware, anti-phishing and inbound anti-spam checks no longer fail with `KeyError` on tenants that use the Standard or Strict preset security policies, which dropped every finding of those checks. Preset policies are covered by `defender_strict_preset_security_policy_enabled`.
|
||||
- **Google Workspace:** `security_2sv_enforced` reports domain-wide 2-Step Verification failures as `FAIL` even when every failing setting is overridden for a group or organizational unit.
|
||||
|
||||
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- `libsqlite3-0`, `gzip`, `perl-base`, `libssh2-1t64` and `libpcre2-8-0` upgraded in the SDK and API container images, patching high-severity Debian CVEs.
|
||||
- PowerShell upgraded to 7.5.11 in the SDK and API container images, bundling .NET runtime 9.0.20 and patching CVE-2026-62901.
|
||||
- `anyio` upgraded to 4.14.2 in the SDK, the API and the MCP Server, patching CVE-2026-63374.
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.43.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.42.0" description="September 11, 2026">
|
||||
### ☁️ AWS — ISO Partitions
|
||||
|
||||
Prowler now resolves regions and services for the AWS ISO partitions (`aws-iso`, `aws-iso-b`, `aws-iso-e` and `aws-iso-f`) the same way it does for the commercial, China, European Sovereign Cloud and GovCloud partitions. The region matrix is filled from the endpoint metadata bundled with botocore, which needs no credentials or network access, so it covers partitions that are air-gapped from the internet. Scanning them no longer requires a hand-edited `aws_regions_by_service.json`: ISO regions such as `us-isob-east-1` are accepted by `--region` and `--excluded-region`.
|
||||
|
||||
Deployments that declare `PROWLER_AWS_PARTITION` also keep their bootstrap STS calls in the configured region when it belongs to that partition. An install in `us-gov-west-1` that reaches AWS only through its own VPC endpoints is no longer sent to `us-gov-east-1`, where the connection check and the scan used to time out.
|
||||
|
||||
Read more in the [AWS Regions and Partitions documentation](https://docs.prowler.com/user-guide/providers/aws/regions-and-partitions).
|
||||
|
||||
### ⏱️ AWS — Configurable Timeouts for Restricted Networks
|
||||
|
||||
Scans from networks with restricted egress (VPC endpoints for only some services, GovCloud or private deployments) could take hours: Boto3 waits 60 seconds to connect by default and retries connection errors, so every service without a reachable endpoint cost up to four 60-second attempts in every region. Prowler now lowers the default connect timeout to 10 seconds, keeps the read timeout at 60 seconds, and exposes both through `--aws-connect-timeout` and `--aws-read-timeout`, or through the `PROWLER_AWS_BOTO3_CONNECT_TIMEOUT` and `PROWLER_AWS_BOTO3_READ_TIMEOUT` environment variables for deployments without a CLI. `--aws-retries-max-attempts 0` now disables retries instead of silently falling back to three, leaving a single attempt per call.
|
||||
|
||||
Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration).
|
||||
|
||||
### 🐳 Image Provider — Reusable Vulnerability Database
|
||||
|
||||
The Image provider now honors `TRIVY_CACHE_DIR`. When the variable names a directory, Trivy keeps its vulnerability database there and Prowler leaves the directory in place after the scan, so the database is downloaded once instead of on every scan. Hosts without internet access can now scan images by pointing `TRIVY_CACHE_DIR` at a pre-populated database and setting `TRIVY_SKIP_DB_UPDATE=true`. Without the variable, the temporary cache is created and removed as before.
|
||||
|
||||
Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#vulnerability-database-cache).
|
||||
|
||||
### 🎫 Jira Integration — Faster Connection Test
|
||||
|
||||
Testing a Jira integration no longer reports a false failure on accounts with many projects. The connection test fetched the issue types of every project one request at a time, which could outlast the wait in the UI even when the check was about to succeed. Issue types are now fetched concurrently, a project whose issue types the integration user cannot see is no longer logged as an error, and the Integrations page keeps following the connection test instead of giving up after about a minute.
|
||||
|
||||
Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration).
|
||||
|
||||
### 📚 Compliance — Catalog Integrity Fixes
|
||||
|
||||
A new integrity test runs over every compliance framework, asserting unique requirement IDs, no check listed twice within a requirement, and that every referenced check exists for its provider. The fixes it drove span 42 frameworks across AWS, Azure, GCP, GitHub, Kubernetes and Microsoft 365:
|
||||
|
||||
- **Duplicate requirement IDs:** identical copies are removed, and distinct requirements that shared an ID get their own, such as `1.10` in CIS AWS 5.0 and `rc_rp_1` for RC.RP-1 in NIST CSF 1.1. In Prowler ThreatScore for Azure, SQL auditing retention moves from `3.2.1` to `3.2.4`, and requirement `1.2.1` of Prowler ThreatScore for GCP now points to `iam_sa_no_user_managed_keys`.
|
||||
- **Stale check references:** checks that no longer exist are replaced with their current name when there is a direct equivalent, or removed so the requirement reports as manual. Most of these were in the FedRAMP 20x KSI frameworks.
|
||||
|
||||
Renamed requirement IDs appear as new requirements for scans run after the upgrade.
|
||||
|
||||
The compliance overview task that runs after every scan is also faster: ThreatScore mappings are read once from the compliance template instead of from every finding, and rows are inserted with time-ordered `uuid7` IDs grouped by framework and requirement.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
`rolesanywhere_profile_restricts_session_permissions`, `iam_role_service_trust_restricts_source_to_account` and `codebuild_project_uses_allowed_github_organizations` no longer crash with `TypeError` when the scanning role is denied `iam:ListRoles`, which dropped every finding of those checks for the account. Without the role inventory, an enabled IAM Roles Anywhere profile without session scoping reports `MANUAL`, and CodeBuild projects whose service role cannot be resolved are skipped.
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- `next` upgraded to 16.3.3 in the UI, patching unauthenticated remote code execution through AVIF image optimization ([GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4)) and on Windows-hosted servers ([GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36)).
|
||||
- `sharp` upgraded to 0.35.4 in the UI, patching libheif image-decoding vulnerabilities ([GHSA-rgj7-g3m4-5g8c](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c)).
|
||||
- `nanoid`, `js-yaml` and `postcss`, plus eleven transitive UI dependencies, upgraded to patched versions, resolving 40 npm audit advisories (21 high, 15 moderate, 4 low).
|
||||
- `libuuid` upgraded to 2.41.6-r1 in the MCP Server image, patching CVE-2026-53612, CVE-2026-53613, CVE-2026-53614, CVE-2026-76642, CVE-2026-78408 and CVE-2026-78410.
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.42.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.41.0" description="September 2, 2026">
|
||||
### 📥 Scans — Import Findings from the Browser
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Findings produced outside the platform, by the Prowler CLI or a CI pipeline, can now be brought into the app without leaving the browser. The Scans page gains an "Import Findings" dialog that takes a Prowler `.ocsf.json` report by drag-and-drop or file picker, hands it to the ingestion API and tracks the job to completion, reporting how many records were processed and how many were invalid. Files that are not a `.ocsf.json` report, or are empty, are refused before any upload starts, and a rejected upload or a failed status poll can be retried in place. The dialog is available to roles holding the Manage Ingestions permission.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-ui).
|
||||
|
||||
### 🎫 Jira Integration — Finding Reference in Every Issue
|
||||
|
||||
Every Jira issue created from a finding now carries a stable reference back to it. Issues are labeled `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`, so they can be filtered, searched with JQL or matched by automation; labels are sanitized to Jira's limits so a long or unusual value never blocks issue creation. The issue also links back to the finding in Prowler, filtered by its UID so the link keeps working after later scans, and names the Prowler organization that sent it. Prowler Cloud always includes the link; Prowler Local Server enables it by setting `DJANGO_UI_BASE_URL` in the API environment.
|
||||
|
||||
Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration).
|
||||
|
||||
### 📚 Compliance — CIS Google Workspace Foundations Benchmark v1.4.0
|
||||
|
||||
Prowler now ships the CIS Google Workspace Foundations Benchmark v1.4.0. Alongside the new framework, the Google Workspace checks mapped to CIS were reworked to evaluate the benchmark's full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass. Expect new `FAIL` findings on domains that rely on those defaults. Three accuracy fixes also land:
|
||||
|
||||
- `security_2sv_enforced` and `security_2sv_hardware_keys_admins` report `MANUAL` instead of judging domain-wide values that a group or a sub-organizational unit overrides; a domain-wide failure is still reported as such, with the override noted.
|
||||
- `rules_*_alert_configured` no longer passes a rule whose delivery to the alert center is disabled.
|
||||
- `security_password_policy_strong` no longer fails a domain that never touched the password strength setting, since Google enforces strong passwords by default.
|
||||
|
||||
`security_login_challenges_configured` was unmapped from CIS Google Workspace requirement 4.1.4.1 (Post-SSO verification) and `security_2sv_enforced` from CISA SCuBA `GWS.COMMONCONTROLS.1.1` (phishing-resistant MFA), because neither check can prove what those requirements ask for.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
Ten new AWS checks land in this release, eight of them contributed by @tamg-aws. Thank you!
|
||||
|
||||
#### Amazon Bedrock AgentCore
|
||||
|
||||
- `iam_policy_passrole_to_bedrock_agentcore_restricted` flags customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy could run agent code under any role in the account.
|
||||
- `iam_policy_no_agentcore_workload_access_token_wildcard` flags customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own.
|
||||
- `cloudwatch_log_group_agentcore_data_protection_policy_enabled` verifies that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy. The log group prefixes are configurable through `agentcore_log_group_name_prefixes` in `config.yaml`.
|
||||
|
||||
#### Amazon GuardDuty
|
||||
|
||||
- `guardduty_runtime_monitoring_enabled` flags detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS.
|
||||
- `guardduty_ai_protection_enabled` flags detectors without AI Protection, which analyzes CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI. A detector that does not report the feature is `MANUAL` rather than `FAIL`.
|
||||
|
||||
`guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS.
|
||||
|
||||
#### Amazon ECR and EKS
|
||||
|
||||
- `ecr_registry_enhanced_scanning_enabled` verifies that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, reporting `MANUAL` when the registry scanning configuration cannot be read.
|
||||
- `eks_cluster_vpc_cni_network_policy_enforced` flags EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, reporting `MANUAL` where the EKS API cannot show the setting.
|
||||
|
||||
#### AWS IAM, Elastic Beanstalk and MemoryDB
|
||||
|
||||
- `iam_role_service_trust_restricts_source_to_account` flags IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate.
|
||||
- `elasticbeanstalk_environment_no_secrets_in_configuration` scans the option settings of every Elastic Beanstalk environment for hardcoded secrets. Thanks to @haneul-24!
|
||||
- `memorydb_cluster_in_transit_encryption_enabled` verifies that MemoryDB clusters have in-transit encryption (TLS) enabled. Thanks to @UTKARSH698!
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🐳 Image Provider — On-Premises Registries
|
||||
|
||||
Scanning registries that live on private networks is now supported end to end. `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` takes a comma-separated list of IPs and CIDRs the provider may reach, while every other non-public address, including link-local and loopback, stays blocked by the SSRF guard. Authentication negotiation is also more resilient: the provider falls back to Basic when a registry such as Harbor rejects the negotiated bearer token, and switches to a bearer token when the server answers a Basic or anonymous request with a Bearer challenge. `--registry-insecure` now propagates to Trivy through `TRIVY_INSECURE`, so images behind self-signed certificates can be pulled and scanned, not just enumerated. The flag now disables certificate validation for the image pull too, so keep it for trusted internal registries only.
|
||||
|
||||
Registry scans also skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations), no longer abort the whole scan when Trivy fails on a single image, and enumerate repositories in parallel instead of one request at a time.
|
||||
|
||||
Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#on-premises-registries-and-private-networks).
|
||||
|
||||
### 🛠️ Prowler MCP Server — Tool Failures Reported as Errors
|
||||
|
||||
Prowler Local Server tools now report a failure as an MCP tool execution error (`isError: true`, with the explanation in `content`) instead of a successful result carrying an `{"error": ...}` object, which clients and models read as a success. The Prowler Documentation and Prowler Hub tools follow the same rule: `prowler_docs_search` no longer reports a failed search as zero matches, `prowler_docs_get_document` no longer reports a failed fetch as a missing page, and `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now name the provider a check ID actually belongs to instead of reporting it as nonexistent. `prowler_get_compliance_framework_state_details` also rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider.
|
||||
|
||||
Read more in the [Prowler MCP documentation](https://docs.prowler.com/getting-started/products/prowler-mcp).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @tamg-aws: GuardDuty unified Runtime Monitoring and AI Protection checks ([#12564](https://github.com/prowler-cloud/prowler/pull/12564)), EKS VPC CNI network policy check ([#12661](https://github.com/prowler-cloud/prowler/pull/12661)), ECR enhanced scanning check ([#12660](https://github.com/prowler-cloud/prowler/pull/12660)), Bedrock AgentCore IAM and service trust checks ([#12664](https://github.com/prowler-cloud/prowler/pull/12664)), AgentCore log group data protection check ([#12662](https://github.com/prowler-cloud/prowler/pull/12662)), and fixes to ECR scan frequency ([#12560](https://github.com/prowler-cloud/prowler/pull/12560)), CloudWatch metric filters ([#12561](https://github.com/prowler-cloud/prowler/pull/12561)) and SageMaker direct internet access ([#12659](https://github.com/prowler-cloud/prowler/pull/12659))
|
||||
- @haneul-24: AWS `elasticbeanstalk_environment_no_secrets_in_configuration` check ([#12378](https://github.com/prowler-cloud/prowler/pull/12378))
|
||||
- @UTKARSH698: AWS `memorydb_cluster_in_transit_encryption_enabled` check ([#12246](https://github.com/prowler-cloud/prowler/pull/12246))
|
||||
- @ye11oc4t: GitHub repository discovery pagination for unscoped scans ([#12460](https://github.com/prowler-cloud/prowler/pull/12460))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.41.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.40.0" description="August 28, 2026">
|
||||
### 💬 Slack Integration — Alert Channel Destinations
|
||||
|
||||
|
||||
@@ -129,12 +129,42 @@ Each check **must** populate the `report.status` and `report.status_extended` fi
|
||||
- Status field: `report.status`
|
||||
- `PASS` – Assigned when the check confirms compliance with the configured value.
|
||||
- `FAIL` – Assigned when the check detects non-compliance with the configured value.
|
||||
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`).
|
||||
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). This includes the case where Prowler could not retrieve the data needed to evaluate the resource (see below).
|
||||
|
||||
- Status extended field: `report.status_extended`
|
||||
- It **must** end with a period (`.`).
|
||||
- It **must** include the audited service, the resource, and a concise explanation of the check result, for instance: `EC2 AMI ami-0123456789 is not public.`.
|
||||
|
||||
### Permission and Data-Availability Errors Are Not Findings
|
||||
|
||||
A `FAIL` must only be emitted when an insecure condition has actually been detected. A check **must never** report `FAIL` because the underlying API call failed: missing permissions or scopes on the scanning identity, an API that is not enabled, a feature that is not licensed, or data that could not be retrieved are scan-configuration problems, not security issues. Reporting them as `FAIL` surfaces a misleading (and often high-severity) finding to the user and skews compliance scores.
|
||||
|
||||
When the service layer cannot obtain the data a check depends on, the check must:
|
||||
|
||||
1. Emit a single `MANUAL` finding scoped to the widest affected resource (the tenant, account, project or subscription), not one finding per resource. For example, if user registration details cannot be read, emit one tenant-level `MANUAL` instead of one per user.
|
||||
2. Explain in `status_extended` that the check could not be evaluated and what to fix, naming the permission, scope, API or license required, for instance: `Cannot evaluate credential exposure for privileged users: unable to query Microsoft Defender XDR Advanced Hunting. Verify that the ThreatHunting.Read.All permission is granted to the scanning application.`
|
||||
3. Leave the check's severity untouched. Do not override `report.check_metadata.Severity` to hide the problem.
|
||||
|
||||
The service layer must make the distinction possible: log the error and expose it to checks in a way that cannot be confused with a legitimate empty result. Common patterns already used in Prowler are:
|
||||
|
||||
- Defaulting the attribute to `None` (data could not be read) instead of `[]`/`{}` (data was read and is empty), e.g. the `metric_filters is not None` guard in `prowler/providers/aws/services/cloudwatch/lib/metric_filters.py`.
|
||||
- Keeping an availability flag raised on any denied listing, e.g. `logs_client.metric_filters_unavailable` consumed by the AWS CloudWatch metric filter checks.
|
||||
- Keeping an error flag or message next to the data, e.g. `entra_client.user_registration_details_error` in M365 or `*_scan_errors` in AWS Bedrock.
|
||||
- Keeping a set of resources whose lookup failed, e.g. `accessapproval_client.settings_lookup_failed` in GCP.
|
||||
|
||||
Make sure the error branch only captures real access errors. A `404`/not-found response frequently means the feature is simply not configured, which **is** a legitimate `FAIL`; a `403` or an unexpected exception is not. An "API not enabled" error is usually a scan-configuration problem too — **except** when the API's activation is itself the control being audited (e.g. GCP Access Approval: with `accessapproval.googleapis.com` disabled the feature provably cannot be enabled, so a definitive API-disabled state is a legitimate `FAIL`, while an undetermined state stays `MANUAL`).
|
||||
|
||||
```python
|
||||
if <service>_client.<data> is None:
|
||||
report = CheckReport<Provider>(metadata=self.metadata(), resource={})
|
||||
report.resource_name = "<Tenant/Account-level resource>"
|
||||
report.resource_id = "<stable-id>"
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = "Cannot evaluate <requirement>: <data> could not be retrieved. Verify that <permission/API/license> is granted to the scanning identity."
|
||||
findings.append(report)
|
||||
return findings
|
||||
```
|
||||
|
||||
### Prowler's Check Severity Levels
|
||||
|
||||
The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below.
|
||||
@@ -437,6 +467,7 @@ The metadata structure is enforced in code using a Pydantic model. For reference
|
||||
- Use clear, actionable, and user-friendly language in `status_extended` to explain the result. Always provide information to identify the resource.
|
||||
- Use helper functions/utilities for repeated logic to avoid code duplication. Save them in the `lib` folder of the service.
|
||||
- Handle exceptions gracefully: catch errors per resource, log them, and continue processing other resources.
|
||||
- Never report `FAIL` because data could not be retrieved (missing permissions, API not enabled, feature not licensed). Emit a single `MANUAL` finding explaining what is required instead; see [Permission and Data-Availability Errors Are Not Findings](#permission-and-data-availability-errors-are-not-findings).
|
||||
- Document the check with a class and function level docstring explaining what it does, what it checks, and any caveats or provider-specific behaviors.
|
||||
- Use type hints for the `execute()` method (e.g., `-> list[CheckReport<Provider>]`) for clarity and static analysis.
|
||||
- Ensure checks are efficient; avoid excessive nested loops. If the complexity is high, consider refactoring the check.
|
||||
|
||||
@@ -154,6 +154,8 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed.
|
||||
| `max_days_secret_unused` | `7..365` days | |
|
||||
| `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 |
|
||||
| `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year |
|
||||
| `inspector2_max_days_since_last_scan` | `1..90` days | |
|
||||
| `inspector2_active_finding_max_age_days` | `1..365` days | Default `192` matches the FedRAMP 20x rule that marks vulnerabilities still open after 192 days as accepted |
|
||||
| `shodan_api_key` | ≤512 chars | |
|
||||
|
||||
### Azure
|
||||
|
||||
@@ -40,6 +40,19 @@ The former build-time variables map to the new runtime variables as follows:
|
||||
|
||||
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
|
||||
|
||||
`UI_SELF_REGISTRATION_ENABLED` is a runtime opt-out flag that Prowler Local Server reads only when `UI_CLOUD_ENABLED` is `"true"`. It defaults to on and turns off when set to `"false"`, matched case-insensitively so the same value can be shared with a backend setting written `False`. When it is off, the sign-up page only opens with an invitation token, the sign-in page drops its "Sign up" link, and the profile hides "Create organization"; invited users can still complete their registration. Outside a Prowler Cloud deployment the flag is ignored and account creation stays open.
|
||||
## Registry UI Rollout and Rollback
|
||||
|
||||
`UI_REGISTRY_ENABLED` is an optional runtime flag for Prowler Cloud and Private Cloud. Registry is eligible only when both `UI_CLOUD_ENABLED` and `UI_REGISTRY_ENABLED` are the exact string `"true"` and the current user has the backend-authorized `manage_registry` permission. Unset, `"false"`, or malformed values fail closed. The flag defaults to off and is not a replacement for backend authorization. Registry access is independent of billing; Private Cloud can use it with `CLOUD_BILLING_ENABLED=false`.
|
||||
|
||||
Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry.
|
||||
|
||||
The Registry links read `PROWLER_REGISTRY_INDEX_URL`, the same base URL the backend installs artifacts from. The Registry page and the credential banner link to it, and its origin is allowed for images. `UI_REGISTRY_MEDIA_URL` adds the Registry media service origin so artifact logos load. When `PROWLER_REGISTRY_INDEX_URL` is unset or invalid, the links are hidden instead of pointing to the public Prowler Registry, which keeps private and air-gapped deployments from sending users to an unreachable host. `UI_REGISTRY_URL` is no longer read.
|
||||
|
||||
The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again.
|
||||
|
||||
To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts.
|
||||
|
||||
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
|
||||
|
||||
## Enabling Third-Party Integrations
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
title: 'Basic Usage'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
## Running Prowler
|
||||
|
||||
Running Prowler requires specifying the provider (e.g. `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`):
|
||||
@@ -91,6 +93,18 @@ By default, `prowler` will scan all AWS regions.
|
||||
</Note>
|
||||
See more details about AWS Authentication in the [Authentication Section](/user-guide/providers/aws/authentication) section.
|
||||
|
||||
- **AWS Retrier and Timeout Configuration**
|
||||
|
||||
<VersionBadge version="5.42.0" />
|
||||
|
||||
Tune the Boto3 standard retrier and the endpoint timeouts when AWS throttles the scan or when some endpoints are unreachable from the network Prowler runs in:
|
||||
|
||||
```console
|
||||
prowler aws --aws-retries-max-attempts 5 --aws-connect-timeout 5 --aws-read-timeout 30
|
||||
```
|
||||
|
||||
See the [Boto3 configuration](/user-guide/providers/aws/boto3-configuration) page for defaults and environment variables.
|
||||
|
||||
## Azure
|
||||
|
||||
Azure requires specifying the auth method:
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.40.0"
|
||||
PROWLER_API_VERSION="5.40.0"
|
||||
PROWLER_UI_VERSION="5.44.0"
|
||||
PROWLER_API_VERSION="5.44.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -71,7 +71,7 @@ Prowler supports a wide range of providers organized by category:
|
||||
| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
|
||||
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI |
|
||||
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
|
||||
| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI |
|
||||
| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | UI, API, CLI |
|
||||
| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI |
|
||||
|
||||
### Kubernetes
|
||||
|
||||
@@ -51,6 +51,7 @@ The following list includes all the AWS checks with configurable variables that
|
||||
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_actions` | List of Strings | See `config.yaml` |
|
||||
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer | `1440` |
|
||||
| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_threshold` | Float | `0.2` |
|
||||
| `cloudwatch_log_group_agentcore_data_protection_policy_enabled` | `agentcore_log_group_name_prefixes` | List of Strings | See `config.yaml` |
|
||||
| `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings | `[]` |
|
||||
| `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer | `365` |
|
||||
| `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings | `[]` |
|
||||
@@ -90,6 +91,8 @@ The following list includes all the AWS checks with configurable variables that
|
||||
| `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer | `90` |
|
||||
| `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer | `45` |
|
||||
| `iam_user_console_access_unused` | `max_console_access_days` | Integer | `45` |
|
||||
| `inspector2_active_findings_within_max_age` | `inspector2_active_finding_max_age_days` | Integer | `192` |
|
||||
| `inspector2_coverage_recently_scanned` | `inspector2_max_days_since_last_scan` | Integer | `3` |
|
||||
| `kinesis_stream_data_retention_period` | `min_kinesis_stream_retention_hours` | Integer | `168` |
|
||||
| `neptune_cluster_backup_enabled` | `minimum_backup_retention_period` | Integer | `7` |
|
||||
| `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings | `[]` |
|
||||
@@ -489,6 +492,14 @@ aws:
|
||||
# MEDIUM
|
||||
ecr_repository_vulnerability_minimum_severity: "MEDIUM"
|
||||
|
||||
# AWS Inspector2
|
||||
# aws.inspector2_coverage_recently_scanned
|
||||
# Maximum days since Inspector2 last scanned an actively covered resource
|
||||
inspector2_max_days_since_last_scan: 3
|
||||
# aws.inspector2_active_findings_within_max_age
|
||||
# Maximum days an Inspector2 finding can stay active since it was first observed
|
||||
inspector2_active_finding_max_age_days: 192
|
||||
|
||||
# AWS Trusted Advisor
|
||||
# aws.trustedadvisor_premium_support_plan_subscribed
|
||||
verify_premium_support_plans: True
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
---
|
||||
title: "Boto3 Retrier Configuration in Prowler"
|
||||
title: "Boto3 Retrier and Timeout Configuration in Prowler"
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
Prowler's AWS Provider leverages Boto3's [Standard](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html) retry mode to automatically retry client calls to AWS services when encountering errors or exceptions.
|
||||
|
||||
## Timeout Configuration
|
||||
|
||||
<VersionBadge version="5.42.0" />
|
||||
|
||||
Every AWS API call is bounded by two timeouts:
|
||||
|
||||
- Connect timeout: seconds to wait to establish a connection (TCP, proxy tunnel and TLS handshake) to the AWS endpoint. Prowler's default is 10 seconds, configurable via `--aws-connect-timeout 5`.
|
||||
- Read timeout: seconds to wait for a response once connected. Prowler's default is 60 seconds, configurable via `--aws-read-timeout 30`.
|
||||
|
||||
Both timeouts can also be set through environment variables, which is the way to tune them in Prowler Cloud and other deployments without a CLI:
|
||||
|
||||
```console
|
||||
export PROWLER_AWS_BOTO3_CONNECT_TIMEOUT=5
|
||||
export PROWLER_AWS_BOTO3_READ_TIMEOUT=30
|
||||
```
|
||||
|
||||
CLI flags take precedence over the environment variables. Prowler sets both timeouts explicitly, so `AWS_DEFAULTS_MODE` and a `connect_timeout` in `~/.aws/config` are ignored; use the flag or the environment variable instead.
|
||||
|
||||
<Note>
|
||||
Boto3 defaults both timeouts to 60 seconds. In networks with restricted egress (for example VPC endpoints for a subset of services, GovCloud or private deployments), every AWS service without a reachable endpoint used to cost up to 4 attempts × 60 seconds (the first call plus the 3 retries) for each region. Prowler lowers the connect timeout to 10 seconds so unreachable endpoints fail fast; lower it further together with `--aws-retries-max-attempts 0`, which disables retries and leaves a single attempt per call, if a scan still spends most of its time waiting on unreachable services.
|
||||
|
||||
</Note>
|
||||
|
||||
## Retries Configuration
|
||||
|
||||
<VersionBadge version="5.44.0" />
|
||||
|
||||
The number of retries is set with `--aws-retries-max-attempts`, where `0` disables retries. It can also be set through an environment variable, which is the way to tune it in Prowler Cloud and other deployments without a CLI:
|
||||
|
||||
```console
|
||||
export PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS=0
|
||||
```
|
||||
|
||||
The CLI flag takes precedence over the environment variable. The value must be a non-negative integer; when neither is set, Prowler uses 3 retries.
|
||||
|
||||
<Warning>
|
||||
The environment variable is process-wide: it applies to every AWS provider built in the process where it is set, not only to a connection check. A scan started in that same process picks it up too. Boto3's Standard retry mode, which Prowler uses, also retries service-side throttling responses (see the errors listed below), so `0` disables retries for those as well. On a large account a scan can hit throttling under normal load, and with retries disabled that throttling becomes a hard failure instead of a retried call. Set the variable only on the processes that run connection checks; leave scan workers on the default, or raise their retry count instead of lowering it.
|
||||
</Warning>
|
||||
|
||||
## Retry Behavior Overview
|
||||
|
||||
Boto3's Standard retry mode includes the following mechanisms:
|
||||
|
||||
- Maximum Retry Attempts: Default value set to 3, configurable via the `--aws-retries-max-attempts 5` argument.
|
||||
- Maximum Retry Attempts: Default value set to 3, configurable via the `--aws-retries-max-attempts 5` argument. `0` disables retries.
|
||||
|
||||
- Expanded Error Handling: Retries occur for a comprehensive set of errors.
|
||||
|
||||
|
||||
@@ -21,10 +21,30 @@ When scanning the China (`aws-cn`), European Sovereign Cloud (`aws-eusc`) or Gov
|
||||
|
||||
- Specify the regions to audit within that partition using the `-f/--region` flag.
|
||||
|
||||
- Declare the partition with the `PROWLER_AWS_PARTITION` environment variable, set to `aws`, `aws-cn`, `aws-eusc` or `aws-us-gov`.
|
||||
|
||||
<Note>
|
||||
Refer to: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials for more information about the AWS credential configuration.
|
||||
|
||||
</Note>
|
||||
### Declaring the Partition
|
||||
|
||||
`PROWLER_AWS_PARTITION` tells Prowler which partition the scan runs against, without relying on a region being configured:
|
||||
|
||||
```bash
|
||||
export PROWLER_AWS_PARTITION="aws-us-gov"
|
||||
```
|
||||
|
||||
It matters most where nothing else says. Resolving an identity means calling STS before anything is known about the credentials, and with no region configured Prowler would otherwise start from the commercial endpoints. Declaring the partition makes that first call go to the right place, which is the difference between a scan that starts and one that fails on an endpoint the credentials cannot use.
|
||||
|
||||
A region configured for the session still wins when it belongs to the declared partition, so a deployment in `us-gov-west-1` is not sent to `us-gov-east-1`. A region belonging to a different partition is ignored, since a partition that has been declared explicitly is the more deliberate statement of the two.
|
||||
|
||||
When no configured region says which one to prefer, the first region of the partition is tried, and up to two more follow if it cannot be reached. A network that routes to only one region of its partition therefore works without having to declare which one that is. Only a connection failure moves on to the next region: a credential error is reported from the first, since it would be the same everywhere. A region excluded from the scan is tried last, so it is avoided whenever another region of the partition answers.
|
||||
|
||||
<Note>
|
||||
Set it wherever the scan runs. For deployments that scan from containers, that means the environment of the containers doing the scanning, not only the one accepting the request.
|
||||
</Note>
|
||||
|
||||
### Scanning Specific Regions
|
||||
|
||||
To scan a particular AWS region with Prowler, use:
|
||||
|
||||
@@ -22,9 +22,12 @@ Prowler requires read-only access to Cloudflare zones and their settings. The fo
|
||||
| Resource | Permission | Access | Description |
|
||||
|----------|------------|--------|-------------|
|
||||
| `Account` | `Account Settings` | `Read` | Required to list accounts and verify user identity |
|
||||
| `Zone` | `Zone` | `Read` | Required to list zones, rulesets, bot management, and SSL settings |
|
||||
| `Zone` | `Zone Settings` | `Read` | Required to read zone security settings (TLS, HSTS, WAF, etc.) |
|
||||
| `Zone` | `DNS` | `Read` | Required to read DNS records and DNSSEC status |
|
||||
| `Zone` | `Zone` | `Read` | Required to list zones |
|
||||
| `Zone` | `Zone Settings` | `Read` | Required to read zone security settings (SSL/TLS mode, TLS versions, HSTS, Always Use HTTPS, WAF, etc.) |
|
||||
| `Zone` | `DNS` | `Read` | Required to read DNS records (SPF, DMARC, DKIM, CAA) and DNSSEC status |
|
||||
| `Zone` | `SSL and Certificates` | `Read` | Required to read Universal SSL settings |
|
||||
| `Zone` | `Bot Management` | `Read` | Required to read Bot Fight Mode |
|
||||
| `Zone` | `Zone WAF` | `Read` | Required to read WAF custom, rate limiting, and managed rulesets |
|
||||
|
||||
<Warning>
|
||||
Ensure the API Token has access to all zones targeted for scanning. Missing permissions may cause some checks to fail or return incomplete results.
|
||||
@@ -46,8 +49,8 @@ Create a **User API Token**, not an Account API Token. User API Tokens are creat
|
||||
|
||||
**Quick Setup:** Use these pre-configured links to open the Cloudflare Dashboard with the required permissions already selected:
|
||||
|
||||
- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a **User API Token** (recommended). Opens the **Create Custom Token** form prefilled with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner`. Adjust **Account Resources** and **Zone Resources** to match the accounts and zones you want to scan, then click **Create Token**.
|
||||
- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/) instead. Use this for automation or CI/CD where the token should not depend on a specific user account remaining active. Requires the **Super Administrator** or **Administrator** role on the account.
|
||||
- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a **User API Token** (recommended). Opens the **Create Custom Token** form prefilled with the seven required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`, `SSL and Certificates`, `Bot Management`, `Zone WAF`) and the name `Prowler Security Scanner`. Adjust **Account Resources** and **Zone Resources** to match the accounts and zones you want to scan, then click **Create Token**.
|
||||
- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/) instead. Use this for automation or CI/CD where the token should not depend on a specific user account remaining active. Requires the **Super Administrator** or **Administrator** role on the account.
|
||||
|
||||
<Note>
|
||||
Template URLs only pre-fill the token creation form. Review the permissions, configure resources, and click **Create Token** to complete the process.
|
||||
@@ -66,6 +69,9 @@ Template URLs only pre-fill the token creation form. Review the permissions, con
|
||||
- `Zone` — `Zone` — `Read`
|
||||
- `Zone` — `Zone Settings` — `Read`
|
||||
- `Zone` — `DNS` — `Read`
|
||||
- `Zone` — `SSL and Certificates` — `Read`
|
||||
- `Zone` — `Bot Management` — `Read`
|
||||
- `Zone` — `Zone WAF` — `Read`
|
||||
- **Zone Resources:** Select either:
|
||||
- **Include → All zones** (to scan all zones in the account)
|
||||
- **Include → Specific zone** (to limit access to specific zones)
|
||||
|
||||
@@ -11,16 +11,16 @@ Prowler for Cloudflare scans zones for security misconfigurations, including SSL
|
||||
Set up authentication for Cloudflare with the [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication) guide before starting either path:
|
||||
|
||||
- Create a Cloudflare User API Token (recommended) or locate the Global API Key
|
||||
- Grant the required read-only permissions (`Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`)
|
||||
- Grant the required read-only permissions (`Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`, `SSL and Certificates:Read`, `Bot Management:Read`, `Zone WAF:Read`)
|
||||
- Identify the Cloudflare Account ID to use as the provider identifier
|
||||
|
||||
<Note>
|
||||
**Quick Setup:** Use these pre-configured links to create a token with the required permissions already selected:
|
||||
|
||||
- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a User API Token (recommended).
|
||||
- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/), better suited for automation and CI/CD.
|
||||
- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a User API Token (recommended).
|
||||
- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/), better suited for automation and CI/CD.
|
||||
|
||||
Both links open the Cloudflare Dashboard with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner` prefilled. See [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication#api-token-recommended) for the equivalent manual steps.
|
||||
Both links open the Cloudflare Dashboard with the seven required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`, `SSL and Certificates`, `Bot Management`, `Zone WAF`) and the name `Prowler Security Scanner` prefilled. See [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication#api-token-recommended) for the equivalent manual steps.
|
||||
</Note>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -96,6 +96,29 @@ Install Trivy using one of the following methods:
|
||||
|
||||
For additional installation methods, see the [Trivy installation guide](https://trivy.dev/latest/getting-started/installation/).
|
||||
|
||||
### Vulnerability Database Cache
|
||||
|
||||
<VersionBadge version="5.42.0" />
|
||||
|
||||
Trivy keeps its vulnerability database in a cache directory. By default Prowler gives it a temporary one and removes it when the scan ends, so the database is downloaded again for every scan.
|
||||
|
||||
Set `TRIVY_CACHE_DIR` to a directory that persists and the database is downloaded once and reused:
|
||||
|
||||
```bash
|
||||
export TRIVY_CACHE_DIR="$HOME/.cache/trivy"
|
||||
prowler image --image <image>
|
||||
```
|
||||
|
||||
Prowler never deletes a directory you supply. Trivy still creates and updates its cache and database files inside it.
|
||||
|
||||
<Note>
|
||||
A host with no internet access needs a pre-populated vulnerability database in a persistent directory, with `TRIVY_CACHE_DIR` pointing at it. Populate the directory on a machine that does have access and copy it across.
|
||||
|
||||
Trivy tries to refresh the database when it considers it stale, and that download fails without network access. Set `TRIVY_SKIP_DB_UPDATE=true` (and `TRIVY_SKIP_JAVA_DB_UPDATE=true` if Java scanning is enabled) so it uses the supplied database as is.
|
||||
|
||||
The database ages. A scan run against an old one reports only the vulnerabilities known when it was built, and nothing in the output says so, so keep track of when it was last refreshed.
|
||||
</Note>
|
||||
|
||||
|
||||
### Supported Scanners
|
||||
|
||||
@@ -306,9 +329,21 @@ prowler image --registry internal-registry.local --registry-insecure
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Skipping TLS verification disables certificate validation for registry connections. Use this flag only for trusted internal registries with self-signed certificates.
|
||||
Skipping TLS verification disables certificate validation for registry connections, including the Trivy image pull (`TRIVY_INSECURE`). Use this flag only for trusted internal registries with self-signed certificates.
|
||||
</Warning>
|
||||
|
||||
#### On-Premises Registries and Private Networks
|
||||
|
||||
<VersionBadge version="5.41.0" />
|
||||
|
||||
By default, Prowler rejects registry-provided URLs (token endpoints, pagination links) that resolve to non-public addresses, as an SSRF defense. On-premises registries live on private networks by definition, so to scan them declare the trusted ranges explicitly:
|
||||
|
||||
```bash
|
||||
export PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS="192.168.65.254/32,10.20.0.0/16"
|
||||
```
|
||||
|
||||
The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback, and the rest of the internal network remain protected. The variable applies to registry enumeration and to the connection test. Malformed entries fail at startup, and a non-empty allowlist is logged as a relaxed security control. When unset, behavior is unchanged: only public addresses are followed.
|
||||
|
||||
#### Supported Registries
|
||||
|
||||
Registry Scan Mode supports the following registry types:
|
||||
|
||||
@@ -12,7 +12,7 @@ The following steps apply to Prowler Cloud and Prowler Local Server.
|
||||
1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID.
|
||||
2. Go to **Identity & Security** → **Users**, select the principal that owns the API key, and copy the **User OCID**.
|
||||
3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint.
|
||||
4. Note the **Region** identifier to scan (for example, `us-ashburn-1`).
|
||||
4. In **Tenancy Details**, note the **Home Region** identifier (for example, `me-abudhabi-1`). Any other region the tenancy is subscribed to also works.
|
||||
|
||||
### Step 2: Access Prowler Cloud
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
|
||||
@@ -26,12 +26,18 @@ Prowler Cloud connects to OCI with API key credentials. Provide:
|
||||
|
||||
- **User OCID** for the API key owner
|
||||
- **Fingerprint** of the API key
|
||||
- **Region** (for example, `us-ashburn-1`)
|
||||
- **Home Region**: select it from the list (for example, `me-abudhabi-1`)
|
||||
- **Private Key Content** (paste the full PEM value)
|
||||
- **Passphrase (Optional)** if the private key is encrypted
|
||||
|
||||
Select **Next**, then **Launch Scan** to validate the connection and start the first OCI scan. The private key content is encoded for secure transmission.
|
||||
|
||||
<Note>
|
||||
The home region is used only to validate the credentials and discover the regions the tenancy is subscribed to. It does not limit the scan: Prowler audits every subscribed region. OCI Identity and Access Management (IAM) only answers in subscribed regions, so a tenancy that is not subscribed to the selected region cannot be validated.
|
||||
</Note>
|
||||
|
||||
Providers created without a region keep using `us-ashburn-1` for validation. If such a provider fails with `401 NotAuthenticated`, update its credentials and select the home region.
|
||||
|
||||

|
||||
|
||||
---
|
||||
@@ -334,6 +340,11 @@ prowler oci \
|
||||
|
||||
#### Region Issues
|
||||
|
||||
**Error: "OCI credential validation failed" with `401 NotAuthenticated` on `get_tenancy`**
|
||||
- The tenancy is not subscribed to the region used for validation (by default `us-ashburn-1`)
|
||||
- In Prowler Cloud or Prowler Local Server, update the provider credentials and select the tenancy home region
|
||||
- In Prowler CLI, set `region` in `~/.oci/config` to the home region. All subscribed regions are still scanned; `--region` also works but limits the scan to that region
|
||||
|
||||
**Error: "Invalid region"**
|
||||
- Check available regions: `prowler oci --list-regions`
|
||||
- Verify your tenancy is subscribed to the region
|
||||
|
||||
@@ -241,7 +241,7 @@ steps:
|
||||
|
||||
### Cloudflare
|
||||
|
||||
Create a Cloudflare API Token with `Zone:Read`, `Zone Settings:Read`, and `DNS:Read` permissions ([provider auth docs](/user-guide/providers/cloudflare/authentication)). Then:
|
||||
Create a Cloudflare API Token with the `Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`, `SSL and Certificates:Read`, `Bot Management:Read`, and `Zone WAF:Read` permissions ([provider auth docs](/user-guide/providers/cloudflare/authentication)). Then:
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
|
||||
@@ -124,6 +124,18 @@ To manually send individual Findings to Jira:
|
||||
|
||||

|
||||
|
||||
### Finding Reference in the Jira Issue
|
||||
|
||||
<VersionBadge version="5.41.0" />
|
||||
|
||||
Every Jira issue created from a single Finding carries a stable reference back to that Finding, so issues can be filtered, searched with Jira Query Language (JQL), or matched by automation:
|
||||
|
||||
* **Labels**: `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>` and `prowler-finding-<finding-uid>`. Labels are sanitized deterministically: whitespace becomes `_`, control characters are removed, and values are truncated to Jira's 255-character label limit.
|
||||
* **Finding URL**: a link that opens the Finding in Prowler, filtered by its unique identifier (UID) so it keeps working after later scans.
|
||||
* **Tenant Info**: the name of the Prowler organization that sent the Finding.
|
||||
|
||||
Prowler Cloud always includes the Finding URL. In Prowler Local Server, set `DJANGO_UI_BASE_URL` in the API environment (for example, `https://prowler.example.com`) to enable it. When the variable is empty, the issue is created without the link.
|
||||
|
||||
## Integration Status
|
||||
|
||||
Monitor and manage your Jira integrations through the management interface:
|
||||
|
||||
@@ -148,6 +148,12 @@ New roles have no provider visibility by default. Assign at least one Provider G
|
||||
|
||||
Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission.
|
||||
|
||||
#### Task Visibility and Revocation
|
||||
|
||||
<VersionBadge version="5.44.0" />
|
||||
|
||||
Background tasks, such as provider deletions, connection checks and scans, follow the visibility of the provider they belong to: a role can see a task when it can access its provider. Tasks that carry no provider reference are treated as tenant-wide and are visible to every role. Revoking a pending task requires the same permission as the operation that queued it, for example **Manage Scans** for a scan. Provider deletions cannot be revoked.
|
||||
|
||||
#### Creating a Provider Group
|
||||
|
||||
Follow these steps to create a provider group in your account:
|
||||
@@ -261,7 +267,7 @@ To grant all administrative permissions, select the **Grant all admin permission
|
||||
|
||||
The following permissions are available exclusively in **Prowler Cloud**:
|
||||
|
||||
**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
|
||||
**Manage Ingestions:** Submit and manage findings ingestion jobs. Required to upload OCSF scan results from the Scans page, with the `--push-to-cloud` CLI flag or through the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
|
||||
|
||||
**Manage Billing:** Access and manage billing settings, subscription plans, and payment methods.
|
||||
|
||||
|
||||
@@ -58,12 +58,14 @@ Two of these read more broadly than they behave, and both are worth understandin
|
||||
|
||||
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first.
|
||||
|
||||
{/* The Prowler UI deep-links to this heading's anchor, so rewording the heading breaks that link. */}
|
||||
|
||||
### Why a Private Channel Is Missing From the Channel List
|
||||
|
||||
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack:
|
||||
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler Cloud` to it in Slack:
|
||||
|
||||
```text
|
||||
/invite @Prowler
|
||||
/invite @Prowler Cloud
|
||||
```
|
||||
|
||||
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
|
||||
@@ -100,14 +102,14 @@ Prowler posts to the channels authorized on the integration. Several channels ca
|
||||
|
||||

|
||||
|
||||
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
|
||||
2. Select one or more channels. A selected private channel keeps the same **Private** marking with the list closed, so the authorized set stays readable at a glance.
|
||||
3. Click **Save channels**.
|
||||
|
||||
Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it.
|
||||
|
||||
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
|
||||
If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler Cloud` to a private one, then click **Refresh channels**.
|
||||
|
||||
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
|
||||
A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler Cloud` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out.
|
||||
|
||||
Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized.
|
||||
|
||||
@@ -159,7 +161,7 @@ The Slack management page reports the state of the connection and offers these a
|
||||
| Button | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized |
|
||||
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
|
||||
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler Cloud` to a private channel |
|
||||
| **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set |
|
||||
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
|
||||
|
||||
@@ -171,7 +173,7 @@ The Prowler Slack app is not configured for the deployment being used, so no wor
|
||||
|
||||
### A Private Channel Does Not Appear in the Channel List
|
||||
|
||||
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
|
||||
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler Cloud` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
|
||||
|
||||
### Connection Test Fails
|
||||
|
||||
|
||||
@@ -89,6 +89,14 @@ After adding your cloud account credentials, click the `Check connection` button
|
||||
|
||||
<img src="/images/test-connection-button.png" alt="Test Connection" width="700" />
|
||||
|
||||
<Note>
|
||||
For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
To delegate the AWS connection, select `I don't have access, invite a teammate` on the same step when you cannot create the IAM role or do not have the account credentials. Prowler App sends the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. This option is available to users who can manage the account.
|
||||
</Note>
|
||||
|
||||
## Step 6: Scan Started
|
||||
After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Import Findings'
|
||||
sidebarTitle: 'Import Findings'
|
||||
description: 'Upload OCSF scan results to Prowler Cloud from external sources or the CLI'
|
||||
description: 'Upload OCSF scan results to Prowler Cloud from the UI, the CLI or the API'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class.
|
||||
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. Reports can be imported from the Scans page in the Prowler Cloud UI, pushed by the CLI with `--push-to-cloud`, or submitted through the API.
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
@@ -132,10 +132,32 @@ Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF
|
||||
|
||||
## Required Permissions
|
||||
|
||||
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`.
|
||||
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted from the Scans page, via the API or with `--push-to-cloud`.
|
||||
|
||||
For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
|
||||
|
||||
## Using the UI
|
||||
|
||||
<VersionBadge version="5.41.0" />
|
||||
|
||||
The Scans page imports a Prowler OCSF report from the browser, with no CLI or API key involved. The import runs as a regular ingestion job, so the [status values](#ingestion-status-values), the [billing impact](#billing-impact) and the [errors endpoint](#get-ingestion-errors) apply as they do for the CLI and the API.
|
||||
|
||||
1. Go to **Scans** and click **Import Findings**. The button is shown only to roles with the **Manage Ingestions** permission.
|
||||
|
||||

|
||||
|
||||
2. Drag a `.ocsf.json` report onto the drop area, or click **Select File** to pick one. The dialog takes one file per import. A file whose name does not end in `.ocsf.json`, or an empty file, is rejected before the upload starts.
|
||||
|
||||

|
||||
|
||||
3. Click **Start import**. The dialog uploads the report, creates the ingestion job and follows its status until the job finishes. On completion it reports the total number of records, how many were processed and how many were invalid.
|
||||
|
||||
Closing the dialog while an import is running does not cancel the job. When the job completes in the background, a notification confirms it and the imported findings appear in Scans.
|
||||
|
||||
If the upload is rejected or the job fails, the dialog shows the reason and a **Retry import** button that sends the same file again. A failed job also shows the progress it reported before failing. A different file can be selected instead of retrying. If the status check fails after the upload was accepted, **Retry status** resumes tracking the same job without uploading the file again.
|
||||
|
||||
Invalid records are counted in the summary but not listed in the dialog. To see why each one was rejected, [list the ingestion jobs](#list-ingestion-jobs) through the API and query the [errors endpoint](#get-ingestion-errors) for that job.
|
||||
|
||||
## Using the CLI
|
||||
|
||||
The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after a scan completes. This approach automates the ingestion process without manual file uploads.
|
||||
|
||||
@@ -4,6 +4,39 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [0.12.2] (Prowler v5.43.0)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848)
|
||||
|
||||
---
|
||||
|
||||
## [0.12.1] (Prowler v5.42.0)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `libuuid` upgraded to 2.41.6-r1 in the container image, patching CVE-2026-53612, CVE-2026-53613, CVE-2026-53614, CVE-2026-76642, CVE-2026-78408 and CVE-2026-78410 [(#12780)](https://github.com/prowler-cloud/prowler/pull/12780)
|
||||
|
||||
---
|
||||
|
||||
## [0.12.0] (Prowler v5.41.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Prowler App tools now report a failure as an MCP tool execution error (`isError: true`, explanation in `content`) instead of as a successful result carrying an `{"error": ...}` object, which clients and models read as a success [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `prowler_get_compliance_framework_state_details` now rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider, which could report on a scan belonging to a different provider than the one that was asked about [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now report a check ID that belongs to another provider as such, naming that provider, instead of reporting the ID as one that does not exist [(#12533)](https://github.com/prowler-cloud/prowler/pull/12533)
|
||||
- `prowler_docs_search` no longer reports a failed search as zero matches or an unreadable answer as a bad search term, and `prowler_docs_get_document` no longer reports a failed fetch as a missing page [(#12534)](https://github.com/prowler-cloud/prowler/pull/12534)
|
||||
|
||||
---
|
||||
|
||||
## [0.11.0] (Prowler v5.40.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -25,13 +25,15 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# =============================================================================
|
||||
# Final stage - Minimal runtime environment
|
||||
# =============================================================================
|
||||
FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76
|
||||
FROM python:3.14.7-alpine3.23@sha256:218761489de417a6eb0808e264cbdd7043ec6659fe5a61898815e9848536541d
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
# High CVEs fixed in Alpine 3.23 but not yet in the pinned base image:
|
||||
# sqlite-libs 3.53.4-r0 CVE-2026-11822, CVE-2026-11824 (image ships 3.51.2-r0)
|
||||
# libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456 (image ships 3.5.7-r0)
|
||||
# libuuid 2.41.6-r1 CVE-2026-53612, -53613, -53614, -76642, -78408, -78410
|
||||
# (image ships 2.41.4-r0; -78408 is the one that needs -r1 rather than -r0)
|
||||
# The base image pins python 3.13.14, which has not been rebuilt since those
|
||||
# packages were published, so the upgrade is taken here rather than by moving
|
||||
# the pin -- the newest published python:3.13-alpine3.23 carries the same
|
||||
@@ -43,7 +45,8 @@ LABEL maintainer="https://github.com/prowler-cloud"
|
||||
RUN apk add --no-cache --upgrade \
|
||||
"sqlite-libs>=3.53.4-r0" \
|
||||
"libcrypto3>=3.5.8-r0" \
|
||||
"libssl3>=3.5.8-r0"
|
||||
"libssl3>=3.5.8-r0" \
|
||||
"libuuid>=2.41.6-r1"
|
||||
|
||||
# Create non-root user for security
|
||||
# Using specific UID/GID for consistency across environments
|
||||
|
||||