Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ea00cab9 | ||
|
|
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 | ||
|
|
1679094f22 | ||
|
|
f05a490cd7 | ||
|
|
89988dada5 | ||
|
|
0326844527 | ||
|
|
73d5c6952b | ||
|
|
ad76b3026f | ||
|
|
e21946874f | ||
|
|
2cae2058e9 | ||
|
|
c88f745038 | ||
|
|
c923c58a39 | ||
|
|
c3bee8c21e | ||
|
|
4d13e8432e | ||
|
|
5f24bec9fe | ||
|
|
afefb8f333 | ||
|
|
2b81fdcc04 | ||
|
|
db298c1d48 | ||
|
|
2877c3d6c0 | ||
|
|
ee64c17108 | ||
|
|
a610314eba | ||
|
|
301edea7ce | ||
|
|
c89d900aae | ||
|
|
4cfb4eeb96 | ||
|
|
f19478f2f6 | ||
|
|
2f11b16299 | ||
|
|
654d2c9f17 | ||
|
|
2721d42594 | ||
|
|
bd1956446d | ||
|
|
d26d7cf91a | ||
|
|
4c20bf1fac | ||
|
|
9dc53ffc60 | ||
|
|
95642fb220 | ||
|
|
6449f3a592 | ||
|
|
91e6cb798d | ||
|
|
bcd37988b5 | ||
|
|
7a64e1a1d1 | ||
|
|
39c85ffb77 | ||
|
|
301ca50541 | ||
|
|
f299e1d9ac | ||
|
|
465e35bf54 | ||
|
|
829af2e3f7 | ||
|
|
51c65bdc69 | ||
|
|
d0c088dd0a | ||
|
|
411d112165 |
@@ -115,6 +115,13 @@ 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 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 +165,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.43.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
|
||||
|
||||
@@ -114,6 +114,8 @@ jobs:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
auth.docker.io:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
dualstack.j.sni.global.fastly.net:443
|
||||
files.pythonhosted.org:443
|
||||
ghcr.io:443
|
||||
github.com:443
|
||||
|
||||
@@ -81,6 +81,8 @@ jobs:
|
||||
pkg-containers.githubusercontent.com:443
|
||||
files.pythonhosted.org:443
|
||||
pypi.org:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
dualstack.j.sni.global.fastly.net:443
|
||||
api.github.com:443
|
||||
mirror.gcr.io:443
|
||||
check.trivy.dev:443
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,12 +10,12 @@ 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/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 +40,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 +60,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 }}
|
||||
@@ -292,7 +292,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 +337,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
|
||||
|
||||
@@ -113,40 +113,82 @@ 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-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:
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280
|
||||
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,16 +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 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
|
||||
# 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 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 \
|
||||
&& 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,39 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [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
|
||||
|
||||
- `FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS` is now applied in months instead of days, and negative values are rejected [(#12580)](https://github.com/prowler-cloud/prowler/pull/12580)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- `sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491 [(#12509)](https://github.com/prowler-cloud/prowler/pull/12509)
|
||||
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
|
||||
|
||||
---
|
||||
|
||||
## [1.40.1] (Prowler v5.39.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280
|
||||
|
||||
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,11 +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 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
|
||||
# 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 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 \
|
||||
@@ -36,7 +48,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
|
||||
&& 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 @@
|
||||
Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374
|
||||
@@ -0,0 +1 @@
|
||||
`libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.41.0"
|
||||
version = "1.44.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",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -1380,6 +1380,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"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.rls import RowLevelSecurityConstraint
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from psqlextra.partitioning import (
|
||||
PostgresPartitioningError,
|
||||
PostgresPartitioningManager,
|
||||
@@ -153,10 +154,17 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
|
||||
)
|
||||
|
||||
|
||||
def relative_days_or_none(value):
|
||||
if value is None:
|
||||
def relative_months_or_none(value):
|
||||
# A negative value would set the cutoff in the future and delete every
|
||||
# partition, so it is rejected rather than silently ignored.
|
||||
if value is not None and value < 0:
|
||||
raise ImproperlyConfigured(
|
||||
"FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; "
|
||||
"leave it unset or use 0 to keep partitions indefinitely"
|
||||
)
|
||||
if not value:
|
||||
return None
|
||||
return relativedelta(days=value)
|
||||
return relativedelta(months=value)
|
||||
|
||||
|
||||
#
|
||||
@@ -173,7 +181,7 @@ manager = PostgresPartitioningManager(
|
||||
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
|
||||
),
|
||||
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
|
||||
max_age=relative_days_or_none(
|
||||
max_age=relative_months_or_none(
|
||||
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
|
||||
),
|
||||
name_format="%Y_%b",
|
||||
@@ -189,7 +197,7 @@ manager = PostgresPartitioningManager(
|
||||
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
|
||||
),
|
||||
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
|
||||
max_age=relative_days_or_none(
|
||||
max_age=relative_months_or_none(
|
||||
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
|
||||
),
|
||||
name_format="%Y_%b",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.41.0
|
||||
version: 1.44.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import UTC, datetime
|
||||
from itertools import islice
|
||||
|
||||
import pytest
|
||||
from api.partitions import (
|
||||
PostgresUUIDv7PartitioningStrategy,
|
||||
relative_months_or_none,
|
||||
)
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from psqlextra.partitioning import PostgresTimePartitionSize
|
||||
|
||||
|
||||
def build_strategy(max_age):
|
||||
return PostgresUUIDv7PartitioningStrategy(
|
||||
size=PostgresTimePartitionSize(months=1),
|
||||
count=1,
|
||||
start_date=datetime.now(UTC),
|
||||
max_age=max_age,
|
||||
name_format="%Y_%b",
|
||||
)
|
||||
|
||||
|
||||
class TestRelativeMonthsOrNone:
|
||||
@pytest.mark.parametrize("value", [None, 0])
|
||||
def test_unset_or_zero_keeps_partitions_indefinitely(self, value):
|
||||
assert relative_months_or_none(value) is None
|
||||
|
||||
@pytest.mark.parametrize("months", [1, 3, 12])
|
||||
def test_value_is_interpreted_as_months(self, months):
|
||||
assert relative_months_or_none(months) == relativedelta(months=months)
|
||||
|
||||
def test_value_is_not_interpreted_as_days(self):
|
||||
assert relative_months_or_none(12) != relativedelta(days=12)
|
||||
|
||||
def test_negative_is_rejected(self):
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
relative_months_or_none(-12)
|
||||
|
||||
|
||||
class TestToDelete:
|
||||
@pytest.mark.parametrize("max_age", [None, relative_months_or_none(0)])
|
||||
def test_nothing_is_deleted_without_max_age(self, max_age):
|
||||
strategy = build_strategy(max_age)
|
||||
|
||||
assert list(islice(strategy.to_delete(), 5)) == []
|
||||
|
||||
def test_first_deleted_partition_is_max_age_old(self):
|
||||
months = 3
|
||||
strategy = build_strategy(relative_months_or_none(months))
|
||||
|
||||
first = next(strategy.to_delete())
|
||||
|
||||
expected = strategy.get_start_datetime() - relativedelta(months=months)
|
||||
assert first.name() == expected.strftime("%Y_%b").lower()
|
||||
|
||||
def test_deleted_partitions_go_further_back_in_time(self):
|
||||
strategy = build_strategy(relative_months_or_none(3))
|
||||
|
||||
names = [p.name() for p in islice(strategy.to_delete(), 3)]
|
||||
starts = [datetime.strptime(n, "%Y_%b") for n in names]
|
||||
|
||||
assert starts == sorted(starts, reverse=True)
|
||||
@@ -82,7 +82,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
|
||||
@@ -4540,6 +4540,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
|
||||
):
|
||||
@@ -8784,6 +8830,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 +9021,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 +9279,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 +18615,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 +18649,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 +18698,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 +18765,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 +18801,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 +18857,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
|
||||
):
|
||||
|
||||
@@ -2149,6 +2149,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 +2181,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 +2189,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."
|
||||
|
||||
@@ -244,7 +244,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 +326,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 +341,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 +2407,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,
|
||||
@@ -4473,7 +4472,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 +4486,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()
|
||||
@@ -7551,35 +7550,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,
|
||||
|
||||
@@ -295,6 +295,11 @@ 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", "")
|
||||
# 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 +308,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
|
||||
|
||||
@@ -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
|
||||
@@ -222,7 +223,9 @@ 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",
|
||||
)
|
||||
s3_client.list_buckets()
|
||||
except (ClientError, NoCredentialsError, ParamValidationError, ValueError):
|
||||
@@ -232,6 +235,44 @@ def get_s3_client():
|
||||
return s3_client
|
||||
|
||||
|
||||
def get_s3_presign_client():
|
||||
"""Return a client that signs URLs against the public storage host.
|
||||
|
||||
None means no public host is configured and the caller should presign with its own
|
||||
client, which leaves deployments on real S3 with the URL they get today.
|
||||
"""
|
||||
public_endpoint = settings.DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL
|
||||
if not public_endpoint:
|
||||
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=public_endpoint,
|
||||
# The signature covers the host, so the addressing style has to be pinned rather
|
||||
# than guessed from the endpoint: MinIO serves path-style.
|
||||
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,104 @@
|
||||
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, StateChoices
|
||||
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
|
||||
|
||||
# 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}"
|
||||
latest_scans = list(
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id__in=provider_ids,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,7 +73,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,
|
||||
@@ -526,6 +529,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 +639,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:
|
||||
@@ -1188,85 +1193,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 +1435,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)
|
||||
|
||||
@@ -4,15 +4,18 @@ import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
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,6 +50,19 @@ 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")
|
||||
@patch("tasks.jobs.export.settings")
|
||||
def test_get_s3_client_fallback(self, mock_settings, mock_boto_client):
|
||||
@@ -243,3 +259,107 @@ 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_PUBLIC_ENDPOINT_URL="")
|
||||
def test_no_public_endpoint_returns_none(self):
|
||||
assert get_s3_presign_client() is None
|
||||
|
||||
@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,6 +9,7 @@ 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 (
|
||||
Finding,
|
||||
@@ -2795,6 +2795,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 +4884,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 +4902,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 +4932,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 +4952,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 +4975,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 +4989,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 +5017,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 +5037,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 +5066,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 +5086,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 +5112,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 +5129,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 +5155,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 +5175,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 +5199,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 +5214,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()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
@@ -33,10 +33,10 @@ from tasks.tasks import (
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
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 +2959,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 +2983,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 +3184,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 +3195,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 +3214,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 +3260,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 +3269,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 +3281,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 +3319,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()
|
||||
|
||||
|
||||
@@ -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" },
|
||||
@@ -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.41.0"
|
||||
version = "1.44.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -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"
|
||||
)
|
||||
@@ -4,6 +4,284 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<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
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Alerts can now reach Slack. Connect a Slack workspace from the Integrations page, authorize one or several destination channels (the connection check confirms each channel with a one-time message and names any channel Slack refuses), and pick those channels in the alert modal's "Destination channels" selector, next to the "Recipients" selector for email. The alerts list summarizes both in a single "Destinations" column, showing a rule's email recipients and Slack channels at a glance. Disconnecting the workspace and recovering from revoked credentials are handled from the same page.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Read more in the [Slack integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration) and the [Alerts documentation](https://docs.prowler.com/user-guide/tutorials/prowler-alerts).
|
||||
|
||||
### 🤖 Lighthouse AI — Answer Feedback
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Every Lighthouse AI answer can now be rated with a thumbs up or thumbs down, with an optional field to describe what worked or what did not. Feedback is collected per answer, directly in the chat, and tells the team where Lighthouse should improve next.
|
||||
|
||||
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
|
||||
|
||||
### 📥 Providers — Imported Findings Indicator
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Providers whose findings were imported with the Prowler CLI now show an "Imported provider" indicator next to their connection status in the providers table. In accounts that mix connected providers with Import Findings uploads, the table now tells them apart at a glance.
|
||||
|
||||

|
||||
|
||||
Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings).
|
||||
|
||||
### 📚 Compliance — NCSC Cyber Essentials 3.3
|
||||
|
||||
Cyber Essentials is the UK National Cyber Security Centre (NCSC) scheme certifying the baseline technical controls an organization must implement, and cloud services are explicitly in scope and cannot be excluded from an assessment. Prowler now includes NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026) as a universal framework, with its 28 requirements organized in the five control themes: Firewalls, Secure Configuration, Security Update Management, User Access Control, and Malware Protection.
|
||||
|
||||
Sixteen requirements map to Azure checks covering the controls the applicant organization owns under the shared responsibility model. The remaining twelve apply to end-user devices, on-premises network appliances, or organizational process, which cloud control-plane evidence cannot observe, so they are reported as Manual.
|
||||
|
||||
Contributed by @m-khan-97. Thank you!
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
Fifteen new checks land across seven providers in this release.
|
||||
|
||||
#### AWS
|
||||
|
||||
- `ecr_repository_image_no_secrets` scans the latest image of each ECR repository, both its configuration and its filesystem layers, for hardcoded secrets. Thanks to @esquaredsec!
|
||||
- Four new Amazon Bedrock checks, thanks to @tamg-aws!
|
||||
- `bedrock_guardrail_contextual_grounding_filter_enabled` verifies that guardrails enable both contextual grounding filters, blocking responses that are not supported by the retrieved source or do not answer the question asked.
|
||||
- `bedrock_custom_model_encrypted_with_cmk` verifies that custom models are encrypted at rest with a customer-managed KMS key instead of an AWS-owned key the organization cannot audit, rotate, or revoke.
|
||||
- `bedrock_knowledge_base_encrypted_with_cmk` verifies that each knowledge-base data source encrypts with a customer-managed KMS key the transient storage used while documents are chunked and embedded.
|
||||
- `bedrock_agent_role_not_shared_across_agents` verifies that every agent has a dedicated execution role, so no agent inherits another's permissions.
|
||||
- `rolesanywhere_profile_restricts_session_permissions` flags IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies.
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
#### GCP
|
||||
|
||||
- `iam_workload_identity_pool_provider_attribute_condition` flags Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals.
|
||||
|
||||
Explore all GCP checks at [Prowler Hub](https://hub.prowler.com/check?provider=gcp).
|
||||
|
||||
#### GitHub
|
||||
|
||||
Three new checks harden GitHub Actions defaults, all contributed by @Edneam. Thank you!
|
||||
|
||||
- `organization_default_workflow_permissions_read_only` and `repository_default_workflow_permissions_read_only` verify that workflows get a read-only default `GITHUB_TOKEN` at the organization and repository level.
|
||||
- `organization_actions_pull_request_approval_disabled` verifies that organizations prevent GitHub Actions from creating and approving pull requests.
|
||||
|
||||
Explore all GitHub checks at [Prowler Hub](https://hub.prowler.com/check?provider=github).
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
- `defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`). Thanks to @Rishi943!
|
||||
|
||||
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
- `oss_bucket_versioning_enabled` verifies that OSS buckets keep versioning enabled, allowing recovery from accidental or malicious object overwrite and deletion. Thanks to @abidedavana!
|
||||
- `oss_bucket_server_side_encryption_enabled` verifies that OSS buckets define a default server-side encryption rule, either AES256 or KMS. Thanks to @alexchen-sys!
|
||||
|
||||
OSS bucket logging, versioning, default encryption, and ACL configurations are also now read correctly from the Alibaba Cloud SDK, so the checks reading them no longer report every bucket as unconfigured.
|
||||
|
||||
Explore all Alibaba Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=alibabacloud).
|
||||
|
||||
#### Huawei Cloud
|
||||
|
||||
- `vpc_security_group_open_egress` flags VPC security groups that allow open egress to the internet. Thanks to @tomitobio!
|
||||
|
||||
Explore all Huawei Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=huaweicloud).
|
||||
|
||||
#### STACKIT
|
||||
|
||||
- `ske_cluster_no_public_endpoint` flags SKE clusters whose Kubernetes API endpoint is reachable from the whole internet, because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0`. Thanks to @johannes-engler-mw!
|
||||
|
||||
Explore all STACKIT checks at [Prowler Hub](https://hub.prowler.com/check?provider=stackit).
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- The API and SDK container images upgrade OpenSSL to 3.5.7-1~deb13u2, patching ten high CVEs; the UI image upgrades `libcrypto3` and `libssl3` to 3.5.8-r0, patching seven high CVEs; the MCP Server image patches CVE-2026-14456 (OpenSSL), CVE-2026-11822, and CVE-2026-11824 (SQLite).
|
||||
- `sqlparse` upgraded to 0.6.0 in the API, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @Edneam: GitHub `organization_default_workflow_permissions_read_only` ([#12122](https://github.com/prowler-cloud/prowler/pull/12122)), `repository_default_workflow_permissions_read_only` ([#12143](https://github.com/prowler-cloud/prowler/pull/12143)), and `organization_actions_pull_request_approval_disabled` ([#12394](https://github.com/prowler-cloud/prowler/pull/12394)) checks
|
||||
- @tamg-aws: four AWS Bedrock checks covering guardrail grounding, CMK encryption, and agent role isolation ([#12459](https://github.com/prowler-cloud/prowler/pull/12459))
|
||||
- @esquaredsec: AWS `ecr_repository_image_no_secrets` check ([#12123](https://github.com/prowler-cloud/prowler/pull/12123))
|
||||
- @Rishi943: Microsoft 365 `defender_domain_dmarc_records_published` check ([#11936](https://github.com/prowler-cloud/prowler/pull/11936))
|
||||
- @abidedavana: Alibaba Cloud `oss_bucket_versioning_enabled` check ([#11913](https://github.com/prowler-cloud/prowler/pull/11913))
|
||||
- @alexchen-sys: Alibaba Cloud `oss_bucket_server_side_encryption_enabled` check ([#11981](https://github.com/prowler-cloud/prowler/pull/11981))
|
||||
- @tomitobio: Huawei Cloud `vpc_security_group_open_egress` check ([#12209](https://github.com/prowler-cloud/prowler/pull/12209))
|
||||
- @johannes-engler-mw: STACKIT `ske_cluster_no_public_endpoint` check ([#11943](https://github.com/prowler-cloud/prowler/pull/11943))
|
||||
- @gabrielfrdev: cluster name in Kubernetes compliance report outputs ([#12506](https://github.com/prowler-cloud/prowler/pull/12506))
|
||||
- @jfgmesquita: AWS FSBP compliance mapping fix for IAM.9 and EKS.1 ([#12372](https://github.com/prowler-cloud/prowler/pull/12372))
|
||||
- @hackertwinten: `ec2_securitygroup_not_used` no longer flags security groups held only by scaled-down AWS Batch compute environments ([#12458](https://github.com/prowler-cloud/prowler/pull/12458))
|
||||
- @0xTaoZ: ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails, shipped early in v5.39.1 ([#12217](https://github.com/prowler-cloud/prowler/pull/12217))
|
||||
- @ye11oc4t: `ses_identity_not_publicly_accessible` now evaluates every identity authorization policy, shipped early in v5.39.1 ([#12464](https://github.com/prowler-cloud/prowler/pull/12464))
|
||||
- @Zuhef: IaC provider raises typed exceptions instead of `sys.exit` when cloning the scanned repository or running Trivy fails ([#12227](https://github.com/prowler-cloud/prowler/pull/12227)), Kubernetes kubelet checks no longer disappear from the scan when a `kubelet-config` ConfigMap is broken ([#12225](https://github.com/prowler-cloud/prowler/pull/12225)), and the CLI `--slack` summary is sent for scans with no findings instead of failing with `ZeroDivisionError` ([#12229](https://github.com/prowler-cloud/prowler/pull/12229))
|
||||
- @m-khan-97: NCSC Cyber Essentials 3.3 compliance framework with Azure provider coverage across the five Cyber Essentials themes ([#11588](https://github.com/prowler-cloud/prowler/pull/11588))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.40.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.39.0" description="August 13, 2026">
|
||||
### 🤖 Lighthouse AI — Finding Skills
|
||||
|
||||
@@ -276,7 +554,7 @@ rss: true
|
||||
|
||||
All checks are fully passive, using AWS APIs and CloudTrail with no instance access or SSM agent required, and are mapped across 23 compliance frameworks, including NIST 800-53 Rev 5, PCI-DSS v4.0, ISO 27001:2022, SOC 2, HIPAA, and MITRE ATT&CK.
|
||||
|
||||
Read more about it this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
|
||||
Read more about it in this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
|
||||
|
||||
Try them out now at [cloud.prowler.com](https://cloud.prowler.com/sign-up)!
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ The AWS provider implementation follows the general [Provider structure](/develo
|
||||
The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the right now implemented services in the following locations:
|
||||
|
||||
- Directly in the code, in location [`prowler/providers/aws/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services)
|
||||
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
|
||||
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
|
||||
|
||||
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all AWS services.
|
||||
|
||||
@@ -131,7 +131,7 @@ def _get_email_identities(self, identity):
|
||||
The AWS checks pattern is described in [checks page](/developer-guide/checks). You can find all the right now implemented checks:
|
||||
|
||||
- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/aws/services/s3/s3_bucket_acl_prohibited/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services/s3/s3_bucket_acl_prohibited))
|
||||
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
|
||||
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
|
||||
|
||||
The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar checks as reference.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ Visual Studio Code (also referred to as VSCode) provides an integrated debugger
|
||||
|
||||
### Debugging Configuration Example
|
||||
|
||||
The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Virtual Studio Code](https://code.visualstudio.com/).
|
||||
The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Visual Studio Code](https://code.visualstudio.com/).
|
||||
|
||||
This file must be placed inside the *.vscode* directory and named *launch.json*:
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid
|
||||
```
|
||||
|
||||
5. **Tag and document scenarios**
|
||||
- Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`,`@aws`) to filter and organize tests.
|
||||
- Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`, `@aws`) to filter and organize tests.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
@@ -71,7 +71,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid
|
||||
}
|
||||
);
|
||||
```
|
||||
- Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**,**Key verification points** and **Notes**.
|
||||
- Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**, **Key verification points** and **Notes**.
|
||||
|
||||
**Example**
|
||||
```Markdown
|
||||
@@ -256,7 +256,7 @@ To execute E2E tests for Prowler Local Server:
|
||||
pnpm run test:e2e
|
||||
```
|
||||
|
||||
This command runs Playwright with the configured projects
|
||||
This command runs Playwright with the configured projects.
|
||||
|
||||
2. **Run E2E tests with the Playwright UI runner**
|
||||
|
||||
|
||||
@@ -40,6 +40,17 @@ 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 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
|
||||
|
||||
@@ -120,14 +120,14 @@ class NewFeatureTools(BaseTool):
|
||||
|
||||
Returns complete feature details including configuration and metadata.
|
||||
"""
|
||||
try:
|
||||
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
|
||||
return DetailedFeature.from_api_response(response["data"]).model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get feature {feature_id}: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
|
||||
return DetailedFeature.from_api_response(response["data"]).model_dump()
|
||||
```
|
||||
|
||||
There is no `try`/`except` here on purpose. A failed request raises, and
|
||||
[Error Handling](#error-handling) explains what turns that raise into a message
|
||||
the agent can act on.
|
||||
|
||||
### Step 2: Create the Models
|
||||
|
||||
Create corresponding models in `prowler_app/models/`:
|
||||
@@ -369,18 +369,62 @@ async def search_items(self, status: str = Field(...)) -> dict:
|
||||
|
||||
### Error Handling
|
||||
|
||||
Return structured error responses instead of raising exceptions:
|
||||
**Raise, never return.** A returned `{"error": ...}` dict is reported to the
|
||||
client as `isError: false` -- a *successful* tool call whose payload happens to
|
||||
mention a failure. Clients and models read that as success. A raised exception
|
||||
becomes a spec-correct tool execution error instead.
|
||||
|
||||
The common case therefore needs no handler at all:
|
||||
|
||||
```python
|
||||
async def get_item(self, item_id: str) -> dict:
|
||||
try:
|
||||
response = await self.api_client.get(f"/api/v1/items/{item_id}")
|
||||
return DetailedItem.from_api_response(response["data"]).model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get item {item_id}: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
response = await self.api_client.get(f"/api/v1/items/{item_id}")
|
||||
return DetailedItem.from_api_response(response["data"]).model_dump()
|
||||
```
|
||||
|
||||
`prowler_mcp_server/lib/errors.py` classifies the failures every tool shares --
|
||||
a rejected credential, a missing permission, a rate limit, an outage, an
|
||||
unreachable API, a bad argument -- and gives each one a message that says what
|
||||
went wrong and what to do about it. Anything it does not recognise is masked,
|
||||
because `mask_error_details=True` is set on every sub-server and upstream
|
||||
response bodies must never be replayed into a model's context.
|
||||
|
||||
Three ways to raise, in the order to reach for them:
|
||||
|
||||
```python
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from prowler_mcp_server.lib.errors import InvalidArgument
|
||||
|
||||
# 1. An argument this server rejected before any request went out. The message
|
||||
# is repeated to the agent verbatim, so write it for one to read.
|
||||
if not 1 <= page_size <= 1000:
|
||||
raise InvalidArgument("page_size must be between 1 and 1000.")
|
||||
|
||||
# 2. A request the API answered or never answered: let it propagate untouched.
|
||||
# `ProwlerAPIError` and `ProwlerAPIUnreachable` are what the classifier keys
|
||||
# on, and the second one is what stops a retry from duplicating a write.
|
||||
response = await self.api_client.get(f"/api/v1/items/{item_id}")
|
||||
data = response["data"]
|
||||
|
||||
# 3. A sentence the classifier cannot know -- a resource name, a precondition,
|
||||
# the next tool to call. NOTE the absent `from` clause: it is what marks the
|
||||
# message as already final. With `from e` the classifier would replace it.
|
||||
if not data:
|
||||
raise ToolError(
|
||||
f"No item with the ID {item_id!r} exists. Use prowler_list_items to "
|
||||
"find a valid one."
|
||||
)
|
||||
```
|
||||
|
||||
The one thing that still *returns* rather than raises is a write whose outcome is
|
||||
genuinely unknown. `prowler_send_findings_to_jira` is the worked example: work
|
||||
items are created one at a time and Prowler cannot delete them, so a dispatch
|
||||
that stopped halfway answers with a result object carrying
|
||||
`safe_to_retry: false`. "This may have been applied" is a fact about the world,
|
||||
not an error, and squashing it into one loses the only thing that stops a retry
|
||||
from duplicating the write.
|
||||
|
||||
### Parameter Descriptions
|
||||
|
||||
Use Pydantic `Field()` with clear descriptions. This also helps LLMs understand
|
||||
|
||||
@@ -25,7 +25,7 @@ For providers supported by Prowler, refer to [Prowler Hub](https://hub.prowler.c
|
||||
|
||||
Prowler supports several types of providers, each with its own implementation pattern and use case. Understanding these differences is key to designing your provider correctly.
|
||||
|
||||
### Classifying your Provider
|
||||
### Classifying Your Provider
|
||||
|
||||
Before implementing a new provider, you need to determine which type it belongs to. This classification will guide your implementation approach and help you choose the right patterns and libraries.
|
||||
|
||||
@@ -1090,7 +1090,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
|
||||
cis.batch_write_data_to_file()
|
||||
```
|
||||
|
||||
#### Step 11: Register in the list of providers
|
||||
#### Step 11: Register in the List of Providers
|
||||
|
||||
**Explanation:**
|
||||
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
|
||||
@@ -1966,7 +1966,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
|
||||
|
||||
This step is the same as the [SDK providers](#step-10-register-in-main).
|
||||
|
||||
#### Step 11: Register in the list of providers
|
||||
#### Step 11: Register in the List of Providers
|
||||
|
||||
**Explanation:**
|
||||
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
|
||||
@@ -2648,7 +2648,7 @@ Main registration makes your provider discoverable by Prowler's core system. It'
|
||||
|
||||
This step is the same as the [SDK providers](#step-10-register-in-main).
|
||||
|
||||
#### Step 7: Register in the list of providers
|
||||
#### Step 7: Register in the List of Providers
|
||||
|
||||
**Explanation:**
|
||||
This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization.
|
||||
@@ -2808,7 +2808,7 @@ def validate_your_provider_uid(value):
|
||||
**Provider Model:**
|
||||
The `Provider` model already exists and supports all provider types. Ensure your provider type is included in the choices.
|
||||
|
||||
### 2.2. Add the provider to the Provider Choices
|
||||
### 2.2. Add the Provider to the Provider Choices
|
||||
|
||||
Update the `return_prowler_provider` function to include your provider. This function is crucial for the API to instantiate the correct provider class.
|
||||
|
||||
@@ -3209,7 +3209,7 @@ class YourProviderAPITestCase(APITestCase):
|
||||
self.assertEqual(response.status_code, 201)
|
||||
```
|
||||
|
||||
#### 2.6.1. Add your mocked provider to the tests
|
||||
#### 2.6.1. Add Your Mocked Provider to the Tests
|
||||
|
||||
If needed, add a named provider fixture or extend the provider factory defaults so tests can request only the provider they need.
|
||||
|
||||
@@ -3272,7 +3272,7 @@ Your provider will be available through these endpoints:
|
||||
- `DELETE /api/v1/providers/{id}/` - Delete provider
|
||||
- `POST /api/v1/providers/secrets/` - Add provider credentials
|
||||
|
||||
### 2.9. Update the provider if needed
|
||||
### 2.9. Update the Provider If Needed
|
||||
|
||||
Depending on your provider's authentication requirements, you may need to add new authentication methods that are compatible with the API. This involves updating the provider class to support additional credential types beyond the basic ones.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ A compliance framework must represent the **complete state** of the source catal
|
||||
Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework.
|
||||
</Warning>
|
||||
|
||||
### Two supported schemas
|
||||
### Two Supported Schemas
|
||||
|
||||
| Schema | When to use | File location | Discovered as |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -45,7 +45,7 @@ Before adding a new framework, complete the following checks:
|
||||
|
||||
## Universal Compliance Framework
|
||||
|
||||
### Where the file lives
|
||||
### Where the File Lives
|
||||
|
||||
Place the file at the top level of the compliance directory:
|
||||
|
||||
@@ -57,7 +57,7 @@ Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/comp
|
||||
|
||||
The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`).
|
||||
|
||||
### Top-level structure
|
||||
### Top-Level Structure
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -198,7 +198,7 @@ Per requirement:
|
||||
|
||||
For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model).
|
||||
|
||||
### Multi-provider frameworks
|
||||
### Multi-Provider Frameworks
|
||||
|
||||
A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict.
|
||||
|
||||
@@ -226,7 +226,7 @@ The legacy schema spans **four layers** — a complete contribution must touch e
|
||||
|
||||
The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions.
|
||||
|
||||
### Directory structure and file naming
|
||||
### Directory Structure and File Naming
|
||||
|
||||
Compliance frameworks live at:
|
||||
|
||||
@@ -259,7 +259,7 @@ prowler/lib/outputs/compliance/<framework>/
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
### JSON schema reference
|
||||
### JSON Schema Reference
|
||||
|
||||
Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py`).
|
||||
|
||||
@@ -362,7 +362,7 @@ For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute
|
||||
The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union — otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`.
|
||||
</Note>
|
||||
|
||||
#### Minimal working example
|
||||
#### Minimal Working Example
|
||||
|
||||
The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity.
|
||||
|
||||
@@ -408,7 +408,7 @@ The following snippet is a complete, valid framework file named `my_framework_1.
|
||||
}
|
||||
```
|
||||
|
||||
### Mapping checks to requirements
|
||||
### Mapping Checks to Requirements
|
||||
|
||||
Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control.
|
||||
|
||||
@@ -425,7 +425,7 @@ To discover available checks:
|
||||
uv run python prowler-cli.py <provider> --list-checks
|
||||
```
|
||||
|
||||
### Supporting multiple providers (legacy)
|
||||
### Supporting Multiple Providers (Legacy)
|
||||
|
||||
The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider:
|
||||
|
||||
@@ -439,7 +439,7 @@ Keep the `Framework` and `Version` values identical across the files so the disp
|
||||
|
||||
For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance/<framework>/` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
|
||||
|
||||
### Output formatter
|
||||
### Output Formatter
|
||||
|
||||
Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema.
|
||||
|
||||
@@ -453,19 +453,19 @@ prowler/lib/outputs/compliance/my_framework/
|
||||
└── models.py # CSV row Pydantic model
|
||||
```
|
||||
|
||||
#### Step 1 — Define the CSV row model
|
||||
#### Step 1 — Define the CSV Row Model
|
||||
|
||||
In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`.
|
||||
|
||||
#### Step 2 — Implement the transformer
|
||||
#### Step 2 — Implement the Transformer
|
||||
|
||||
In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`.
|
||||
|
||||
#### Step 3 — Add the summary-table dispatcher
|
||||
#### Step 3 — Add the Summary-Table Dispatcher
|
||||
|
||||
In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`.
|
||||
|
||||
#### Step 4 — Register the framework in the dispatchers
|
||||
#### Step 4 — Register the Framework in the Dispatchers
|
||||
|
||||
- Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`.
|
||||
- Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan.
|
||||
@@ -474,7 +474,7 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me
|
||||
For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema.
|
||||
</Note>
|
||||
|
||||
### Legacy-to-universal adapter
|
||||
### Legacy-to-Universal Adapter
|
||||
|
||||
At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically.
|
||||
|
||||
@@ -497,7 +497,7 @@ Configuration guardrails close that gap. A requirement declares the configuratio
|
||||
Guardrails are an **optional** safety net for configurable checks. A requirement that maps only to non-configurable checks does not need them. When the field is absent, behavior is unchanged.
|
||||
</Note>
|
||||
|
||||
### Where guardrails are declared
|
||||
### Where Guardrails Are Declared
|
||||
|
||||
The field is attached to each requirement and exists in both schemas:
|
||||
|
||||
@@ -506,7 +506,7 @@ The field is attached to each requirement and exists in both schemas:
|
||||
|
||||
When a legacy file is adapted to the universal model, `adapt_legacy_to_universal()` copies `ConfigRequirements` into `config_requirements` (`compliance_models.py`), so downstream code only ever reads one shape.
|
||||
|
||||
### Constraint schema
|
||||
### Constraint Schema
|
||||
|
||||
Each entry in the list is a single constraint with the following fields:
|
||||
|
||||
@@ -533,7 +533,7 @@ Each entry in the list is a single constraint with the following fields:
|
||||
`subset` / `superset` require both the applied value and `Value` to be lists; any other type is treated as not satisfied. For `eq` against a boolean, declare `Value` as a JSON boolean (`false`, not `0`) — the model keeps booleans distinct from integers.
|
||||
</Note>
|
||||
|
||||
### How guardrails are evaluated
|
||||
### How Guardrails Are Evaluated
|
||||
|
||||
All evaluation lives in one shared module, `prowler/lib/check/compliance_config_eval.py`, consumed by every compliance output (CSV, OCSF, and the CLI tables) and reused by the Prowler API backend so the rule is defined exactly once.
|
||||
|
||||
@@ -547,7 +547,7 @@ All evaluation lives in one shared module, `prowler/lib/check/compliance_config_
|
||||
Guardrails only ever make a result **stricter** (they can turn PASS into FAIL); they never relax a real FAIL into PASS. A requirement with no constraints, or whose keys all use defaults, is reported exactly as before.
|
||||
</Warning>
|
||||
|
||||
### Example: legacy framework
|
||||
### Example: Legacy Framework
|
||||
|
||||
From `prowler/compliance/aws/cis_6.0_aws.json`, requirement 2.11 declares two guardrails — one per configurable check it maps to:
|
||||
|
||||
@@ -590,7 +590,7 @@ A boolean guardrail from the same file: requirement 2.5 (IAM Access Analyzer) on
|
||||
]
|
||||
```
|
||||
|
||||
### Example: universal framework
|
||||
### Example: Universal Framework
|
||||
|
||||
The universal schema uses the lowercase `config_requirements` key with the identical object shape:
|
||||
|
||||
@@ -616,7 +616,7 @@ The universal schema uses the lowercase `config_requirements` key with the ident
|
||||
|
||||
Each constraint declares the `Provider` it targets so the guardrail is only evaluated on scans of that provider — essential for universal frameworks like CSA CCM and DORA, where one requirement maps checks across `aws`, `azure`, `gcp` and more. Because the operator is `subset`, adding `"TLS 1.0"` to `recommended_minimal_tls_versions` widens the allowlist beyond `["TLS 1.2", "TLS 1.3"]` and the requirement is forced to FAIL.
|
||||
|
||||
### What the user sees
|
||||
### What the User Sees
|
||||
|
||||
With a loosened config, the affected requirement's findings report:
|
||||
|
||||
@@ -630,7 +630,7 @@ StatusExtended: Configuration not valid for this requirement. The check
|
||||
|
||||
The same `Configuration not valid for this requirement.` message appears identically across the CSV, OCSF, and console-table outputs.
|
||||
|
||||
### Authoring guidelines
|
||||
### Authoring Guidelines
|
||||
|
||||
- Declare a guardrail only for keys whose value actually changes whether the requirement is met. Most configurable checks do not need one.
|
||||
- Set `Value` to the **strictest** configuration the control tolerates — the same number the control text cites (CIS 45 days, NIST ≤90, and so on).
|
||||
@@ -639,7 +639,7 @@ The same `Configuration not valid for this requirement.` message appears identic
|
||||
- Pick the operator from the value's role: a max threshold is `lte`, a min threshold is `gte`, a toggle is `eq`, an allowlist is `subset`, a denylist is `superset`.
|
||||
- An unrecognized operator does **not** block the requirement — a malformed constraint is treated as satisfied rather than failing the whole framework. Validate your JSON with the tests below.
|
||||
|
||||
### Testing guardrails
|
||||
### Testing Guardrails
|
||||
|
||||
The shared evaluator and the per-output integration are covered by:
|
||||
|
||||
@@ -670,7 +670,7 @@ Prowler matches frameworks by concatenating `Framework` and `Version`. A missing
|
||||
|
||||
Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists.
|
||||
|
||||
### 1. Schema validation
|
||||
### 1. Schema Validation
|
||||
|
||||
For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`.
|
||||
|
||||
@@ -688,7 +688,7 @@ bulk = get_bulk_compliance_frameworks_universal("aws")
|
||||
assert "<your_framework_filename_without_json>" in bulk
|
||||
```
|
||||
|
||||
### 2. Check existence cross-check
|
||||
### 2. Check Existence Cross-Check
|
||||
|
||||
There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory:
|
||||
|
||||
@@ -708,7 +708,7 @@ missing = referenced - real
|
||||
assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}"
|
||||
```
|
||||
|
||||
### 3. CLI smoke test
|
||||
### 3. CLI Smoke Test
|
||||
|
||||
```bash
|
||||
uv run python prowler-cli.py <provider> --list-compliance
|
||||
@@ -728,7 +728,7 @@ Verify that:
|
||||
- The CLI summary table lists every section / pillar of the framework.
|
||||
- Findings roll up under the expected requirements.
|
||||
|
||||
### 4. Inspect the CSV output
|
||||
### 4. Inspect the CSV Output
|
||||
|
||||
Open the generated CSV and confirm:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Pr
|
||||
The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base.
|
||||
</Info>
|
||||
|
||||
## When to use SSE
|
||||
## When to Use SSE
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
@@ -22,7 +22,7 @@ The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature
|
||||
|
||||
SSE is the right tool when the **client only consumes**: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API, and needs no extra protocol.
|
||||
|
||||
## How it works
|
||||
## How It Works
|
||||
|
||||
SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`:
|
||||
|
||||
@@ -34,11 +34,11 @@ SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eve
|
||||
| `make_channel_name` / `tenant_id_from_channel` | `api/sse/utils.py` | Single source of truth for the channel-name format, so publishers and the channel manager agree byte-for-byte. |
|
||||
| Settings | `config/settings/eventstream.py` | Valkey Pub/Sub backend (dedicated DB), channel manager, allowed headers. |
|
||||
|
||||
### Transport: the server runs on ASGI
|
||||
### Transport: The Server Runs on ASGI
|
||||
|
||||
SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn's native **`asgi` worker** (`config.asgi:application`). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in `config/guniconf.py` and used by both the dev and production entrypoints — no separate server process is needed.
|
||||
|
||||
### The data flow
|
||||
### The Data Flow
|
||||
|
||||
```
|
||||
publisher (Celery task / view) subscriber (browser, CLI)
|
||||
@@ -53,7 +53,7 @@ publisher (Celery task / view) subscriber (browser, CLI)
|
||||
|
||||
A publisher anywhere in the system (most often a Celery task) calls `send_event(channel, event_type, payload)`. `django-eventstream` fans it out over Valkey Pub/Sub to every connection subscribed to that channel.
|
||||
|
||||
## Adding an SSE endpoint to your feature
|
||||
## Adding an SSE Endpoint to Your Feature
|
||||
|
||||
The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature.
|
||||
|
||||
@@ -161,7 +161,7 @@ publish_end(channel, scan_id=str(scan.id))
|
||||
|
||||
</Steps>
|
||||
|
||||
## Event naming convention
|
||||
## Event Naming Convention
|
||||
|
||||
Every event uses an event type of the form **`<resource>.<verb>`** (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable.
|
||||
|
||||
@@ -197,7 +197,7 @@ curl -N -H "Authorization: Bearer $JWT" \
|
||||
https://<host>/api/v1/scans/$SCAN_ID/event-stream
|
||||
```
|
||||
|
||||
## Tenant isolation & security model
|
||||
## Tenant Isolation & Security Model
|
||||
|
||||
Authorization is enforced at two layers:
|
||||
|
||||
@@ -206,7 +206,7 @@ Authorization is enforced at two layers:
|
||||
|
||||
Because the tenant id lives inside the channel name, this gate works for any feature without the platform knowing anything about it.
|
||||
|
||||
## Reconnect & state recovery
|
||||
## Reconnect & State Recovery
|
||||
|
||||
The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead:
|
||||
|
||||
@@ -215,7 +215,7 @@ The platform deliberately ships **without server-side replay** (`is_channel_reli
|
||||
|
||||
Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource.
|
||||
|
||||
## Local development
|
||||
## Local Development
|
||||
|
||||
- The dev and production entrypoints both launch Gunicorn with the `asgi` worker (`config.asgi:application`). In dev, `DJANGO_DEBUG=True` enables hot reload; `preload_app` is automatically disabled under debug so edited code is picked up.
|
||||
- SSE uses a **dedicated Valkey database** (`EVENTSTREAM_VALKEY_DB`, default `2`) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the same `VALKEY_*` connection settings as the rest of the platform.
|
||||
|
||||
@@ -537,7 +537,7 @@ This architecture allows Prowler to efficiently scan AWS accounts with resources
|
||||
|
||||
## Best Practices
|
||||
|
||||
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized by to maximize performance and reduce scan time.
|
||||
- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized to maximize performance and reduce scan time.
|
||||
- Define a Pydantic `BaseModel` for every resource you manage, and use these models for all resource data handling.
|
||||
- Log every major step (start, success, error) in resource discovery and attribute collection for traceability and debugging; include as much context as possible.
|
||||
- Catch and log all exceptions, providing detailed context (region, subscription, resource, error type, line number) to aid troubleshooting.
|
||||
|
||||
@@ -154,7 +154,7 @@ Failing to update this table when adding cross-service dependencies may result i
|
||||
For AWS provider, different testing approaches apply based on API coverage based on several criteria.
|
||||
|
||||
<Note>
|
||||
Prowler leverages and contributes to the[Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests.
|
||||
Prowler leverages and contributes to the [Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests.
|
||||
|
||||
</Note>
|
||||
- AWS API Calls Covered by [Moto](https://github.com/getmoto/moto):
|
||||
@@ -408,7 +408,7 @@ In all above scenarios, check execution must occur within the context of mocked
|
||||
|
||||
When a service requires API calls that are partially covered by the Moto decorator, additional mocking is necessary. In such cases, custom mocked API calls must be implemented alongside Moto to ensure full coverage.
|
||||
|
||||
To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using `mock.patch <https://docs.python.org/3/library/unittest.mock.html#patch>`:
|
||||
To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using [`mock.patch`](https://docs.python.org/3/library/unittest.mock.html#patch):
|
||||
|
||||
```python
|
||||
|
||||
@@ -475,7 +475,7 @@ However, if additional `moto` decorators are applied alongside the patch, Moto w
|
||||
|
||||
</Note>
|
||||
<Note>
|
||||
The source of the above implementation can be found here:[Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching\_other\_services.html)
|
||||
The source of the above implementation can be found here: [Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching_other_services.html)
|
||||
|
||||
</Note>
|
||||
#### Mocking Several Services
|
||||
@@ -603,7 +603,7 @@ with mock.patch(
|
||||
|
||||
will cause that the service is initialized only once—at the moment of mocking out `set_mocked_aws_provider([<region>])` using `mock.patch`.
|
||||
|
||||
Later, when Python attempts to import the client at the check level, the execution continues using`from prowler.providers.<provider>.services.<service>.<service>_client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `<service>_client.py`.
|
||||
Later, when Python attempts to import the client at the check level, the execution continues using `from prowler.providers.<provider>.services.<service>.<service>_client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `<service>_client.py`.
|
||||
|
||||
### Testing AWS Services
|
||||
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app-s3-integration",
|
||||
"user-guide/tutorials/prowler-app-security-hub-integration",
|
||||
"user-guide/tutorials/prowler-app-jira-integration"
|
||||
"user-guide/tutorials/prowler-app-jira-integration",
|
||||
"user-guide/tutorials/prowler-app-slack-integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -301,6 +302,15 @@
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Organizations",
|
||||
"pages": [
|
||||
"user-guide/organizations",
|
||||
"user-guide/providers/aws/organizations",
|
||||
"user-guide/providers/gcp/organization",
|
||||
"user-guide/providers/azure/management-groups"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Alibaba Cloud",
|
||||
"pages": [
|
||||
@@ -330,6 +340,7 @@
|
||||
"user-guide/providers/azure/getting-started-azure",
|
||||
"user-guide/providers/azure/authentication",
|
||||
"user-guide/providers/azure/use-non-default-cloud",
|
||||
"user-guide/providers/azure/management-groups",
|
||||
"user-guide/providers/azure/subscriptions",
|
||||
"user-guide/providers/azure/resource-groups",
|
||||
"user-guide/providers/azure/create-prowler-service-principal"
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
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`):
|
||||
Running Prowler requires specifying the provider (e.g. `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`):
|
||||
|
||||
<Note>
|
||||
If no provider is specified, AWS is used by default for backward compatibility with Prowler v2.
|
||||
@@ -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.39.0"
|
||||
PROWLER_API_VERSION="5.39.0"
|
||||
PROWLER_UI_VERSION="5.42.0"
|
||||
PROWLER_API_VERSION="5.42.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -161,7 +161,7 @@ The Prowler MCP Server enables powerful workflows through AI assistants:
|
||||
- "What authentication methods does Prowler support for Azure?"
|
||||
- "How can I contribute with a new security check to Prowler?"
|
||||
|
||||
### Example: Creating a custom dashboard with Prowler extracted data
|
||||
### Example: Creating a Custom Dashboard with Prowler Extracted Data
|
||||
|
||||
In the next example you can see how to create a dashboard using Prowler MCP Server and Claude Desktop.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 93 KiB |
@@ -92,6 +92,7 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::
|
||||
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
|
||||
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
|
||||
|
||||
@@ -22,7 +22,7 @@ See section [Logging](/user-guide/cli/tutorials/logging) for further information
|
||||
|
||||
Common issues with the Docker Compose installation of Prowler Local Server.
|
||||
|
||||
### Problem adding AWS Provider using "Connect assuming IAM Role" in Docker
|
||||
### Problem Adding AWS Provider Using "Connect assuming IAM Role" in Docker
|
||||
|
||||
See [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745) for more details.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -105,7 +105,7 @@ def fixer(resource_id: str) -> bool:
|
||||
return True
|
||||
```
|
||||
|
||||
## Fixer Config file
|
||||
## Fixer Config File
|
||||
|
||||
For some fixers, you can have configurable parameters depending on your use case. You can either use the default config file in `prowler/config/fixer_config.yaml` or create a custom config file and pass it to the fixer with the `--fixer-config` flag. The config file should be a YAML file with the following structure:
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: 'Miscellaneous'
|
||||
|
||||
## Prowler Version
|
||||
|
||||
### Showing the Prowler version:
|
||||
### Showing the Prowler Version
|
||||
|
||||
```console
|
||||
prowler <provider> -V/-v/--version
|
||||
@@ -22,7 +22,7 @@ To enable verbose mode in Prowler, similar to Version 2, use:
|
||||
prowler <provider> --verbose
|
||||
```
|
||||
|
||||
### Filter findings by status
|
||||
### Filter Findings by Status
|
||||
|
||||
Prowler allows filtering findings based on their status, ensuring reports and CLI display only relevant findings:
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ Accounts:
|
||||
|
||||
## AWS Mutelist
|
||||
|
||||
### Muting specific AWS regions
|
||||
### Muting Specific AWS Regions
|
||||
|
||||
If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`:
|
||||
|
||||
|
||||
@@ -43,8 +43,7 @@ prowler <provider> --categories secrets
|
||||
|
||||
Several checks analyse resources that are exposed to the Internet, these are:
|
||||
|
||||
1. apigateway\_restapi\_public
|
||||
|
||||
- apigateway\_restapi\_public
|
||||
- appstream\_fleet\_default\_internet\_access\_disabled
|
||||
- awslambda\_function\_not\_publicly\_accessible
|
||||
- ec2\_ami\_public
|
||||
@@ -58,8 +57,6 @@ Several checks analyse resources that are exposed to the Internet, these are:
|
||||
- ecr\_repositories\_not\_publicly\_accessible
|
||||
- eks\_control\_plane\_endpoint\_access\_restricted
|
||||
- eks\_endpoints\_not\_publicly\_accessible
|
||||
- eks\_control\_plane\_endpoint\_access\_restricted
|
||||
- eks\_endpoints\_not\_publicly\_accessible
|
||||
- elbv2\_internet\_facing
|
||||
- kms\_key\_not\_publicly\_accessible
|
||||
- opensearch\_service\_domains\_not\_publicly\_accessible
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: 'Organizations Across Cloud Providers'
|
||||
description: 'Understand organization hierarchies and onboarding across AWS, Google Cloud, and Azure'
|
||||
---
|
||||
|
||||
Cloud providers use organization-level hierarchies to group accounts, projects, or subscriptions and apply access and governance consistently. Prowler uses these hierarchies to discover cloud targets and help configure multi-account or multi-project scanning.
|
||||
|
||||
This guide explains the shared lifecycle and the differences between AWS Organizations, Google Cloud organizations, and Azure Management Groups. Use the provider-specific guides for commands, permissions, and limitations.
|
||||
|
||||
## Organization Lifecycle
|
||||
|
||||
Organization-level onboarding generally follows these steps:
|
||||
|
||||
1. **Identify the hierarchy:** Locate the organization, management account, management group, folder, organizational unit, or equivalent parent node in the cloud provider.
|
||||
2. **Grant access:** Assign the provider permissions required to enumerate the hierarchy and read the resources that Prowler scans.
|
||||
3. **Discover members:** Use Prowler to retrieve accounts, projects, or subscriptions under the selected hierarchy.
|
||||
4. **Select scan targets:** Choose the cloud targets to connect or scan. Discovery does not necessarily make every discovered target a Prowler provider.
|
||||
5. **Test access:** Confirm that Prowler can authenticate to each selected target and read its resources.
|
||||
6. **Scan and maintain:** Run scans, review findings, and repeat discovery when the provider hierarchy changes.
|
||||
|
||||
<Note>
|
||||
Organization membership changes are not automatically synchronized in every Prowler workflow. Follow the provider-specific guide to learn when manual rediscovery is required.
|
||||
</Note>
|
||||
|
||||
## Capability Matrix
|
||||
|
||||
| Capability | AWS Organizations | Google Cloud organization | Azure Management Groups |
|
||||
| --- | --- | --- | --- |
|
||||
| Hierarchy members | AWS accounts grouped in organizational units (OUs) | Projects grouped in folders and nested folders | Subscriptions grouped in management groups |
|
||||
| Organization-level discovery | Supported through AWS Organizations APIs | Supported through the Cloud Asset API | Supported through Azure management-group and subscription APIs |
|
||||
| Primary scan target | AWS account | Google Cloud project | Azure subscription |
|
||||
| Common organization-level permission | IAM role in the management or delegated administrator account | Cloud Asset Viewer or Cloud Asset Owner at the organization node | Appropriate Azure role assignment at the management-group or subscription scope |
|
||||
| Provider-specific onboarding | AWS account discovery and optional StackSet role deployment | Project discovery under an organization ID | Subscription discovery under a management group; role assignments inherit to subscriptions |
|
||||
| Membership maintenance | Repeat the discovery flow when accounts are added or removed | Re-run organization discovery when projects or folders change | Refresh discovery when subscriptions move between management groups |
|
||||
|
||||
## Provider Guides
|
||||
|
||||
### AWS Organizations
|
||||
|
||||
The [AWS Organizations guide](/user-guide/providers/aws/organizations) covers account details, delegated administration, IAM roles, CloudFormation StackSets, and CLI scanning. For Prowler Cloud onboarding, see [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations).
|
||||
|
||||
### Google Cloud Organization
|
||||
|
||||
The [Google Cloud organization guide](/user-guide/providers/gcp/organization) covers scanning projects under an organization ID, organization-level permissions, and Cloud Asset API requirements. For Prowler Cloud onboarding, see [Google Cloud organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-gcp-organizations).
|
||||
|
||||
### Azure Management Groups
|
||||
|
||||
The [Azure Management Groups guide](/user-guide/providers/azure/management-groups) covers hierarchy setup, role assignment, subscription scope, and Azure-specific limitations. For Prowler Cloud onboarding, see [Azure Management Groups in Prowler Cloud](/user-guide/tutorials/prowler-cloud-azure-management-groups).
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
The organization concepts in this guide refer only to cloud-provider resource hierarchies:
|
||||
|
||||
- **GitHub organizations** group repositories and GitHub resources. They are a separate provider concept and are not part of AWS, Google Cloud, or Azure organization discovery.
|
||||
- **MongoDB Atlas organizations** group Atlas projects and teams. They use a separate provider API and authentication model.
|
||||
- **Prowler Cloud organizations** are internal tenants that isolate providers, scans, findings, users, and permissions. They are not the same as a cloud-provider organization and do not replace one.
|
||||
|
||||
Choose the guide that matches the hierarchy being configured, then use the relevant Prowler Cloud or CLI workflow for the scan targets.
|
||||
@@ -144,25 +144,25 @@ prowler alibabacloud --ecs-ram-role RoleName
|
||||
|
||||
### Step 2: Run the First Scan
|
||||
|
||||
#### Scan all regions
|
||||
#### Scan All Regions
|
||||
|
||||
```bash
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
#### Scan specific regions
|
||||
#### Scan Specific Regions
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --region cn-hangzhou cn-shanghai
|
||||
```
|
||||
|
||||
#### Run specific checks
|
||||
#### Run Specific Checks
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --checks ram_no_root_access_key ram_user_mfa_enabled_console_access
|
||||
```
|
||||
|
||||
#### Run a compliance framework
|
||||
#### Run a Compliance Framework
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --compliance cis_2.0_alibabacloud
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
---
|
||||
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>
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) i
|
||||
|
||||
Prowler can integrate with AWS Organizations to manage the visibility and onboarding of accounts centrally.
|
||||
|
||||
For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations).
|
||||
|
||||
When trusted access is enabled with the Organization, Prowler can discover accounts as they are created and even automate deployment of the Prowler Scan IAM Role.
|
||||
|
||||
> ℹ️ Trusted access can be enabled in the Management Account from the AWS Console under **AWS Organizations → Settings → Trusted access for AWS CloudFormation StackSets**.
|
||||
@@ -165,7 +167,7 @@ Include the `ExternalId` parameter in the StackSet if required by the organizati
|
||||
|
||||
When encountering issues during deployment or needing to target specific OUs or environments (e.g., dev/staging/prod), reach out to the Prowler team via [Slack Community](https://prowler.com/slack) or [Support](mailto:support@prowler.com).
|
||||
|
||||
## Extra: Run Prowler across all accounts in AWS Organizations by assuming roles
|
||||
## Extra: Run Prowler Across All Accounts in AWS Organizations by Assuming Roles
|
||||
|
||||
### Running Prowler Across All AWS Organization Accounts
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: 'Azure Management Groups in Prowler'
|
||||
---
|
||||
|
||||
Azure Management Groups provide a hierarchy above subscriptions. They allow Azure role assignments and governance policies to apply to multiple subscriptions through a shared scope.
|
||||
|
||||
For the cross-provider concepts and lifecycle, see [Organizations Across Cloud Providers](/user-guide/organizations).
|
||||
|
||||
## Azure Hierarchy
|
||||
|
||||
Azure resources are organized in the following order:
|
||||
|
||||
1. Tenant
|
||||
2. Management groups
|
||||
3. Subscriptions
|
||||
4. Resource groups
|
||||
5. Resources
|
||||
|
||||
Prowler scans Azure subscriptions. Management groups help organize those subscriptions and provide a scope where permissions can be assigned, but a management group is not itself a scan target.
|
||||
|
||||
## Create a Management Group
|
||||
|
||||
To create a management group, follow the [official Azure guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal).
|
||||
|
||||

|
||||
|
||||
After creating the management group, add the subscriptions that Prowler should access and scan.
|
||||
|
||||

|
||||
|
||||
## Assign Roles
|
||||
|
||||
Assign the roles required by Prowler at the management-group scope instead of assigning them separately to every subscription. Role assignments at a management group can inherit to its child subscriptions, subject to Azure role-assignment and inheritance rules.
|
||||
|
||||
Use the [subscription scope permissions](/user-guide/providers/azure/authentication#subscription-scope-permissions) guide to identify the permissions required for scans. The identity used by Prowler must be able to read the management-group hierarchy and access each subscription selected for scanning.
|
||||
|
||||
## Subscription Scope
|
||||
|
||||
Management groups organize subscriptions, but Azure scan results remain scoped to individual subscriptions:
|
||||
|
||||
- Prowler Cloud scans one subscription per scan.
|
||||
- Prowler CLI can scan multiple subscriptions by using the `--subscription-ids` option.
|
||||
- A subscription must be accessible to the configured identity before Prowler can scan it.
|
||||
- Moving a subscription between management groups can change the permissions it inherits and may require a connection test or rediscovery.
|
||||
|
||||
See [Azure Subscription Scope](/user-guide/providers/azure/subscriptions) for subscription selection and CLI options.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Management groups do not replace subscription providers in Prowler.
|
||||
- Azure role inheritance depends on the management-group hierarchy and the scope of each assignment; verify access on every subscription selected for scanning.
|
||||
- The Prowler Cloud workflow is designed around Azure management-group discovery and subscription onboarding. The Prowler CLI workflow still requires explicit subscription selection when restricting scans.
|
||||
- Changes to management-group membership or role assignments may not be reflected until the hierarchy is refreshed and access is tested again.
|
||||
@@ -25,14 +25,4 @@ Check the [Authentication > Subscription Scope Permissions](/user-guide/provider
|
||||
|
||||
## Recommendation for Managing Multiple Subscriptions
|
||||
|
||||
Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use management groups in Azure. This approach allows Prowler to efficiently organize and audit multiple subscriptions collectively.
|
||||
|
||||
1. **Create a Management Group**: Follow the [official guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal) to create a new management group.
|
||||
|
||||

|
||||
|
||||
2. **Assign Roles**: Assign necessary roles to the management group, similar to the [role assignment process](#assigning-permissions-for-subscription-scans).
|
||||
|
||||
Role assignment should be done at the management group level instead of per subscription.
|
||||
|
||||
3. **Add Subscriptions**: Add all subscriptions you want to audit to the newly created management group. 
|
||||
Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use [Azure Management Groups](/user-guide/providers/azure/management-groups) to organize subscriptions and assign permissions collectively.
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -4,6 +4,8 @@ title: 'Scanning a Specific GCP Organization'
|
||||
|
||||
By default, Prowler scans all Google Cloud projects accessible to the authenticated user.
|
||||
|
||||
For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations).
|
||||
|
||||
To limit the scan to projects within a specific Google Cloud organization, use the `--organization-id` option with the GCP organization’s ID:
|
||||
|
||||
```console
|
||||
|
||||
@@ -42,7 +42,7 @@ Required for scanning repository security settings:
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Branch protection, security settings | All branch protection checks, secret scanning status |
|
||||
| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, secret scanning status, `repository_default_workflow_permissions_read_only` |
|
||||
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
|
||||
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
|
||||
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
|
||||
@@ -63,7 +63,7 @@ Required for scanning organization-level security settings:
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
|
||||
| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` |
|
||||
| **Members** | Read | Member access reviews | Organization membership auditing |
|
||||
|
||||
#### Account Permissions (Fine-Grained PAT only)
|
||||
@@ -83,8 +83,8 @@ With the **Read-only permissions** listed above, Prowler can run:
|
||||
| Check Category | Coverage | Notes |
|
||||
|----------------|----------|-------|
|
||||
| Branch protection checks (12 checks) | ✅ Full | Signed commits, status checks, PR reviews, etc. |
|
||||
| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS |
|
||||
| Organization checks (3 checks) | ✅ Full | MFA, repo creation policies, default permissions |
|
||||
| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS, default workflow permissions |
|
||||
| Organization checks (5 checks) | ✅ Full | MFA, repo creation policies, default permissions, Actions workflow permissions |
|
||||
| Compliance frameworks | ✅ Full | CIS GitHub Benchmark and others |
|
||||
| Merge settings (`delete_branch_on_merge`) | ⚠️ MANUAL | Requires write permission (see below) |
|
||||
|
||||
@@ -171,6 +171,7 @@ Use OAuth App Tokens when building applications that need delegated user permiss
|
||||
|
||||
- `repo`: Full control of repositories
|
||||
- `read:org`: Read organization and team membership
|
||||
- `admin:org`: Required by `organization_default_workflow_permissions_read_only` and `organization_actions_pull_request_approval_disabled` to read the organization Actions workflow permissions
|
||||
- `read:user`: Read user profile data
|
||||
|
||||
**Create an OAuth App:**
|
||||
@@ -214,7 +215,7 @@ If a GitHub App is required:
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Branch protection, security settings | All branch protection checks, `repository_secret_scanning_enabled` |
|
||||
| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, `repository_secret_scanning_enabled`, `repository_default_workflow_permissions_read_only` |
|
||||
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
|
||||
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
|
||||
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
|
||||
@@ -223,7 +224,7 @@ If a GitHub App is required:
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
|
||||
| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` |
|
||||
| **Members** | Read | Member access reviews | Organization membership auditing |
|
||||
|
||||
**Create a GitHub App:**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -36,7 +36,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
|
||||
|
||||
<VersionBadge version="5.15.0" />
|
||||
|
||||
### Step 1: Add the provider
|
||||
### Step 1: Add the Provider
|
||||
|
||||
1. Navigate to **Providers** and click **Add Provider**.
|
||||

|
||||
@@ -45,13 +45,13 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
|
||||

|
||||
4. (Optional) Add a friendly alias to identify this organization in dashboards.
|
||||
|
||||
### Step 2: Provide API credentials
|
||||
### Step 2: Provide API Credentials
|
||||
|
||||
1. Click **Next** to open the credentials form.
|
||||
2. Paste the **Atlas Public Key** and **Atlas Private Key** generated in the Atlas console.
|
||||

|
||||
|
||||
### Step 3: Test the connection and start scanning
|
||||
### Step 3: Test the Connection and Start Scanning
|
||||
|
||||
1. Click **Test connection** to ensure Prowler Cloud can reach the Atlas API.
|
||||
2. Save the credentials. The provider will appear in the list with its current connection status.
|
||||
@@ -66,11 +66,11 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the
|
||||
|
||||
You can also run MongoDB Atlas assessments directly from the CLI. Both command-line flags and environment variables are supported.
|
||||
|
||||
### Step 1: Select an authentication method
|
||||
### Step 1: Select an Authentication Method
|
||||
|
||||
Choose one of the following authentication methods:
|
||||
|
||||
#### Command-line arguments
|
||||
#### Command-Line Arguments
|
||||
|
||||
```bash
|
||||
prowler mongodbatlas \
|
||||
@@ -78,7 +78,7 @@ prowler mongodbatlas \
|
||||
--atlas-private-key <private_key>
|
||||
```
|
||||
|
||||
#### Environment variables
|
||||
#### Environment Variables
|
||||
|
||||
```bash
|
||||
export ATLAS_PUBLIC_KEY=<public_key>
|
||||
@@ -86,9 +86,9 @@ export ATLAS_PRIVATE_KEY=<private_key>
|
||||
prowler mongodbatlas
|
||||
```
|
||||
|
||||
### Step 2: Run the first scan
|
||||
### Step 2: Run the First Scan
|
||||
|
||||
#### Scan all projects and clusters
|
||||
#### Scan All Projects and Clusters
|
||||
|
||||
```bash
|
||||
prowler mongodbatlas
|
||||
@@ -96,7 +96,7 @@ prowler mongodbatlas
|
||||
|
||||
This command enumerates all projects accessible to the API key and scans every cluster.
|
||||
|
||||
#### Scan a specific project
|
||||
#### Scan a Specific Project
|
||||
|
||||
Add the `--atlas-project-id` flag when you only want to assess one project:
|
||||
|
||||
@@ -104,7 +104,7 @@ Add the `--atlas-project-id` flag when you only want to assess one project:
|
||||
prowler mongodbatlas --atlas-project-id <project-id>
|
||||
```
|
||||
|
||||
### Additional tips
|
||||
### Additional Tips
|
||||
|
||||
- Combine flags (for example, `--checks` or `--services`) just like with other providers.
|
||||
- Use `--output-modes` to export findings in JSON, CSV, ASFF, etc.
|
||||
|
||||