mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 982171a336 | |||
| 703a33108c | |||
| 7c6d658154 | |||
| 21d7d08b4b | |||
| f314725f4d | |||
| 02f43a7ad6 | |||
| 0dd8981ee4 | |||
| 269e51259d | |||
| f4afdf0541 | |||
| 652cb69216 | |||
| 921f49a0de | |||
| 6cb770fcc8 | |||
| 86449fb99d | |||
| 40dd0e640b | |||
| 8db3a89669 | |||
| c802dc8a36 | |||
| 3ab9a4efa5 | |||
| 36b8aa1b79 | |||
| e821e07d7d | |||
| 228fe6d579 | |||
| 578186aa40 | |||
| 4608e45c8a | |||
| 5987651aee | |||
| 85800f2ddd | |||
| 4fb5272362 | |||
| 85d38b5f71 | |||
| 59dcdb87c4 | |||
| 9297453b8a | |||
| dd37f4ee1f | |||
| 20f36f7c84 | |||
| ec4d27746f | |||
| 7076900fb1 | |||
| 5d90352a0f | |||
| a981dc64a7 | |||
| d2086cad3f | |||
| 380b89cfb6 | |||
| 13b04d339b | |||
| be3c5fb3c1 | |||
| 1de01bcb78 | |||
| 13d983450c | |||
| 8b368e1343 | |||
| c76a9baa20 | |||
| 30e2813e02 | |||
| 0f874c6ffd | |||
| 2242689295 | |||
| e252058af4 | |||
| 37e6c9761f | |||
| ebe666bec7 | |||
| 7df2703db1 | |||
| 67234210ba | |||
| 15ca69942d | |||
| df76efc197 | |||
| 3441ad7f70 | |||
| 059b71d34b | |||
| 013809919c | |||
| 368d9c1519 | |||
| fb6da427f8 | |||
| 65fd3335d3 | |||
| d6288be472 | |||
| 0cddb71d1c | |||
| af2930130c | |||
| b668770480 | |||
| f31c5717e9 | |||
| 4788dcade2 | |||
| 22a6cc9e73 | |||
| 06bb382f8e | |||
| d4ece2b43e | |||
| b97d68fbd5 | |||
| ca79300440 | |||
| 7a0e107617 | |||
| 6d3fcec5da | |||
| ce1cf51d37 | |||
| 3554859a5c | |||
| 80d62f355f | |||
| 0df24eeff6 | |||
| d1fc482832 | |||
| ffb1bb89e1 | |||
| d877bea0e3 | |||
| 2304bf0093 | |||
| 2ca74102a9 | |||
| 6ae129fcc0 | |||
| e9731f53ad | |||
| db2f92e6d5 | |||
| f4b0f8fa22 | |||
| dff5541e11 | |||
| 927be17fb7 | |||
| c27cb28a2a | |||
| 94ee24071a | |||
| 1093f6c99b | |||
| 48060c47ba | |||
| 72acc2119d | |||
| b1ebea4a7e | |||
| 001057644e | |||
| 1456def7d4 |
@@ -0,0 +1,23 @@
|
||||
# Prowler worktree automation for worktrunk (wt CLI).
|
||||
# Runs automatically on `wt switch --create`.
|
||||
|
||||
# Block 1: setup + copy gitignored env files (.envrc, ui/.env.local)
|
||||
# from the primary worktree — patterns selected via .worktreeinclude.
|
||||
[[pre-start]]
|
||||
skills = "./skills/setup.sh --claude"
|
||||
python = "poetry env use python3.12"
|
||||
envs = "wt step copy-ignored"
|
||||
|
||||
# Block 2: install Python deps (requires `poetry env use` from block 1).
|
||||
[[pre-start]]
|
||||
deps = "poetry install --with dev"
|
||||
|
||||
# Block 3: reminder — last visible output before `wt switch` returns.
|
||||
# Hooks can't mutate the parent shell, so venv activation is manual.
|
||||
[[pre-start]]
|
||||
reminder = "echo '>> Reminder: activate the venv in this shell with: eval $(poetry env activate)'"
|
||||
|
||||
# Background: pnpm install runs while you start working.
|
||||
# Tail logs via `wt config state logs`.
|
||||
[post-start]
|
||||
ui = "cd ui && pnpm install"
|
||||
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.25.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: "🔎 New Check Request"
|
||||
description: Request a new Prowler security check
|
||||
title: "[New Check]: "
|
||||
labels: ["feature-request", "status/needs-triage"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: search
|
||||
attributes:
|
||||
label: Existing check search
|
||||
description: Confirm this check does not already exist before opening a new request.
|
||||
options:
|
||||
- label: I have searched existing issues, Prowler Hub, and the public roadmap, and this check does not already exist.
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form to describe the security condition that Prowler should evaluate.
|
||||
|
||||
The most useful inputs for [Prowler Studio](https://github.com/prowler-cloud/prowler-studio) are:
|
||||
- What should be detected
|
||||
- What PASS and FAIL mean
|
||||
- Vendor docs, API references, SDK methods, CLI commands, or reference code
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Provider
|
||||
description: Cloud or platform this check targets.
|
||||
options:
|
||||
- AWS
|
||||
- Azure
|
||||
- GCP
|
||||
- Kubernetes
|
||||
- GitHub
|
||||
- Microsoft 365
|
||||
- OCI
|
||||
- Alibaba Cloud
|
||||
- Cloudflare
|
||||
- MongoDB Atlas
|
||||
- Google Workspace
|
||||
- OpenStack
|
||||
- Vercel
|
||||
- NHN
|
||||
- Other / New provider
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: other_provider_name
|
||||
attributes:
|
||||
label: New provider name
|
||||
description: Only fill this if you selected "Other / New provider" above.
|
||||
placeholder: "NewProviderName"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: service_name
|
||||
attributes:
|
||||
label: Service or product area
|
||||
description: Optional. Main service, product, or feature to audit.
|
||||
placeholder: "s3, bedrock, entra, repository, apiserver"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: suggested_check_name
|
||||
attributes:
|
||||
label: Suggested check name
|
||||
description: Optional. Use `snake_case` following `<service>_<resource>_<best_practice>`, with lowercase letters and underscores only.
|
||||
placeholder: "bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context and goal
|
||||
description: Describe the security problem, why it matters, and what this new check should help detect.
|
||||
placeholder: |-
|
||||
- Security condition to validate:
|
||||
- Why it matters:
|
||||
- Resource, feature, or configuration involved:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Explain what the check should evaluate and what PASS, FAIL, or MANUAL should mean.
|
||||
placeholder: |-
|
||||
- Resource or scope to evaluate:
|
||||
- PASS when:
|
||||
- FAIL when:
|
||||
- MANUAL when (if applicable):
|
||||
- Exclusions, thresholds, or edge cases:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: Add vendor docs, API references, SDK methods, CLI commands, endpoint docs, sample payloads, or similar reference material.
|
||||
placeholder: |-
|
||||
- Product or service documentation:
|
||||
- API or SDK reference:
|
||||
- CLI command or endpoint documentation:
|
||||
- Sample payload or response:
|
||||
- Security advisory or benchmark:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: Suggested severity
|
||||
description: Your best estimate. Reviewers will confirm during triage.
|
||||
options:
|
||||
- Critical
|
||||
- High
|
||||
- Medium
|
||||
- Low
|
||||
- Informational
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: implementation_notes
|
||||
attributes:
|
||||
label: Additional implementation notes
|
||||
description: Optional. Add permissions, unsupported regions, config knobs, product limitations, or anything else that may affect implementation.
|
||||
placeholder: |-
|
||||
- Required permissions or scopes:
|
||||
- Region, tenant, or subscription limitations:
|
||||
- Configurable behavior or thresholds:
|
||||
- Other constraints:
|
||||
validations:
|
||||
required: false
|
||||
@@ -22,6 +22,10 @@ inputs:
|
||||
description: 'Run `poetry lock` during setup. Only enable when a prior step mutates pyproject.toml (e.g. API `@master` VCS rewrite). Default: false.'
|
||||
required: false
|
||||
default: 'false'
|
||||
enable-cache:
|
||||
description: 'Whether to enable Poetry dependency caching via actions/setup-python'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
@@ -74,8 +78,10 @@ runs:
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache: 'poetry'
|
||||
cache-dependency-path: ${{ inputs.working-directory }}/poetry.lock
|
||||
# Disable cache when callers skip dependency install: Poetry 2.3.4 creates
|
||||
# the venv in a path setup-python can't hash, breaking the post-step save-cache.
|
||||
cache: ${{ inputs.enable-cache == 'true' && 'poetry' || '' }}
|
||||
cache-dependency-path: ${{ inputs.enable-cache == 'true' && format('{0}/poetry.lock', inputs.working-directory) || '' }}
|
||||
|
||||
- name: Install Python dependencies
|
||||
if: inputs.install-dependencies == 'true'
|
||||
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
files: |
|
||||
api/**
|
||||
.github/workflows/api-security.yml
|
||||
.safety-policy.yml
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
@@ -80,10 +81,8 @@ jobs:
|
||||
|
||||
- name: Safety
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: poetry run safety check --ignore 79023,79027,86217,71600
|
||||
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
|
||||
# TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0`
|
||||
# TODO: 71600 CVE-2024-1135 false positive - fixed in gunicorn 22.0.0, project uses 23.0.0
|
||||
# Accepted CVEs, severity threshold, and ignore expirations live in ../.safety-policy.yml
|
||||
run: poetry run safety check --policy-file ../.safety-policy.yml
|
||||
|
||||
- name: Vulture
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
|
||||
- name: Check labels
|
||||
id: label_check
|
||||
|
||||
@@ -12,74 +12,12 @@ concurrency:
|
||||
env:
|
||||
PROWLER_VERSION: ${{ github.event.release.tag_name }}
|
||||
BASE_BRANCH: master
|
||||
DOCS_FILE: docs/getting-started/installation/prowler-app.mdx
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
detect-release-type:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
is_minor: ${{ steps.detect.outputs.is_minor }}
|
||||
is_patch: ${{ steps.detect.outputs.is_patch }}
|
||||
major_version: ${{ steps.detect.outputs.major_version }}
|
||||
minor_version: ${{ steps.detect.outputs.minor_version }}
|
||||
patch_version: ${{ steps.detect.outputs.patch_version }}
|
||||
current_docs_version: ${{ steps.get_docs_version.outputs.current_docs_version }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get current documentation version
|
||||
id: get_docs_version
|
||||
run: |
|
||||
CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' docs/getting-started/installation/prowler-app.mdx)
|
||||
echo "current_docs_version=${CURRENT_DOCS_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Current documentation version: $CURRENT_DOCS_VERSION"
|
||||
|
||||
- name: Detect release type and parse version
|
||||
id: detect
|
||||
run: |
|
||||
if [[ $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
MAJOR_VERSION=${BASH_REMATCH[1]}
|
||||
MINOR_VERSION=${BASH_REMATCH[2]}
|
||||
PATCH_VERSION=${BASH_REMATCH[3]}
|
||||
|
||||
echo "major_version=${MAJOR_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
echo "minor_version=${MINOR_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
echo "patch_version=${PATCH_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if (( MAJOR_VERSION != 5 )); then
|
||||
echo "::error::Releasing another Prowler major version, aborting..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( PATCH_VERSION == 0 )); then
|
||||
echo "is_minor=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "is_patch=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "✓ Minor release detected: $PROWLER_VERSION"
|
||||
else
|
||||
echo "is_minor=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "is_patch=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "✓ Patch release detected: $PROWLER_VERSION"
|
||||
fi
|
||||
else
|
||||
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bump-minor-version:
|
||||
needs: detect-release-type
|
||||
if: needs.detect-release-type.outputs.is_minor == 'true'
|
||||
bump-version:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -91,185 +29,60 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
- name: Validate release version
|
||||
run: |
|
||||
if [[ ! $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
if (( ${BASH_REMATCH[1]} != 5 )); then
|
||||
echo "::error::Releasing another Prowler major version, aborting..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout master branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ env.BASE_BRANCH }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Calculate next minor version
|
||||
- name: Read current docs version on master
|
||||
id: docs_version
|
||||
run: |
|
||||
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
|
||||
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
|
||||
CURRENT_DOCS_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION}"
|
||||
|
||||
NEXT_MINOR_VERSION=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).0
|
||||
CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' "${DOCS_FILE}")
|
||||
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
|
||||
echo "NEXT_MINOR_VERSION=${NEXT_MINOR_VERSION}" >> "${GITHUB_ENV}"
|
||||
echo "Current docs version on master: $CURRENT_DOCS_VERSION"
|
||||
echo "Target release version: $PROWLER_VERSION"
|
||||
|
||||
echo "Current documentation version: $CURRENT_DOCS_VERSION"
|
||||
echo "Current release version: $PROWLER_VERSION"
|
||||
echo "Next minor version: $NEXT_MINOR_VERSION"
|
||||
env:
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION: ${{ needs.detect-release-type.outputs.current_docs_version }}
|
||||
# Skip if master is already at or ahead of the release version
|
||||
# (re-run, or patch shipped against an older minor line)
|
||||
HIGHEST=$(printf '%s\n%s\n' "${CURRENT_DOCS_VERSION}" "${PROWLER_VERSION}" | sort -V | tail -n1)
|
||||
if [[ "${CURRENT_DOCS_VERSION}" == "${PROWLER_VERSION}" || "${HIGHEST}" != "${PROWLER_VERSION}" ]]; then
|
||||
echo "skip=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "Skipping bump: current ($CURRENT_DOCS_VERSION) >= release ($PROWLER_VERSION)"
|
||||
else
|
||||
echo "skip=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Bump versions in documentation for master
|
||||
- name: Bump versions in documentation
|
||||
if: steps.docs_version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Update prowler-app.mdx with current release version
|
||||
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
|
||||
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
|
||||
|
||||
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
|
||||
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}"
|
||||
echo "Files modified:"
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for documentation update to master
|
||||
if: steps.docs_version.outputs.skip == 'false'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
base: master
|
||||
commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
|
||||
branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}
|
||||
title: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
|
||||
labels: no-changelog,skip-sync
|
||||
body: |
|
||||
### Description
|
||||
|
||||
Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} after releasing Prowler v${{ env.PROWLER_VERSION }}.
|
||||
|
||||
### Files Updated
|
||||
- `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION`
|
||||
- All `*.mdx` files with `<VersionBadge>` components
|
||||
|
||||
### License
|
||||
|
||||
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
|
||||
|
||||
- name: Checkout version branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Calculate first patch version
|
||||
run: |
|
||||
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
|
||||
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
|
||||
CURRENT_DOCS_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION}"
|
||||
|
||||
FIRST_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.1
|
||||
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
|
||||
|
||||
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
|
||||
echo "FIRST_PATCH_VERSION=${FIRST_PATCH_VERSION}" >> "${GITHUB_ENV}"
|
||||
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
|
||||
|
||||
echo "First patch version: $FIRST_PATCH_VERSION"
|
||||
echo "Version branch: $VERSION_BRANCH"
|
||||
env:
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION: ${{ needs.detect-release-type.outputs.current_docs_version }}
|
||||
|
||||
- name: Bump versions in documentation for version branch
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Update prowler-app.mdx with current release version
|
||||
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
|
||||
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
|
||||
|
||||
echo "Files modified:"
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for documentation update to version branch
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
base: ${{ env.VERSION_BRANCH }}
|
||||
commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
|
||||
branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}-branch
|
||||
title: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
|
||||
labels: no-changelog,skip-sync
|
||||
body: |
|
||||
### Description
|
||||
|
||||
Update Prowler documentation version references to v${{ env.PROWLER_VERSION }} in version branch after releasing Prowler v${{ env.PROWLER_VERSION }}.
|
||||
|
||||
### Files Updated
|
||||
- `docs/getting-started/installation/prowler-app.mdx`: `PROWLER_UI_VERSION` and `PROWLER_API_VERSION`
|
||||
|
||||
### License
|
||||
|
||||
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
|
||||
|
||||
bump-patch-version:
|
||||
needs: detect-release-type
|
||||
if: needs.detect-release-type.outputs.is_patch == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Calculate next patch version
|
||||
run: |
|
||||
MAJOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION}
|
||||
MINOR_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION}
|
||||
PATCH_VERSION=${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION}
|
||||
CURRENT_DOCS_VERSION="${NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION}"
|
||||
|
||||
NEXT_PATCH_VERSION=${MAJOR_VERSION}.${MINOR_VERSION}.$((PATCH_VERSION + 1))
|
||||
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
|
||||
|
||||
echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}"
|
||||
echo "NEXT_PATCH_VERSION=${NEXT_PATCH_VERSION}" >> "${GITHUB_ENV}"
|
||||
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
|
||||
|
||||
echo "Current documentation version: $CURRENT_DOCS_VERSION"
|
||||
echo "Current release version: $PROWLER_VERSION"
|
||||
echo "Next patch version: $NEXT_PATCH_VERSION"
|
||||
echo "Target branch: $VERSION_BRANCH"
|
||||
env:
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MAJOR_VERSION: ${{ needs.detect-release-type.outputs.major_version }}
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_MINOR_VERSION: ${{ needs.detect-release-type.outputs.minor_version }}
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_PATCH_VERSION: ${{ needs.detect-release-type.outputs.patch_version }}
|
||||
NEEDS_DETECT_RELEASE_TYPE_OUTPUTS_CURRENT_DOCS_VERSION: ${{ needs.detect-release-type.outputs.current_docs_version }}
|
||||
|
||||
- name: Bump versions in documentation for patch version
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Update prowler-app.mdx with current release version
|
||||
sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
|
||||
sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" docs/getting-started/installation/prowler-app.mdx
|
||||
|
||||
echo "Files modified:"
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for documentation update to version branch
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
base: ${{ env.VERSION_BRANCH }}
|
||||
commit-message: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
|
||||
branch: docs-version-update-to-v${{ env.PROWLER_VERSION }}
|
||||
title: 'docs: Update version to v${{ env.PROWLER_VERSION }}'
|
||||
base: ${{ env.BASE_BRANCH }}
|
||||
commit-message: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}'
|
||||
branch: docs-version-bump-to-v${{ env.PROWLER_VERSION }}
|
||||
title: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}'
|
||||
labels: no-changelog,skip-sync
|
||||
body: |
|
||||
### Description
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
"Alan-TheGentleman"
|
||||
"alejandrobailo"
|
||||
"amitsharm"
|
||||
"andoniaf"
|
||||
# "andoniaf"
|
||||
"cesararroba"
|
||||
"danibarranqueroo"
|
||||
"HugoPBrito"
|
||||
|
||||
@@ -20,7 +20,13 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
check-compliance-mapping:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false
|
||||
if: >-
|
||||
github.event.pull_request.state == 'open' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false &&
|
||||
(
|
||||
(github.event.action != 'labeled' && github.event.action != 'unlabeled')
|
||||
|| github.event.label.name == 'no-compliance-check'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
install-dependencies: 'false'
|
||||
enable-cache: 'false'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -113,9 +113,9 @@ jobs:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
base: master
|
||||
commit-message: 'chore(release): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
|
||||
branch: version-bump-to-v${{ env.NEXT_MINOR_VERSION }}
|
||||
title: 'chore(release): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
|
||||
commit-message: 'chore(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
|
||||
branch: sdk-version-bump-to-v${{ env.NEXT_MINOR_VERSION }}
|
||||
title: 'chore(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}'
|
||||
labels: no-changelog,skip-sync
|
||||
body: |
|
||||
### Description
|
||||
@@ -165,9 +165,9 @@ jobs:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
base: ${{ env.VERSION_BRANCH }}
|
||||
commit-message: 'chore(release): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
|
||||
branch: version-bump-to-v${{ env.FIRST_PATCH_VERSION }}
|
||||
title: 'chore(release): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
|
||||
commit-message: 'chore(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
|
||||
branch: sdk-version-bump-to-v${{ env.FIRST_PATCH_VERSION }}
|
||||
title: 'chore(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}'
|
||||
labels: no-changelog,skip-sync
|
||||
body: |
|
||||
### Description
|
||||
@@ -233,9 +233,9 @@ jobs:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
base: ${{ env.VERSION_BRANCH }}
|
||||
commit-message: 'chore(release): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
|
||||
branch: version-bump-to-v${{ env.NEXT_PATCH_VERSION }}
|
||||
title: 'chore(release): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
|
||||
commit-message: 'chore(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
|
||||
branch: sdk-version-bump-to-v${{ env.NEXT_PATCH_VERSION }}
|
||||
title: 'chore(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}'
|
||||
labels: no-changelog,skip-sync
|
||||
body: |
|
||||
### Description
|
||||
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
install-dependencies: 'false'
|
||||
enable-cache: 'false'
|
||||
|
||||
- name: Inject poetry-bumpversion plugin
|
||||
run: pipx inject poetry poetry-bumpversion
|
||||
|
||||
@@ -80,6 +80,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
install-dependencies: 'false'
|
||||
enable-cache: 'false'
|
||||
|
||||
- name: Build Prowler package
|
||||
run: poetry build
|
||||
@@ -116,6 +117,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
install-dependencies: 'false'
|
||||
enable-cache: 'false'
|
||||
|
||||
- name: Install toml package
|
||||
run: pip install toml
|
||||
|
||||
@@ -83,7 +83,8 @@ jobs:
|
||||
|
||||
- name: Security scan with Safety
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: poetry run safety check -r pyproject.toml
|
||||
# Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml
|
||||
run: poetry run safety check -r pyproject.toml --policy-file .safety-policy.yml
|
||||
|
||||
- name: Dead code detection with Vulture
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -209,11 +209,11 @@ jobs:
|
||||
echo "AWS service_paths='${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}'"
|
||||
|
||||
if [ "${STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL}" = "true" ]; then
|
||||
poetry run pytest -p no:randomly -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
|
||||
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
|
||||
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
|
||||
echo "No AWS service paths detected; skipping AWS tests."
|
||||
else
|
||||
poetry run pytest -p no:randomly -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
|
||||
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
|
||||
fi
|
||||
env:
|
||||
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
|
||||
|
||||
@@ -151,6 +151,8 @@ node_modules
|
||||
|
||||
# Persistent data
|
||||
_data/
|
||||
/openspec/
|
||||
/.gitmodules
|
||||
|
||||
# AI Instructions (generated by skills/setup.sh from AGENTS.md)
|
||||
CLAUDE.md
|
||||
|
||||
+99
-22
@@ -1,18 +1,34 @@
|
||||
# Priority tiers (lower = runs first, same priority = concurrent):
|
||||
# P0 — fast file fixers
|
||||
# P10 — validators and guards
|
||||
# P20 — auto-formatters
|
||||
# P30 — linters
|
||||
# P40 — security scanners
|
||||
# P50 — dependency validation
|
||||
|
||||
default_install_hook_types: [pre-commit, pre-push]
|
||||
|
||||
repos:
|
||||
## GENERAL
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
## GENERAL (prek built-in — no external repo needed)
|
||||
- repo: builtin
|
||||
hooks:
|
||||
- id: check-merge-conflict
|
||||
priority: 10
|
||||
- id: check-yaml
|
||||
args: ["--unsafe"]
|
||||
exclude: prowler/config/llm_config.yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
exclude: (prowler/config/llm_config.yaml|contrib/)
|
||||
priority: 10
|
||||
- id: check-json
|
||||
priority: 10
|
||||
- id: end-of-file-fixer
|
||||
priority: 0
|
||||
- id: trailing-whitespace
|
||||
priority: 0
|
||||
- id: no-commit-to-branch
|
||||
priority: 10
|
||||
- id: pretty-format-json
|
||||
args: ["--autofix", --no-sort-keys, --no-ensure-ascii]
|
||||
priority: 10
|
||||
|
||||
## TOML
|
||||
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
||||
@@ -21,13 +37,20 @@ repos:
|
||||
- id: pretty-format-toml
|
||||
args: [--autofix]
|
||||
files: pyproject.toml
|
||||
priority: 20
|
||||
|
||||
## GITHUB ACTIONS
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.24.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
files: ^\.github/
|
||||
# Scope to the inputs zizmor actually audits: workflows, composite
|
||||
# actions, and dependabot config. The previous `^\.github/` regex
|
||||
# also matched issue templates / labeler / CODEOWNERS, which made
|
||||
# zizmor exit 3 ("no audit was performed") on commits that touch
|
||||
# those files.
|
||||
files: ^\.github/(workflows|actions)/.+\.ya?ml$|^\.github/dependabot\.ya?ml$
|
||||
priority: 30
|
||||
|
||||
## BASH
|
||||
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||
@@ -35,106 +58,160 @@ repos:
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
exclude: contrib
|
||||
priority: 30
|
||||
|
||||
## PYTHON
|
||||
## PYTHON — SDK (prowler/, tests/, dashboard/, util/, scripts/)
|
||||
- repo: https://github.com/myint/autoflake
|
||||
rev: v2.3.3
|
||||
hooks:
|
||||
- id: autoflake
|
||||
exclude: ^skills/
|
||||
name: "SDK - autoflake"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
args:
|
||||
[
|
||||
"--in-place",
|
||||
"--remove-all-unused-imports",
|
||||
"--remove-unused-variable",
|
||||
]
|
||||
priority: 20
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 8.0.1
|
||||
hooks:
|
||||
- id: isort
|
||||
exclude: ^skills/
|
||||
name: "SDK - isort"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
args: ["--profile", "black"]
|
||||
priority: 20
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 26.3.1
|
||||
hooks:
|
||||
- id: black
|
||||
exclude: ^skills/
|
||||
name: "SDK - black"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
priority: 20
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.3.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
exclude: (contrib|^skills/)
|
||||
name: "SDK - flake8"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
args: ["--ignore=E266,W503,E203,E501,W605"]
|
||||
priority: 30
|
||||
|
||||
## PYTHON — API + MCP Server (ruff)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.11
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "API + MCP - ruff check"
|
||||
files: { glob: ["{api,mcp_server}/**/*.py"] }
|
||||
args: ["--fix"]
|
||||
priority: 30
|
||||
- id: ruff-format
|
||||
name: "API + MCP - ruff format"
|
||||
files: { glob: ["{api,mcp_server}/**/*.py"] }
|
||||
priority: 20
|
||||
|
||||
## PYTHON — Poetry
|
||||
- repo: https://github.com/python-poetry/poetry
|
||||
rev: 2.3.4
|
||||
hooks:
|
||||
- id: poetry-check
|
||||
name: API - poetry-check
|
||||
args: ["--directory=./api"]
|
||||
files: { glob: ["api/{pyproject.toml,poetry.lock}"] }
|
||||
pass_filenames: false
|
||||
priority: 50
|
||||
|
||||
- id: poetry-lock
|
||||
name: API - poetry-lock
|
||||
args: ["--directory=./api"]
|
||||
files: { glob: ["api/{pyproject.toml,poetry.lock}"] }
|
||||
pass_filenames: false
|
||||
priority: 50
|
||||
|
||||
- id: poetry-check
|
||||
name: SDK - poetry-check
|
||||
args: ["--directory=./"]
|
||||
files: { glob: ["{pyproject.toml,poetry.lock}"] }
|
||||
pass_filenames: false
|
||||
priority: 50
|
||||
|
||||
- id: poetry-lock
|
||||
name: SDK - poetry-lock
|
||||
args: ["--directory=./"]
|
||||
files: { glob: ["{pyproject.toml,poetry.lock}"] }
|
||||
pass_filenames: false
|
||||
priority: 50
|
||||
|
||||
## CONTAINERS
|
||||
- repo: https://github.com/hadolint/hadolint
|
||||
rev: v2.14.0
|
||||
hooks:
|
||||
- id: hadolint
|
||||
args: ["--ignore=DL3013"]
|
||||
priority: 30
|
||||
|
||||
## LOCAL HOOKS
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pylint
|
||||
name: pylint
|
||||
entry: bash -c 'pylint --disable=W,C,R,E -j 0 -rn -sn prowler/'
|
||||
name: "SDK - pylint"
|
||||
entry: pylint --disable=W,C,R,E -j 0 -rn -sn
|
||||
language: system
|
||||
files: '.*\.py'
|
||||
types: [python]
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
priority: 30
|
||||
|
||||
- id: trufflehog
|
||||
name: TruffleHog
|
||||
description: Detect secrets in your data.
|
||||
entry: bash -c 'trufflehog --no-update git file://. --only-verified --fail'
|
||||
entry: bash -c 'trufflehog --no-update git file://. --since-commit HEAD --only-verified --fail'
|
||||
# For running trufflehog in docker, use the following entry instead:
|
||||
# entry: bash -c 'docker run -v "$(pwd):/workdir" -i --rm trufflesecurity/trufflehog:latest git file:///workdir --only-verified --fail'
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: ["pre-commit", "pre-push"]
|
||||
priority: 40
|
||||
|
||||
- id: bandit
|
||||
name: bandit
|
||||
description: "Bandit is a tool for finding common security issues in Python code"
|
||||
entry: bash -c 'bandit -q -lll -x '*_test.py,./contrib/,./.venv/,./skills/' -r .'
|
||||
entry: bandit -q -lll
|
||||
language: system
|
||||
types: [python]
|
||||
files: '.*\.py'
|
||||
exclude:
|
||||
{ glob: ["{contrib,skills}/**", "**/.venv/**", "**/*_test.py"] }
|
||||
priority: 40
|
||||
|
||||
- id: safety
|
||||
name: safety
|
||||
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
|
||||
# TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X
|
||||
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
|
||||
# TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0`
|
||||
# TODO: 71600 CVE-2024-1135 false positive - fixed in gunicorn 22.0.0, project uses 23.0.0
|
||||
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,86217,71600'
|
||||
# Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml
|
||||
entry: safety check --policy-file .safety-policy.yml
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files:
|
||||
{
|
||||
glob:
|
||||
[
|
||||
"**/pyproject.toml",
|
||||
"**/poetry.lock",
|
||||
"**/requirements*.txt",
|
||||
".safety-policy.yml",
|
||||
],
|
||||
}
|
||||
priority: 40
|
||||
|
||||
- id: vulture
|
||||
name: vulture
|
||||
description: "Vulture finds unused code in Python programs."
|
||||
entry: bash -c 'vulture --exclude "contrib,.venv,api/src/backend/api/tests/,api/src/backend/conftest.py,api/src/backend/tasks/tests/,skills/" --min-confidence 100 .'
|
||||
entry: vulture --min-confidence 100
|
||||
language: system
|
||||
types: [python]
|
||||
files: '.*\.py'
|
||||
priority: 40
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Safety policy for `safety check` (Safety CLI 3.x, v2 schema).
|
||||
# Applied in: .pre-commit-config.yaml, .github/workflows/api-security.yml,
|
||||
# .github/workflows/sdk-security.yml via `--policy-file`.
|
||||
#
|
||||
# Validate: poetry run safety validate policy_file --path .safety-policy.yml
|
||||
|
||||
security:
|
||||
# Scan unpinned requirements too. Prowler pins via poetry.lock, so this is
|
||||
# defensive against accidental unpinned entries.
|
||||
ignore-unpinned-requirements: False
|
||||
|
||||
# CVSS severity filter. 7 = report only HIGH (7.0–8.9) and CRITICAL (9.0–10.0).
|
||||
# Reference: 9=CRITICAL only, 7=CRITICAL+HIGH, 4=CRITICAL+HIGH+MEDIUM.
|
||||
ignore-cvss-severity-below: 7
|
||||
|
||||
# Unknown severity is unrated, not safe. Keep False so unrated CVEs still fail
|
||||
# the build and get a human eye. Flip to True only if noise is unmanageable.
|
||||
ignore-cvss-unknown-severity: False
|
||||
|
||||
# Fail the build when a non-ignored vulnerability is found.
|
||||
continue-on-vulnerability-error: False
|
||||
|
||||
# Explicit accepted vulnerabilities. Each entry MUST have a reason and an
|
||||
# expiry. Expired entries fail the scan, forcing re-audit.
|
||||
ignore-vulnerabilities:
|
||||
77744:
|
||||
reason: "Botocore requires urllib3 1.X. Remove once upgraded to urllib3 2.X."
|
||||
expires: '2026-10-22'
|
||||
77745:
|
||||
reason: "Botocore requires urllib3 1.X. Remove once upgraded to urllib3 2.X."
|
||||
expires: '2026-10-22'
|
||||
79023:
|
||||
reason: "knack ReDoS; blocked until azure-cli-core (via cartography) allows knack >=0.13.0."
|
||||
expires: '2026-10-22'
|
||||
79027:
|
||||
reason: "knack ReDoS; blocked until azure-cli-core (via cartography) allows knack >=0.13.0."
|
||||
expires: '2026-10-22'
|
||||
86217:
|
||||
reason: "alibabacloud-tea-openapi==0.4.3 blocks upgrade to cryptography >=46.0.0."
|
||||
expires: '2026-10-22'
|
||||
71600:
|
||||
reason: "CVE-2024-1135 false positive. Fixed in gunicorn 22.0.0; project uses 23.0.0."
|
||||
expires: '2026-10-22'
|
||||
70612:
|
||||
reason: "TBD - audit required. Reason not documented in prior --ignore list."
|
||||
expires: '2026-07-22'
|
||||
66963:
|
||||
reason: "TBD - audit required. Reason not documented in prior --ignore list."
|
||||
expires: '2026-07-22'
|
||||
74429:
|
||||
reason: "TBD - audit required. Reason not documented in prior --ignore list."
|
||||
expires: '2026-07-22'
|
||||
76352:
|
||||
reason: "TBD - audit required. Reason not documented in prior --ignore list."
|
||||
expires: '2026-07-22'
|
||||
76353:
|
||||
reason: "TBD - audit required. Reason not documented in prior --ignore list."
|
||||
expires: '2026-07-22'
|
||||
@@ -0,0 +1,2 @@
|
||||
.envrc
|
||||
ui/.env.local
|
||||
+19
@@ -9,6 +9,9 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
ARG TRIVY_VERSION=0.69.2
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
@@ -48,6 +51,22 @@ RUN ARCH=$(uname -m) && \
|
||||
mkdir -p /tmp/.cache/trivy && \
|
||||
chmod 777 /tmp/.cache/trivy
|
||||
|
||||
# Install zizmor for GitHub Actions workflow scanning
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
ZIZMOR_ARCH="x86_64-unknown-linux-gnu" ; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then \
|
||||
ZIZMOR_ARCH="aarch64-unknown-linux-gnu" ; \
|
||||
else \
|
||||
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
|
||||
mkdir -p /tmp/zizmor-extract && \
|
||||
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
|
||||
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
|
||||
chmod +x /usr/local/bin/zizmor && \
|
||||
rm -rf /tmp/zizmor.tar.gz /tmp/zizmor-extract
|
||||
|
||||
# Add prowler user
|
||||
RUN addgroup --gid 1000 prowler && \
|
||||
adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler
|
||||
|
||||
@@ -104,22 +104,22 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 572 | 83 | 41 | 17 | Official | UI, API, CLI |
|
||||
| Azure | 165 | 20 | 18 | 13 | Official | UI, API, CLI |
|
||||
| GCP | 100 | 13 | 15 | 11 | Official | UI, API, CLI |
|
||||
| Kubernetes | 83 | 7 | 7 | 9 | Official | UI, API, CLI |
|
||||
| GitHub | 21 | 2 | 1 | 2 | Official | UI, API, CLI |
|
||||
| M365 | 89 | 9 | 4 | 5 | Official | UI, API, CLI |
|
||||
| OCI | 48 | 13 | 3 | 10 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 61 | 9 | 3 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 29 | 2 | 0 | 5 | Official | UI, API, CLI |
|
||||
| AWS | 595 | 84 | 43 | 17 | Official | UI, API, CLI |
|
||||
| Azure | 167 | 22 | 19 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 102 | 18 | 17 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 83 | 7 | 7 | 11 | Official | UI, API, CLI |
|
||||
| GitHub | 24 | 3 | 1 | 5 | Official | UI, API, CLI |
|
||||
| M365 | 101 | 10 | 4 | 10 | Official | UI, API, CLI |
|
||||
| OCI | 51 | 14 | 4 | 10 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 61 | 9 | 4 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 29 | 3 | 0 | 5 | Official | UI, API, CLI |
|
||||
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| MongoDB Atlas | 10 | 3 | 0 | 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 | CLI, API |
|
||||
| Google Workspace | 1 | 1 | 0 | 1 | Official | CLI |
|
||||
| OpenStack | 27 | 4 | 0 | 8 | Official | UI, API, CLI |
|
||||
| Vercel | 30 | 6 | 0 | 5 | Official | CLI |
|
||||
| Google Workspace | 25 | 4 | 2 | 4 | Official | CLI |
|
||||
| OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI |
|
||||
| Vercel | 26 | 6 | 0 | 5 | Official | CLI |
|
||||
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
|
||||
|
||||
> [!Note]
|
||||
@@ -300,6 +300,36 @@ python prowler-cli.py -v
|
||||
> If your Poetry version is below v2.0.0, continue using `poetry shell` to activate your environment.
|
||||
> For further guidance, refer to the Poetry Environment Activation Guide https://python-poetry.org/docs/managing-environments/#activating-the-environment.
|
||||
|
||||
# 🛡️ GitHub Action
|
||||
|
||||
The official **Prowler GitHub Action** runs Prowler scans in your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. Scans run on any [supported provider](https://docs.prowler.com/user-guide/providers/), with optional [`--push-to-cloud`](https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings) to send findings to Prowler Cloud and optional SARIF upload so findings show up in the repo's **Security → Code scanning** tab and as inline PR annotations.
|
||||
|
||||
```yaml
|
||||
name: Prowler IaC Scan
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
prowler:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: iac
|
||||
output-formats: sarif json-ocsf
|
||||
upload-sarif: true
|
||||
flags: --severity critical high
|
||||
```
|
||||
|
||||
Full configuration, per-provider authentication, and SARIF examples: [Prowler GitHub Action tutorial](docs/user-guide/tutorials/prowler-app-github-action.mdx). Marketplace listing: [Prowler Security Scan](https://github.com/marketplace/actions/prowler-security-scan).
|
||||
|
||||
# ✏️ High level architecture
|
||||
|
||||
## Prowler App
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
name: Prowler Security Scan
|
||||
description: Run Prowler cloud security scanner using the official Docker image
|
||||
branding:
|
||||
icon: cloud
|
||||
color: green
|
||||
|
||||
inputs:
|
||||
provider:
|
||||
description: Cloud provider to scan (e.g. aws, azure, gcp, github, kubernetes, iac). See https://docs.prowler.com for supported providers.
|
||||
required: true
|
||||
image-tag:
|
||||
description: >
|
||||
Docker image tag for prowlercloud/prowler.
|
||||
Default is "stable" (latest release). Available tags:
|
||||
"stable" (latest release), "latest" (master branch, not stable),
|
||||
"<x.y.z>" (pinned release version).
|
||||
See all tags at https://hub.docker.com/r/prowlercloud/prowler/tags
|
||||
required: false
|
||||
default: stable
|
||||
output-formats:
|
||||
description: Output format(s) for scan results (e.g. "json-ocsf", "sarif json-ocsf")
|
||||
required: false
|
||||
default: json-ocsf
|
||||
push-to-cloud:
|
||||
description: Push scan findings to Prowler Cloud. Requires the PROWLER_CLOUD_API_KEY environment variable. See https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings#using-the-cli
|
||||
required: false
|
||||
default: "false"
|
||||
flags:
|
||||
description: 'Additional CLI flags passed to the Prowler scan (e.g. "--severity critical high --compliance cis_aws"). Values containing spaces can be quoted, e.g. "--resource-tag ''Environment=My Server''".'
|
||||
required: false
|
||||
default: ""
|
||||
extra-env:
|
||||
description: >
|
||||
Space-, newline-, or comma-separated list of host environment variable NAMES to forward to the Prowler container
|
||||
(e.g. "AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN" for AWS,
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN" for GitHub, "CLOUDFLARE_API_TOKEN" for Cloudflare).
|
||||
List names only; set the values via `env:` at the workflow or job level (typically from `secrets.*`).
|
||||
See the README for per-provider examples.
|
||||
required: false
|
||||
default: ""
|
||||
upload-sarif:
|
||||
description: 'Upload SARIF results to GitHub Code Scanning (requires "sarif" in output-formats and both `security-events: write` and `actions: read` permissions)'
|
||||
required: false
|
||||
default: "false"
|
||||
sarif-file:
|
||||
description: Path to the SARIF file to upload (auto-detected from output/ if not set)
|
||||
required: false
|
||||
default: ""
|
||||
sarif-category:
|
||||
description: Category for the SARIF upload (used to distinguish multiple analyses)
|
||||
required: false
|
||||
default: prowler
|
||||
fail-on-findings:
|
||||
description: Fail the workflow step when Prowler detects findings (exit code 3). By default the action tolerates findings and succeeds.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_IMAGE_TAG: ${{ inputs.image-tag }}
|
||||
INPUT_UPLOAD_SARIF: ${{ inputs.upload-sarif }}
|
||||
INPUT_OUTPUT_FORMATS: ${{ inputs.output-formats }}
|
||||
run: |
|
||||
# Validate image tag format (alphanumeric, dots, hyphens, underscores only)
|
||||
if [[ ! "$INPUT_IMAGE_TAG" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||
echo "::error::Invalid image-tag '${INPUT_IMAGE_TAG}'. Must contain only alphanumeric characters, dots, hyphens, and underscores."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Warn if upload-sarif is enabled but sarif not in output-formats
|
||||
if [ "$INPUT_UPLOAD_SARIF" = "true" ]; then
|
||||
if [[ ! "$INPUT_OUTPUT_FORMATS" =~ (^|[[:space:]])sarif($|[[:space:]]) ]]; then
|
||||
echo "::warning::upload-sarif is enabled but 'sarif' is not included in output-formats ('${INPUT_OUTPUT_FORMATS}'). SARIF upload will fail unless you add 'sarif' to output-formats."
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Run Prowler scan
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_PROVIDER: ${{ inputs.provider }}
|
||||
INPUT_IMAGE_TAG: ${{ inputs.image-tag }}
|
||||
INPUT_OUTPUT_FORMATS: ${{ inputs.output-formats }}
|
||||
INPUT_PUSH_TO_CLOUD: ${{ inputs.push-to-cloud }}
|
||||
INPUT_FLAGS: ${{ inputs.flags }}
|
||||
INPUT_EXTRA_ENV: ${{ inputs.extra-env }}
|
||||
INPUT_FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Parse space-separated inputs with shlex so values with spaces can be quoted
|
||||
# (e.g. `--resource-tag 'Environment=My Server'`).
|
||||
mapfile -t OUTPUT_FORMATS < <(python3 -c 'import shlex, os; [print(t) for t in shlex.split(os.environ.get("INPUT_OUTPUT_FORMATS", ""))]')
|
||||
mapfile -t EXTRA_FLAGS < <(python3 -c 'import shlex, os; [print(t) for t in shlex.split(os.environ.get("INPUT_FLAGS", ""))]')
|
||||
mapfile -t EXTRA_ENV_NAMES < <(python3 -c 'import shlex, os; [print(t) for t in shlex.split(os.environ.get("INPUT_EXTRA_ENV", "").replace(",", " "))]')
|
||||
|
||||
env_args=()
|
||||
for var in "${EXTRA_ENV_NAMES[@]}"; do
|
||||
[ -z "$var" ] && continue
|
||||
if [[ ! "$var" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
||||
echo "::error::Invalid env var name '${var}' in extra-env. Names must match ^[A-Za-z_][A-Za-z0-9_]*$."
|
||||
exit 1
|
||||
fi
|
||||
env_args+=("-e" "$var")
|
||||
done
|
||||
|
||||
push_args=()
|
||||
if [ "$INPUT_PUSH_TO_CLOUD" = "true" ]; then
|
||||
push_args=("--push-to-cloud")
|
||||
env_args+=("-e" "PROWLER_CLOUD_API_KEY")
|
||||
fi
|
||||
|
||||
mkdir -p "$GITHUB_WORKSPACE/output"
|
||||
chmod 777 "$GITHUB_WORKSPACE/output"
|
||||
|
||||
set +e
|
||||
docker run --rm \
|
||||
"${env_args[@]}" \
|
||||
-v "$GITHUB_WORKSPACE:/home/prowler/workspace" \
|
||||
-v "$GITHUB_WORKSPACE/output:/home/prowler/workspace/output" \
|
||||
-w /home/prowler/workspace \
|
||||
"prowlercloud/prowler:${INPUT_IMAGE_TAG}" \
|
||||
"$INPUT_PROVIDER" \
|
||||
--output-formats "${OUTPUT_FORMATS[@]}" \
|
||||
"${push_args[@]}" \
|
||||
"${EXTRA_FLAGS[@]}"
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
# Exit code 3 = findings detected
|
||||
if [ "$exit_code" -eq 3 ] && [ "$INPUT_FAIL_ON_FINDINGS" != "true" ]; then
|
||||
echo "::notice::Prowler detected findings (exit code 3). Set fail-on-findings to 'true' to fail the workflow on findings."
|
||||
exit 0
|
||||
fi
|
||||
exit $exit_code
|
||||
|
||||
- name: Upload scan results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: prowler-${{ inputs.provider }}
|
||||
path: output/
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Find SARIF file
|
||||
if: always() && inputs.upload-sarif == 'true'
|
||||
id: find-sarif
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_SARIF_FILE: ${{ inputs.sarif-file }}
|
||||
run: |
|
||||
if [ -n "$INPUT_SARIF_FILE" ]; then
|
||||
echo "sarif_path=$INPUT_SARIF_FILE" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
sarif_file=$(find output/ -name '*.sarif' -type f | head -1)
|
||||
if [ -z "$sarif_file" ]; then
|
||||
echo "::warning::No .sarif file found in output/. Ensure 'sarif' is included in output-formats."
|
||||
echo "sarif_path=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "sarif_path=$sarif_file" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
if: always() && inputs.upload-sarif == 'true' && steps.find-sarif.outputs.sarif_path != ''
|
||||
uses: github/codeql-action/upload-sarif@d4b3ca9fa7f69d38bfcd667bdc45bc373d16277e # v4
|
||||
with:
|
||||
sarif_file: ${{ steps.find-sarif.outputs.sarif_path }}
|
||||
category: ${{ inputs.sarif-category }}
|
||||
|
||||
- name: Write scan summary
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_PROVIDER: ${{ inputs.provider }}
|
||||
INPUT_UPLOAD_SARIF: ${{ inputs.upload-sarif }}
|
||||
INPUT_PUSH_TO_CLOUD: ${{ inputs.push-to-cloud }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
|
||||
# Build a link to the scan step in the workflow logs. Requires `actions: read`
|
||||
# on the caller's GITHUB_TOKEN; silently skips the link if unavailable.
|
||||
scan_step_url=""
|
||||
if [ -n "${GH_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then
|
||||
job_info=$(gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT:-1}/jobs" \
|
||||
--jq ".jobs[] | select(.runner_name == \"${RUNNER_NAME:-}\")" 2>/dev/null)
|
||||
if [ -n "$job_info" ]; then
|
||||
job_id=$(jq -r '.id // empty' <<<"$job_info")
|
||||
step_number=$(jq -r '[.steps[]? | select((.name // "") | test("Run Prowler scan"; "i")) | .number] | first // empty' <<<"$job_info")
|
||||
if [ -z "$step_number" ]; then
|
||||
step_number=$(jq -r '[.steps[]? | select(.status == "in_progress") | .number] | first // empty' <<<"$job_info")
|
||||
fi
|
||||
if [ -n "$job_id" ] && [ -n "$step_number" ]; then
|
||||
scan_step_url="${REPO_URL}/actions/runs/${GITHUB_RUN_ID}/job/${job_id}#step:${step_number}:1"
|
||||
elif [ -n "$job_id" ]; then
|
||||
scan_step_url="${REPO_URL}/actions/runs/${GITHUB_RUN_ID}/job/${job_id}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Map provider code to a properly-cased display name.
|
||||
case "$INPUT_PROVIDER" in
|
||||
alibabacloud) provider_name="Alibaba Cloud" ;;
|
||||
aws) provider_name="AWS" ;;
|
||||
azure) provider_name="Azure" ;;
|
||||
cloudflare) provider_name="Cloudflare" ;;
|
||||
gcp) provider_name="GCP" ;;
|
||||
github) provider_name="GitHub" ;;
|
||||
googleworkspace) provider_name="Google Workspace" ;;
|
||||
iac) provider_name="IaC" ;;
|
||||
image) provider_name="Container Image" ;;
|
||||
kubernetes) provider_name="Kubernetes" ;;
|
||||
llm) provider_name="LLM" ;;
|
||||
m365) provider_name="Microsoft 365" ;;
|
||||
mongodbatlas) provider_name="MongoDB Atlas" ;;
|
||||
nhn) provider_name="NHN" ;;
|
||||
openstack) provider_name="OpenStack" ;;
|
||||
oraclecloud) provider_name="Oracle Cloud" ;;
|
||||
vercel) provider_name="Vercel" ;;
|
||||
*) provider_name="${INPUT_PROVIDER^}" ;;
|
||||
esac
|
||||
|
||||
ocsf_file=$(find output/ -name '*.ocsf.json' -type f 2>/dev/null | head -1)
|
||||
|
||||
{
|
||||
echo "## Prowler ${provider_name} Scan Summary"
|
||||
echo ""
|
||||
|
||||
counts=""
|
||||
if [ -n "$ocsf_file" ] && [ -s "$ocsf_file" ]; then
|
||||
counts=$(jq -r '[
|
||||
length,
|
||||
([.[] | select(.status_code == "FAIL")] | length),
|
||||
([.[] | select(.status_code == "PASS")] | length),
|
||||
([.[] | select(.status_code == "MUTED")] | length),
|
||||
([.[] | select(.status_code == "FAIL" and .severity == "Critical")] | length),
|
||||
([.[] | select(.status_code == "FAIL" and .severity == "High")] | length),
|
||||
([.[] | select(.status_code == "FAIL" and .severity == "Medium")] | length),
|
||||
([.[] | select(.status_code == "FAIL" and .severity == "Low")] | length),
|
||||
([.[] | select(.status_code == "FAIL" and .severity == "Informational")] | length)
|
||||
] | @tsv' "$ocsf_file" 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -n "$counts" ]; then
|
||||
read -r total fail pass muted critical high medium low info <<<"$counts"
|
||||
|
||||
line="**${fail:-0} failing** · ${pass:-0} passing"
|
||||
[ "${muted:-0}" -gt 0 ] && line="${line} · ${muted} muted"
|
||||
echo "${line} — ${total:-0} checks total"
|
||||
echo ""
|
||||
echo "| Severity | Failing |"
|
||||
echo "|----------|---------|"
|
||||
echo "| ‼️ Critical | ${critical:-0} |"
|
||||
echo "| 🔴 High | ${high:-0} |"
|
||||
echo "| 🟠 Medium | ${medium:-0} |"
|
||||
echo "| 🔵 Low | ${low:-0} |"
|
||||
echo "| ⚪ Informational | ${info:-0} |"
|
||||
echo ""
|
||||
else
|
||||
echo "_No findings report was produced. Check the scan logs above._"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$scan_step_url" ]; then
|
||||
echo "**Scan logs:** [view in workflow run](${scan_step_url})"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "**Get the full report:** [\`prowler-${INPUT_PROVIDER}\` artifact](${RUN_URL}#artifacts)"
|
||||
|
||||
if [ "$INPUT_UPLOAD_SARIF" = "true" ] && [ -n "$BRANCH" ]; then
|
||||
encoded_branch=$(jq -nr --arg b "$BRANCH" '$b|@uri')
|
||||
echo ""
|
||||
echo "**See results in GitHub Code Security:** [open alerts on \`${BRANCH}\`](${REPO_URL}/security/code-scanning?query=is%3Aopen+branch%3A${encoded_branch})"
|
||||
fi
|
||||
|
||||
if [ "$INPUT_PUSH_TO_CLOUD" != "true" ]; then
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "### Scale ${provider_name} security with Prowler Cloud ☁️"
|
||||
echo ""
|
||||
echo "Send this scan's findings to **[Prowler Cloud](https://cloud.prowler.com)** and get:"
|
||||
echo ""
|
||||
echo "- **Unified findings** across every cloud, SaaS provider (M365, Google Workspace, GitHub, MongoDB Atlas), IaC repo, Kubernetes cluster, and container image"
|
||||
echo "- **Posture over time** with alerts, and notifications"
|
||||
echo "- **Prowler Lighthouse AI**: agentic assistant that triages findings, explains root cause and helps with remediation"
|
||||
echo "- **50+ Compliance frameworks** mapped automatically"
|
||||
echo "- **Enterprise-ready platform**: SOC 2 Type 2, SSO/SAML, AWS Security Hub, S3 and Jira integrations"
|
||||
echo ""
|
||||
echo "**Get started in 3 steps:**"
|
||||
echo "1. Create an account at [cloud.prowler.com](https://cloud.prowler.com)"
|
||||
echo "2. Generate a Prowler Cloud API key ([docs](https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings#using-the-cli))"
|
||||
echo "3. Add \`PROWLER_CLOUD_API_KEY\` to your GitHub secrets and set \`push-to-cloud: true\` on this action"
|
||||
echo ""
|
||||
echo "See [prowler.com/pricing](https://prowler.com/pricing) for plan details."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
+53
-2
@@ -2,19 +2,70 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.26.0] (Prowler UNRELEASED)
|
||||
## [1.27.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
|
||||
---
|
||||
|
||||
## [1.26.1] (Prowler v5.25.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: AWS scans no longer fail when enabled regions cannot be retrieved, and scans stuck in `scheduled` state are now cleaned up after the stale threshold [(#10917)](https://github.com/prowler-cloud/prowler/pull/10917)
|
||||
- Scan report and compliance downloads now redirect to a presigned S3 URL instead of streaming through the API worker, preventing gunicorn timeouts on large files [(#10927)](https://github.com/prowler-cloud/prowler/pull/10927)
|
||||
|
||||
---
|
||||
|
||||
## [1.26.0] (Prowler v5.25.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- CIS Benchmark PDF report generation for scans, exposing the latest CIS version per provider via `GET /scans/{id}/cis/{name}/` [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650)
|
||||
- `/overviews/resource-groups` (resource inventory), `/overviews/categories` and `/overviews/attack-surfaces` now reflect newly-muted findings without waiting for the next scan. The post-mute `reaggregate-all-finding-group-summaries` task now also dispatches `aggregate_scan_resource_group_summaries_task`, `aggregate_scan_category_summaries_task` and `aggregate_attack_surface_task` per latest scan of every `(provider, day)` pair, rebuilding `ScanGroupSummary`, `ScanCategorySummary` and `AttackSurfaceOverview` alongside the tables already covered in #10827 [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843)
|
||||
- Install zizmor v1.24.1 in API Docker image for GitHub Actions workflow scanning [(#10607)](https://github.com/prowler-cloud/prowler/pull/10607)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
- `aggregate_findings`, `aggregate_attack_surface`, `aggregate_scan_resource_group_summaries` and `aggregate_scan_category_summaries` now upsert via `bulk_create(update_conflicts=True, ...)` instead of the prior `ignore_conflicts=True` / plain INSERT / `already backfilled` short-circuit. Re-runs triggered by the post-mute reaggregation pipeline no longer trip the `unique_*_per_scan` constraints nor silently drop updates, and are race-safe under concurrent writers (e.g. scan completion overlapping with a fresh mute rule) [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843)
|
||||
- Rename the scan-category and scan-resource-group summary aggregators from `backfill_*` to `aggregate_*` [(#10843)](https://github.com/prowler-cloud/prowler/pull/10843)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `generate_outputs_task` crashing with `KeyError` for compliance frameworks listed by `get_compliance_frameworks` but not loadable by `Compliance.get_bulk` [(#10903)](https://github.com/prowler-cloud/prowler/pull/10903)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.4] (Prowler v5.24.4)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `DJANGO_SENTRY_TRACES_SAMPLE_RATE` env var (default `0.02`) enables Sentry performance tracing for the API [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths: Neo4j driver `connection_acquisition_timeout` is now configurable via `NEO4J_CONN_ACQUISITION_TIMEOUT` (default lowered from 120 s to 15 s) [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `/tmp/prowler_api_output` saturation in compliance report workers: the final `rmtree` in `generate_compliance_reports` now only waits on frameworks actually generated for the provider (so unsupported frameworks no longer leave a placeholder `results` entry that blocks cleanup), output directories are created lazily per enabled framework, and both `generate_compliance_reports` and `generate_outputs_task` run an opportunistic stale cleanup at task start with a 48h age threshold, a per-host `fcntl` throttle, a 50-deletions-per-run cap, and guards that protect EXECUTING scans and scans whose `output_location` still points to a local path (metadata lookups routed through the admin DB so RLS does not hide those rows) [(#10874)](https://github.com/prowler-cloud/prowler/pull/10874)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.3] (Prowler v5.24.3)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `/overviews/findings`, `/overviews/findings-severity` and `/overviews/services` now reflect newly-muted findings without waiting for the next scan. The post-mute `reaggregate-all-finding-group-summaries` task was extended to re-run the same per-scan pipeline that scan completion runs (`ScanSummary`, `DailySeveritySummary`, `FindingGroupDailySummary`) on the latest scan of every `(provider, day)` pair, keeping the pre-aggregated tables in sync with `Finding.muted` updates [(#10827)](https://github.com/prowler-cloud/prowler/pull/10827)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Finding groups aggregated `status` now treats muted findings as resolved: a group is `FAIL` only while at least one non-muted FAIL remains, otherwise it is `PASS` (including fully-muted groups). The `filter[status]` filter and the `sort=status` ordering share the same semantics, keeping `status` consistent with `fail_count` and the orthogonal `muted` flag [(#10825)](https://github.com/prowler-cloud/prowler/pull/10825)
|
||||
- `aggregate_findings` is now idempotent: it deletes the scan's existing `ScanSummary` rows before `bulk_create`, so re-runs (such as the post-mute reaggregation pipeline) no longer violate the `unique_scan_summary` constraint and no longer abort the downstream `DailySeveritySummary` / `FindingGroupDailySummary` recomputation for the affected scan [(#10827)](https://github.com/prowler-cloud/prowler/pull/10827)
|
||||
- Attack Paths: Findings on AWS were silently dropped during the Neo4j merge for resources whose Cartography node is keyed by a short identifier (e.g. EC2 instances) rather than the full ARN [(#10839)](https://github.com/prowler-cloud/prowler/pull/10839)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
ARG TRIVY_VERSION=0.69.2
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
@@ -22,6 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -57,6 +61,22 @@ RUN ARCH=$(uname -m) && \
|
||||
mkdir -p /tmp/.cache/trivy && \
|
||||
chmod 777 /tmp/.cache/trivy
|
||||
|
||||
# Install zizmor for GitHub Actions workflow scanning
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
ZIZMOR_ARCH="x86_64-unknown-linux-gnu" ; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then \
|
||||
ZIZMOR_ARCH="aarch64-unknown-linux-gnu" ; \
|
||||
else \
|
||||
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
|
||||
fi && \
|
||||
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
|
||||
mkdir -p /tmp/zizmor-extract && \
|
||||
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
|
||||
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
|
||||
chmod +x /usr/local/bin/zizmor && \
|
||||
rm -rf /tmp/zizmor.tar.gz /tmp/zizmor-extract
|
||||
|
||||
# Add prowler user
|
||||
RUN addgroup --gid 1000 prowler && \
|
||||
adduser --uid 1000 --gid 1000 --disabled-password --gecos "" prowler
|
||||
|
||||
Generated
+10
-10
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2974,7 +2974,7 @@ files = [
|
||||
[package.dependencies]
|
||||
autopep8 = "*"
|
||||
Django = ">=4.2"
|
||||
gprof2dot = ">=2017.09.19"
|
||||
gprof2dot = ">=2017.9.19"
|
||||
sqlparse = "*"
|
||||
|
||||
[[package]]
|
||||
@@ -4582,7 +4582,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
@@ -4790,7 +4790,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""]
|
||||
mongodb = ["pymongo (==4.15.3)"]
|
||||
msgpack = ["msgpack (==1.1.2)"]
|
||||
pyro = ["pyro4 (==4.82)"]
|
||||
qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"]
|
||||
qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"]
|
||||
redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"]
|
||||
slmq = ["softlayer_messaging (>=1.0.3)"]
|
||||
sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"]
|
||||
@@ -4811,7 +4811,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.05.14"
|
||||
certifi = ">=14.5.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -6964,11 +6964,11 @@ description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
|
||||
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
|
||||
]
|
||||
markers = {main = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
@@ -7194,7 +7194,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
astroid = ">=3.2.2,<=3.3.0-dev0"
|
||||
astroid = ">=3.2.2,<=3.3.0.dev0"
|
||||
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||
dill = [
|
||||
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
|
||||
@@ -7216,7 +7216,7 @@ description = "The MSALRuntime Python Interop Package"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
markers = "(platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\") and sys_platform == \"win32\""
|
||||
markers = "sys_platform == \"win32\" and (platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\")"
|
||||
files = [
|
||||
{file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0c22e2e83faa10de422bbfaacc1bb2887c9025ee8a53f0fc2e4f7db01c4a7b66"},
|
||||
{file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8ce2944a0f944833d047bb121396091e00287e2b6373716106da86ea99abf379"},
|
||||
@@ -8209,10 +8209,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "safety"
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.26.0"
|
||||
version = "1.27.0"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
|
||||
@@ -52,7 +52,7 @@ class ApiConfig(AppConfig):
|
||||
"check_and_fix_socialaccount_sites_migration",
|
||||
]
|
||||
|
||||
# Skip Neo4j initialization during tests, some Django commands, and Celery
|
||||
# Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook)
|
||||
if getattr(settings, "TESTING", False) or (
|
||||
len(sys.argv) > 1
|
||||
and (
|
||||
@@ -64,7 +64,7 @@ class ApiConfig(AppConfig):
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
"Skipping Neo4j initialization because tests, some Django commands or Celery"
|
||||
"Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)"
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -28,6 +28,7 @@ READ_QUERY_TIMEOUT_SECONDS = env.int(
|
||||
"ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
|
||||
)
|
||||
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
|
||||
CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
|
||||
READ_EXCEPTION_CODES = [
|
||||
"Neo.ClientError.Statement.AccessMode",
|
||||
"Neo.ClientError.Procedure.ProcedureNotFound",
|
||||
@@ -62,7 +63,7 @@ def init_driver() -> neo4j.Driver:
|
||||
auth=(config["USER"], config["PASSWORD"]),
|
||||
keep_alive=True,
|
||||
max_connection_lifetime=7200,
|
||||
connection_acquisition_timeout=120,
|
||||
connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
|
||||
max_connection_pool_size=50,
|
||||
)
|
||||
_driver.verify_connectivity()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from api.models import Provider
|
||||
from prowler.config.config import get_available_compliance_frameworks
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.models import CheckMetadata
|
||||
|
||||
@@ -95,12 +94,12 @@ PROWLER_CHECKS = LazyChecksMapping()
|
||||
|
||||
|
||||
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
|
||||
"""
|
||||
Retrieve and cache the list of available compliance frameworks for a specific cloud provider.
|
||||
"""List compliance frameworks the API can load for `provider_type`.
|
||||
|
||||
This function lazily loads and caches the available compliance frameworks (e.g., CIS, MITRE, ISO)
|
||||
for each provider type (AWS, Azure, GCP, etc.) on first access. Subsequent calls for the same
|
||||
provider will return the cached result.
|
||||
The list is sourced from `Compliance.get_bulk` so that the names
|
||||
returned here are guaranteed to be loadable by the bulk loader. This
|
||||
prevents downstream key mismatches (e.g. CSV report generation iterating
|
||||
framework names and looking them up in the bulk dict).
|
||||
|
||||
Args:
|
||||
provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve
|
||||
@@ -112,8 +111,8 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s
|
||||
"""
|
||||
global AVAILABLE_COMPLIANCE_FRAMEWORKS
|
||||
if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS:
|
||||
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = (
|
||||
get_available_compliance_frameworks(provider_type)
|
||||
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list(
|
||||
Compliance.get_bulk(provider_type).keys()
|
||||
)
|
||||
|
||||
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
|
||||
|
||||
@@ -595,10 +595,40 @@ class Scan(RowLevelSecurityProtectedModel):
|
||||
objects = ActiveProviderManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
_SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None
|
||||
|
||||
@classmethod
|
||||
def get_scoping_scanner_arg_keys(cls) -> tuple[str, ...]:
|
||||
"""Return the scanner_args keys that mark a scan as scoped.
|
||||
|
||||
Derived from ``prowler.lib.scan.scan.Scan.__init__`` so the API stays
|
||||
in sync with whatever the SDK actually accepts as filters. Cached at
|
||||
class level — the signature is stable for the process lifetime.
|
||||
"""
|
||||
if cls._SCOPING_SCANNER_ARG_KEYS_CACHE is None:
|
||||
import inspect
|
||||
|
||||
from prowler.lib.scan.scan import Scan as ProwlerScan
|
||||
|
||||
params = inspect.signature(ProwlerScan.__init__).parameters
|
||||
cls._SCOPING_SCANNER_ARG_KEYS_CACHE = tuple(
|
||||
name for name in params if name not in ("self", "provider")
|
||||
)
|
||||
return cls._SCOPING_SCANNER_ARG_KEYS_CACHE
|
||||
|
||||
class TriggerChoices(models.TextChoices):
|
||||
SCHEDULED = "scheduled", _("Scheduled")
|
||||
MANUAL = "manual", _("Manual")
|
||||
|
||||
# Trigger values for scans that ran the SDK end-to-end. Imported scans (or
|
||||
# any future trigger) are intentionally NOT in this set — they may carry
|
||||
# only a partial slice of resources, so post-scan logic that depends on a
|
||||
# full-scope sweep (e.g. resetting ephemeral resource findings) must skip
|
||||
# them by default.
|
||||
LIVE_SCAN_TRIGGERS = frozenset(
|
||||
(TriggerChoices.SCHEDULED.value, TriggerChoices.MANUAL.value)
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
|
||||
name = models.CharField(
|
||||
blank=True, null=True, max_length=100, validators=[MinLengthValidator(3)]
|
||||
@@ -681,6 +711,24 @@ class Scan(RowLevelSecurityProtectedModel):
|
||||
class JSONAPIMeta:
|
||||
resource_name = "scans"
|
||||
|
||||
def is_full_scope(self) -> bool:
|
||||
"""Return True if this scan ran with no scoping filters at all.
|
||||
|
||||
Used to gate post-scan operations (such as resetting the
|
||||
failed_findings_count of resources missing from the scan) that are only
|
||||
safe when the scan covered every check, service, and category. Imported
|
||||
scans are NOT full-scope by definition — they may carry only a partial
|
||||
slice of resources, so they're rejected via ``trigger`` even before the
|
||||
scanner_args check.
|
||||
"""
|
||||
if self.trigger not in self.LIVE_SCAN_TRIGGERS:
|
||||
return False
|
||||
scanner_args = self.scanner_args or {}
|
||||
for key in self.get_scoping_scanner_arg_keys():
|
||||
if scanner_args.get(key):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class AttackPathsScan(RowLevelSecurityProtectedModel):
|
||||
objects = ActiveProviderManager()
|
||||
|
||||
+1620
-58
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ from unittest.mock import MagicMock, patch
|
||||
import neo4j
|
||||
import pytest
|
||||
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
|
||||
class TestLazyInitialization:
|
||||
"""Test that Neo4j driver is initialized lazily on first use."""
|
||||
@@ -19,8 +21,6 @@ class TestLazyInitialization:
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_module_state(self):
|
||||
"""Reset module-level singleton state before each test."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
original_driver = db_module._driver
|
||||
|
||||
db_module._driver = None
|
||||
@@ -31,8 +31,6 @@ class TestLazyInitialization:
|
||||
|
||||
def test_driver_not_initialized_at_import(self):
|
||||
"""Driver should be None after module import (no eager connection)."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
assert db_module._driver is None
|
||||
|
||||
@patch("api.attack_paths.database.settings")
|
||||
@@ -41,8 +39,6 @@ class TestLazyInitialization:
|
||||
self, mock_driver_factory, mock_settings
|
||||
):
|
||||
"""init_driver() should create connection only when called."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver = MagicMock()
|
||||
mock_driver_factory.return_value = mock_driver
|
||||
mock_settings.DATABASES = {
|
||||
@@ -69,8 +65,6 @@ class TestLazyInitialization:
|
||||
self, mock_driver_factory, mock_settings
|
||||
):
|
||||
"""Subsequent calls should return cached driver without reconnecting."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver = MagicMock()
|
||||
mock_driver_factory.return_value = mock_driver
|
||||
mock_settings.DATABASES = {
|
||||
@@ -99,8 +93,6 @@ class TestLazyInitialization:
|
||||
self, mock_driver_factory, mock_settings
|
||||
):
|
||||
"""get_driver() should use init_driver() for lazy initialization."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver = MagicMock()
|
||||
mock_driver_factory.return_value = mock_driver
|
||||
mock_settings.DATABASES = {
|
||||
@@ -118,14 +110,50 @@ class TestLazyInitialization:
|
||||
mock_driver_factory.assert_called_once()
|
||||
|
||||
|
||||
class TestConnectionAcquisitionTimeout:
|
||||
"""Test that the connection acquisition timeout is configurable."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_module_state(self):
|
||||
original_driver = db_module._driver
|
||||
original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
|
||||
|
||||
db_module._driver = None
|
||||
|
||||
yield
|
||||
|
||||
db_module._driver = original_driver
|
||||
db_module.CONN_ACQUISITION_TIMEOUT = original_timeout
|
||||
|
||||
@patch("api.attack_paths.database.settings")
|
||||
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
|
||||
def test_driver_receives_configured_timeout(
|
||||
self, mock_driver_factory, mock_settings
|
||||
):
|
||||
"""init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
|
||||
mock_driver_factory.return_value = MagicMock()
|
||||
mock_settings.DATABASES = {
|
||||
"neo4j": {
|
||||
"HOST": "localhost",
|
||||
"PORT": 7687,
|
||||
"USER": "neo4j",
|
||||
"PASSWORD": "password",
|
||||
}
|
||||
}
|
||||
db_module.CONN_ACQUISITION_TIMEOUT = 42
|
||||
|
||||
db_module.init_driver()
|
||||
|
||||
_, kwargs = mock_driver_factory.call_args
|
||||
assert kwargs["connection_acquisition_timeout"] == 42
|
||||
|
||||
|
||||
class TestAtexitRegistration:
|
||||
"""Test that atexit cleanup handler is registered correctly."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_module_state(self):
|
||||
"""Reset module-level singleton state before each test."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
original_driver = db_module._driver
|
||||
|
||||
db_module._driver = None
|
||||
@@ -141,8 +169,6 @@ class TestAtexitRegistration:
|
||||
self, mock_driver_factory, mock_atexit_register, mock_settings
|
||||
):
|
||||
"""atexit.register should be called on first initialization."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver_factory.return_value = MagicMock()
|
||||
mock_settings.DATABASES = {
|
||||
"neo4j": {
|
||||
@@ -168,8 +194,6 @@ class TestAtexitRegistration:
|
||||
The double-checked locking on _driver ensures the atexit registration
|
||||
block only executes once (when _driver is first created).
|
||||
"""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver_factory.return_value = MagicMock()
|
||||
mock_settings.DATABASES = {
|
||||
"neo4j": {
|
||||
@@ -194,8 +218,6 @@ class TestCloseDriver:
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_module_state(self):
|
||||
"""Reset module-level singleton state before each test."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
original_driver = db_module._driver
|
||||
|
||||
db_module._driver = None
|
||||
@@ -206,8 +228,6 @@ class TestCloseDriver:
|
||||
|
||||
def test_close_driver_closes_and_clears_driver(self):
|
||||
"""close_driver() should close the driver and set it to None."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver = MagicMock()
|
||||
db_module._driver = mock_driver
|
||||
|
||||
@@ -218,8 +238,6 @@ class TestCloseDriver:
|
||||
|
||||
def test_close_driver_handles_none_driver(self):
|
||||
"""close_driver() should handle case where driver is None."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
db_module._driver = None
|
||||
|
||||
# Should not raise
|
||||
@@ -229,8 +247,6 @@ class TestCloseDriver:
|
||||
|
||||
def test_close_driver_clears_driver_even_on_close_error(self):
|
||||
"""Driver should be cleared even if close() raises an exception."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver = MagicMock()
|
||||
mock_driver.close.side_effect = Exception("Connection error")
|
||||
db_module._driver = mock_driver
|
||||
@@ -246,8 +262,6 @@ class TestExecuteReadQuery:
|
||||
"""Test read query execution helper."""
|
||||
|
||||
def test_execute_read_query_calls_read_session_and_returns_result(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
tx = MagicMock()
|
||||
expected_graph = MagicMock()
|
||||
run_result = MagicMock()
|
||||
@@ -289,8 +303,6 @@ class TestExecuteReadQuery:
|
||||
assert result is expected_graph
|
||||
|
||||
def test_execute_read_query_defaults_parameters_to_empty_dict(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
tx = MagicMock()
|
||||
run_result = MagicMock()
|
||||
run_result.graph.return_value = MagicMock()
|
||||
@@ -325,8 +337,6 @@ class TestGetSessionReadOnly:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_module_state(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
original_driver = db_module._driver
|
||||
db_module._driver = None
|
||||
yield
|
||||
@@ -341,8 +351,6 @@ class TestGetSessionReadOnly:
|
||||
)
|
||||
def test_get_session_raises_write_query_not_allowed(self, neo4j_code):
|
||||
"""Read-mode Neo4j errors should raise `WriteQueryNotAllowedException`."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_session = MagicMock()
|
||||
neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code=neo4j_code,
|
||||
@@ -362,8 +370,6 @@ class TestGetSessionReadOnly:
|
||||
|
||||
def test_get_session_raises_generic_exception_for_other_errors(self):
|
||||
"""Non-read-mode Neo4j errors should raise GraphDatabaseQueryException."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_session = MagicMock()
|
||||
neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code="Neo.ClientError.Statement.SyntaxError",
|
||||
@@ -388,8 +394,6 @@ class TestThreadSafety:
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_module_state(self):
|
||||
"""Reset module-level singleton state before each test."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
original_driver = db_module._driver
|
||||
|
||||
db_module._driver = None
|
||||
@@ -404,8 +408,6 @@ class TestThreadSafety:
|
||||
self, mock_driver_factory, mock_settings
|
||||
):
|
||||
"""Multiple threads calling init_driver() should create only one driver."""
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_driver = MagicMock()
|
||||
mock_driver_factory.return_value = mock_driver
|
||||
mock_settings.DATABASES = {
|
||||
@@ -448,8 +450,6 @@ class TestHasProviderData:
|
||||
"""Test has_provider_data helper for checking provider nodes in Neo4j."""
|
||||
|
||||
def test_returns_true_when_nodes_exist(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.single.return_value = MagicMock() # non-None record
|
||||
@@ -468,8 +468,6 @@ class TestHasProviderData:
|
||||
mock_session.run.assert_called_once()
|
||||
|
||||
def test_returns_false_when_no_nodes(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.single.return_value = None
|
||||
@@ -486,8 +484,6 @@ class TestHasProviderData:
|
||||
assert db_module.has_provider_data("db-tenant-abc", "provider-123") is False
|
||||
|
||||
def test_returns_false_when_database_not_found(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
|
||||
message="Database does not exist",
|
||||
@@ -503,8 +499,6 @@ class TestHasProviderData:
|
||||
)
|
||||
|
||||
def test_raises_on_other_errors(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
|
||||
message="Connection refused",
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api import compliance as compliance_module
|
||||
from api.compliance import (
|
||||
generate_compliance_overview_template,
|
||||
generate_scan_compliance,
|
||||
get_compliance_frameworks,
|
||||
get_prowler_provider_checks,
|
||||
get_prowler_provider_compliance,
|
||||
load_prowler_checks,
|
||||
)
|
||||
from api.models import Provider
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
|
||||
|
||||
class TestCompliance:
|
||||
@@ -250,3 +255,58 @@ class TestCompliance:
|
||||
}
|
||||
|
||||
assert template == expected_template
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_compliance_cache():
|
||||
"""Reset the module-level cache so each test starts cold."""
|
||||
previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS)
|
||||
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
|
||||
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous)
|
||||
|
||||
|
||||
class TestGetComplianceFrameworks:
|
||||
def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache):
|
||||
with patch("api.compliance.Compliance") as mock_compliance:
|
||||
mock_compliance.get_bulk.return_value = {
|
||||
"cis_1.4_aws": MagicMock(),
|
||||
"mitre_attack_aws": MagicMock(),
|
||||
}
|
||||
result = get_compliance_frameworks(Provider.ProviderChoices.AWS)
|
||||
|
||||
assert sorted(result) == ["cis_1.4_aws", "mitre_attack_aws"]
|
||||
mock_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
|
||||
|
||||
def test_caches_result_per_provider(self, reset_compliance_cache):
|
||||
with patch("api.compliance.Compliance") as mock_compliance:
|
||||
mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
|
||||
get_compliance_frameworks(Provider.ProviderChoices.AWS)
|
||||
get_compliance_frameworks(Provider.ProviderChoices.AWS)
|
||||
|
||||
# Cached after first call.
|
||||
assert mock_compliance.get_bulk.call_count == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_type",
|
||||
[choice.value for choice in Provider.ProviderChoices],
|
||||
)
|
||||
def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type):
|
||||
"""Regression for CLOUD-API-40S: every name returned by
|
||||
``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``.
|
||||
|
||||
A divergence here is what produced ``KeyError: 'csa_ccm_4.0'`` in
|
||||
``generate_outputs_task`` after universal/multi-provider compliance
|
||||
JSONs were introduced at the top-level ``prowler/compliance/`` path.
|
||||
"""
|
||||
bulk_keys = set(Compliance.get_bulk(provider_type).keys())
|
||||
listed = set(get_compliance_frameworks(provider_type))
|
||||
|
||||
missing = listed - bulk_keys
|
||||
assert not missing, (
|
||||
f"get_compliance_frameworks({provider_type!r}) returned names not "
|
||||
f"loadable by Compliance.get_bulk: {sorted(missing)}"
|
||||
)
|
||||
|
||||
@@ -3841,9 +3841,14 @@ class TestScanViewSet:
|
||||
"prowler-output-123_threatscore_report.pdf",
|
||||
)
|
||||
|
||||
presigned_url = (
|
||||
"https://test-bucket.s3.amazonaws.com/"
|
||||
"tenant-id/scan-id/threatscore/prowler-output-123_threatscore_report.pdf"
|
||||
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300"
|
||||
)
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.list_objects_v2.return_value = {"Contents": [{"Key": pdf_key}]}
|
||||
mock_s3_client.get_object.return_value = {"Body": io.BytesIO(b"pdf-bytes")}
|
||||
mock_s3_client.generate_presigned_url.return_value = presigned_url
|
||||
|
||||
mock_env_str.return_value = bucket
|
||||
mock_get_s3_client.return_value = mock_s3_client
|
||||
@@ -3852,19 +3857,26 @@ class TestScanViewSet:
|
||||
url = reverse("scan-threatscore", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response["Content-Type"] == "application/pdf"
|
||||
assert response["Content-Disposition"].endswith(
|
||||
'"prowler-output-123_threatscore_report.pdf"'
|
||||
)
|
||||
assert response.content == b"pdf-bytes"
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert response["Location"] == presigned_url
|
||||
mock_s3_client.list_objects_v2.assert_called_once()
|
||||
mock_s3_client.get_object.assert_called_once_with(Bucket=bucket, Key=pdf_key)
|
||||
mock_s3_client.generate_presigned_url.assert_called_once_with(
|
||||
"get_object",
|
||||
Params={
|
||||
"Bucket": bucket,
|
||||
"Key": pdf_key,
|
||||
"ResponseContentDisposition": (
|
||||
'attachment; filename="prowler-output-123_threatscore_report.pdf"'
|
||||
),
|
||||
"ResponseContentType": "application/pdf",
|
||||
},
|
||||
ExpiresIn=300,
|
||||
)
|
||||
|
||||
def test_report_s3_success(self, authenticated_client, scans_fixture, monkeypatch):
|
||||
"""
|
||||
When output_location is an S3 URL and the S3 client returns the file successfully,
|
||||
the view should return the ZIP file with HTTP 200 and proper headers.
|
||||
When output_location is an S3 URL and the object exists,
|
||||
the view should return a 302 redirect to a presigned S3 URL.
|
||||
"""
|
||||
scan = scans_fixture[0]
|
||||
bucket = "test-bucket"
|
||||
@@ -3878,22 +3890,33 @@ class TestScanViewSet:
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
presigned_url = (
|
||||
"https://test-bucket.s3.amazonaws.com/report.zip"
|
||||
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300"
|
||||
)
|
||||
|
||||
class FakeS3Client:
|
||||
def get_object(self, Bucket, Key):
|
||||
def head_object(self, Bucket, Key):
|
||||
assert Bucket == bucket
|
||||
assert Key == key
|
||||
return {"Body": io.BytesIO(b"s3 zip content")}
|
||||
return {}
|
||||
|
||||
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
|
||||
assert ClientMethod == "get_object"
|
||||
assert Params["Bucket"] == bucket
|
||||
assert Params["Key"] == key
|
||||
assert Params["ResponseContentDisposition"] == (
|
||||
'attachment; filename="report.zip"'
|
||||
)
|
||||
assert ExpiresIn == 300
|
||||
return presigned_url
|
||||
|
||||
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
|
||||
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
assert response.status_code == 200
|
||||
expected_filename = os.path.basename("report.zip")
|
||||
content_disposition = response.get("Content-Disposition")
|
||||
assert content_disposition.startswith('attachment; filename="')
|
||||
assert f'filename="{expected_filename}"' in content_disposition
|
||||
assert response.content == b"s3 zip content"
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert response["Location"] == presigned_url
|
||||
|
||||
def test_report_s3_success_no_local_files(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
@@ -4032,23 +4055,31 @@ class TestScanViewSet:
|
||||
)
|
||||
|
||||
match_key = "path/compliance/mitre_attack_aws.csv"
|
||||
presigned_url = (
|
||||
"https://test-bucket.s3.amazonaws.com/path/compliance/mitre_attack_aws.csv"
|
||||
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300"
|
||||
)
|
||||
|
||||
class FakeS3Client:
|
||||
def list_objects_v2(self, Bucket, Prefix):
|
||||
return {"Contents": [{"Key": match_key}]}
|
||||
|
||||
def get_object(self, Bucket, Key):
|
||||
return {"Body": io.BytesIO(b"ignored")}
|
||||
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
|
||||
assert ClientMethod == "get_object"
|
||||
assert Params["Key"] == match_key
|
||||
assert Params["ResponseContentDisposition"] == (
|
||||
'attachment; filename="mitre_attack_aws.csv"'
|
||||
)
|
||||
assert ExpiresIn == 300
|
||||
return presigned_url
|
||||
|
||||
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
|
||||
|
||||
framework = match_key.split("/")[-1].split(".")[0]
|
||||
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework})
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
cd = resp["Content-Disposition"]
|
||||
assert cd.startswith('attachment; filename="')
|
||||
assert cd.endswith('filename="mitre_attack_aws.csv"')
|
||||
assert resp.status_code == status.HTTP_302_FOUND
|
||||
assert resp["Location"] == presigned_url
|
||||
|
||||
def test_compliance_s3_not_found(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
@@ -4113,6 +4144,51 @@ class TestScanViewSet:
|
||||
assert cd.startswith('attachment; filename="')
|
||||
assert cd.endswith(f'filename="{fname.name}"')
|
||||
|
||||
def test_cis_no_output(self, authenticated_client, scans_fixture):
|
||||
"""CIS PDF endpoint must 404 when the scan has no output_location."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = ""
|
||||
scan.save()
|
||||
|
||||
url = reverse("scan-cis", kwargs={"pk": scan.id})
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert (
|
||||
resp.json()["errors"]["detail"]
|
||||
== "The scan has no reports, or the CIS report generation task has not started yet."
|
||||
)
|
||||
|
||||
def test_cis_local_file(self, authenticated_client, scans_fixture, monkeypatch):
|
||||
"""CIS PDF endpoint must serve the latest generated PDF."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
base = tmp_path / "reports"
|
||||
cis_dir = base / "cis"
|
||||
cis_dir.mkdir(parents=True, exist_ok=True)
|
||||
fname = cis_dir / "prowler-output-aws-20260101000000_cis_report.pdf"
|
||||
fname.write_bytes(b"%PDF-1.4 fake pdf")
|
||||
|
||||
scan.output_location = str(base / "scan.zip")
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda p: [str(fname)] if p.endswith("*_cis_report.pdf") else [],
|
||||
)
|
||||
|
||||
url = reverse("scan-cis", kwargs={"pk": scan.id})
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp["Content-Type"] == "application/pdf"
|
||||
cd = resp["Content-Disposition"]
|
||||
assert cd.startswith('attachment; filename="')
|
||||
assert cd.endswith(f'filename="{fname.name}"')
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.TaskSerializer")
|
||||
def test__get_task_status_returns_none_if_task_not_executing(
|
||||
@@ -4206,8 +4282,8 @@ class TestScanViewSet:
|
||||
scan.save()
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.get_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "NoSuchKey"}}, "GetObject"
|
||||
fake_client.head_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "NoSuchKey"}}, "HeadObject"
|
||||
)
|
||||
mock_get_s3_client.return_value = fake_client
|
||||
|
||||
@@ -4230,8 +4306,8 @@ class TestScanViewSet:
|
||||
scan.save()
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.get_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "AccessDenied"}}, "GetObject"
|
||||
fake_client.head_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "AccessDenied"}}, "HeadObject"
|
||||
)
|
||||
mock_get_s3_client.return_value = fake_client
|
||||
|
||||
|
||||
+173
-36
@@ -53,7 +53,7 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
from django.db.models.functions import Cast, Coalesce, RowNumber
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.http import HttpResponse, HttpResponseBase, HttpResponseRedirect, QueryDict
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
@@ -422,7 +422,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.26.0"
|
||||
spectacular_settings.VERSION = "1.27.0"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -1926,6 +1926,27 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
),
|
||||
},
|
||||
),
|
||||
cis=extend_schema(
|
||||
tags=["Scan"],
|
||||
summary="Retrieve CIS Benchmark compliance report",
|
||||
description="Download the CIS Benchmark compliance report as a PDF file. "
|
||||
"When a provider ships multiple CIS versions, the report is generated "
|
||||
"for the highest available version.",
|
||||
request=None,
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="PDF file containing the CIS compliance report"
|
||||
),
|
||||
202: OpenApiResponse(description="The task is in progress"),
|
||||
401: OpenApiResponse(
|
||||
description="API key missing or user not Authenticated"
|
||||
),
|
||||
403: OpenApiResponse(description="There is a problem with credentials"),
|
||||
404: OpenApiResponse(
|
||||
description="The scan has no CIS reports, or the CIS report generation task has not started yet"
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
@@ -1994,6 +2015,9 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
elif self.action == "csa":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
elif self.action == "cis":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
return super().get_serializer_class()
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
@@ -2056,24 +2080,38 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
},
|
||||
)
|
||||
|
||||
def _load_file(self, path_pattern, s3=False, bucket=None, list_objects=False):
|
||||
def _load_file(
|
||||
self,
|
||||
path_pattern,
|
||||
s3=False,
|
||||
bucket=None,
|
||||
list_objects=False,
|
||||
content_type=None,
|
||||
):
|
||||
"""
|
||||
Loads a binary file (e.g., ZIP or CSV) and returns its content and filename.
|
||||
Resolve a report file location and return the bytes (filesystem) or a redirect (S3).
|
||||
|
||||
Depending on the input parameters, this method supports loading:
|
||||
- From S3 using a direct key.
|
||||
- From S3 by listing objects under a prefix and matching suffix.
|
||||
- From the local filesystem using glob pattern matching.
|
||||
- From S3 using a direct key, returns a 302 to a short-lived presigned URL.
|
||||
- From S3 by listing objects under a prefix and matching suffix, returns a 302 to a short-lived presigned URL.
|
||||
- From the local filesystem using glob pattern matching, returns the file bytes.
|
||||
|
||||
The S3 branch never streams bytes through the worker; this prevents gunicorn
|
||||
worker timeouts on large reports.
|
||||
|
||||
Args:
|
||||
path_pattern (str): The key or glob pattern representing the file location.
|
||||
s3 (bool, optional): Whether the file is stored in S3. Defaults to False.
|
||||
bucket (str, optional): The name of the S3 bucket, required if `s3=True`. Defaults to None.
|
||||
list_objects (bool, optional): If True and `s3=True`, list objects by prefix to find the file. Defaults to False.
|
||||
content_type (str, optional): On the S3 branch, forwarded as `ResponseContentType`
|
||||
so the presigned download advertises the same Content-Type the API used to send.
|
||||
Ignored on the filesystem branch.
|
||||
|
||||
Returns:
|
||||
tuple[bytes, str]: A tuple containing the file content as bytes and the filename if successful.
|
||||
Response: A DRF `Response` object with an appropriate status and error detail if an error occurs.
|
||||
tuple[bytes, str]: For the filesystem branch, the file content and filename.
|
||||
HttpResponseRedirect: For the S3 branch on success, a 302 redirect to a presigned `GetObject` URL.
|
||||
Response: For any error path, a DRF `Response` with an appropriate status and detail.
|
||||
"""
|
||||
if s3:
|
||||
try:
|
||||
@@ -2120,25 +2158,45 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
# path_pattern here is prefix, but in compliance we build correct suffix check before
|
||||
key = keys[0]
|
||||
else:
|
||||
# path_pattern is exact key
|
||||
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
|
||||
key = path_pattern
|
||||
try:
|
||||
s3_obj = client.get_object(Bucket=bucket, Key=key)
|
||||
except ClientError as e:
|
||||
code = e.response.get("Error", {}).get("Code")
|
||||
if code == "NoSuchKey":
|
||||
try:
|
||||
client.head_object(Bucket=bucket, Key=key)
|
||||
except ClientError as e:
|
||||
code = e.response.get("Error", {}).get("Code")
|
||||
if code in ("NoSuchKey", "404"):
|
||||
return Response(
|
||||
{
|
||||
"detail": "The scan has no reports, or the report generation task has not started yet."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"detail": "The scan has no reports, or the report generation task has not started yet."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
{"detail": "There is a problem with credentials."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return Response(
|
||||
{"detail": "There is a problem with credentials."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
content = s3_obj["Body"].read()
|
||||
|
||||
filename = os.path.basename(key)
|
||||
# escape quotes and strip CR/LF so a malformed key cannot break out of the header
|
||||
safe_filename = (
|
||||
filename.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\r", "")
|
||||
.replace("\n", "")
|
||||
)
|
||||
params = {
|
||||
"Bucket": bucket,
|
||||
"Key": key,
|
||||
"ResponseContentDisposition": f'attachment; filename="{safe_filename}"',
|
||||
}
|
||||
if content_type:
|
||||
params["ResponseContentType"] = content_type
|
||||
url = client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=300,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
files = glob.glob(path_pattern)
|
||||
if not files:
|
||||
@@ -2181,12 +2239,16 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
|
||||
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
|
||||
loader = self._load_file(
|
||||
key_prefix, s3=True, bucket=bucket, list_objects=False
|
||||
key_prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=False,
|
||||
content_type="application/x-zip-compressed",
|
||||
)
|
||||
else:
|
||||
loader = self._load_file(scan.output_location, s3=False)
|
||||
|
||||
if isinstance(loader, Response):
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
@@ -2224,18 +2286,69 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
prefix = os.path.join(
|
||||
os.path.dirname(key_prefix), "compliance", f"{name}.csv"
|
||||
)
|
||||
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
|
||||
loader = self._load_file(
|
||||
prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=True,
|
||||
content_type="text/csv",
|
||||
)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "compliance", f"*_{name}.csv")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, Response):
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
return self._serve_file(content, filename, "text/csv")
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_name="cis",
|
||||
)
|
||||
def cis(self, request, pk=None):
|
||||
scan = self.get_object()
|
||||
running_resp = self._get_task_status(scan)
|
||||
if running_resp:
|
||||
return running_resp
|
||||
|
||||
if not scan.output_location:
|
||||
return Response(
|
||||
{
|
||||
"detail": "The scan has no reports, or the CIS report generation task has not started yet."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if scan.output_location.startswith("s3://"):
|
||||
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
|
||||
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
|
||||
prefix = os.path.join(
|
||||
os.path.dirname(key_prefix),
|
||||
"cis",
|
||||
"*_cis_report.pdf",
|
||||
)
|
||||
loader = self._load_file(
|
||||
prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=True,
|
||||
content_type="application/pdf",
|
||||
)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "cis", "*_cis_report.pdf")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
return self._serve_file(content, filename, "application/pdf")
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -2264,13 +2377,19 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
"threatscore",
|
||||
"*_threatscore_report.pdf",
|
||||
)
|
||||
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
|
||||
loader = self._load_file(
|
||||
prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=True,
|
||||
content_type="application/pdf",
|
||||
)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "threatscore", "*_threatscore_report.pdf")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, Response):
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
@@ -2304,13 +2423,19 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
"ens",
|
||||
"*_ens_report.pdf",
|
||||
)
|
||||
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
|
||||
loader = self._load_file(
|
||||
prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=True,
|
||||
content_type="application/pdf",
|
||||
)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "ens", "*_ens_report.pdf")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, Response):
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
@@ -2343,13 +2468,19 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
"nis2",
|
||||
"*_nis2_report.pdf",
|
||||
)
|
||||
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
|
||||
loader = self._load_file(
|
||||
prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=True,
|
||||
content_type="application/pdf",
|
||||
)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "nis2", "*_nis2_report.pdf")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, Response):
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
@@ -2382,13 +2513,19 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
"csa",
|
||||
"*_csa_report.pdf",
|
||||
)
|
||||
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
|
||||
loader = self._load_file(
|
||||
prefix,
|
||||
s3=True,
|
||||
bucket=bucket,
|
||||
list_objects=True,
|
||||
content_type="application/pdf",
|
||||
)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "csa", "*_csa_report.pdf")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, Response):
|
||||
if isinstance(loader, HttpResponseBase):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
|
||||
@@ -120,6 +120,7 @@ sentry_sdk.init(
|
||||
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
|
||||
before_send=before_send,
|
||||
send_default_pii=True,
|
||||
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
|
||||
_experiments={
|
||||
# Set continuous_profiling_auto_start to True
|
||||
# to automatically start the profiler on when
|
||||
|
||||
@@ -14,8 +14,8 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from tasks.jobs.backfill import (
|
||||
backfill_resource_scan_summaries,
|
||||
backfill_scan_category_summaries,
|
||||
backfill_scan_resource_group_summaries,
|
||||
aggregate_scan_category_summaries,
|
||||
aggregate_scan_resource_group_summaries,
|
||||
)
|
||||
|
||||
from api.attack_paths import (
|
||||
@@ -1445,8 +1445,8 @@ def latest_scan_finding_with_categories(
|
||||
)
|
||||
finding.add_resources([resource])
|
||||
backfill_resource_scan_summaries(tenant_id, str(scan.id))
|
||||
backfill_scan_category_summaries(tenant_id, str(scan.id))
|
||||
backfill_scan_resource_group_summaries(tenant_id, str(scan.id))
|
||||
aggregate_scan_category_summaries(tenant_id, str(scan.id))
|
||||
aggregate_scan_resource_group_summaries(tenant_id, str(scan.id))
|
||||
return finding
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -49,7 +49,7 @@ def start_aws_ingestion(
|
||||
}
|
||||
|
||||
boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider)
|
||||
regions: list[str] = list(prowler_sdk_provider._enabled_regions)
|
||||
regions: list[str] = resolve_aws_regions(prowler_api_provider, prowler_sdk_provider)
|
||||
requested_syncs = list(cartography_aws.RESOURCE_FUNCTIONS.keys())
|
||||
|
||||
sync_args = cartography_aws._build_aws_sync_kwargs(
|
||||
@@ -226,6 +226,48 @@ def get_boto3_session(
|
||||
return boto3_session
|
||||
|
||||
|
||||
def resolve_aws_regions(
|
||||
prowler_api_provider: ProwlerAPIProvider,
|
||||
prowler_sdk_provider: ProwlerSDKProvider,
|
||||
) -> list[str]:
|
||||
"""Resolve the regions to scan, falling back when `_enabled_regions` is `None`.
|
||||
|
||||
The SDK silently sets `_enabled_regions` to `None` when `ec2:DescribeRegions`
|
||||
fails (missing IAM permission, transient error). Without a fallback the
|
||||
Cartography ingestion crashes with a non-actionable `TypeError`. Try the
|
||||
user's `audited_regions` next, then the partition's static region list.
|
||||
Excluded regions are honored on every branch.
|
||||
"""
|
||||
if prowler_sdk_provider._enabled_regions is not None:
|
||||
regions = set(prowler_sdk_provider._enabled_regions)
|
||||
|
||||
elif prowler_sdk_provider.identity.audited_regions:
|
||||
regions = set(prowler_sdk_provider.identity.audited_regions)
|
||||
|
||||
else:
|
||||
partition = prowler_sdk_provider.identity.partition
|
||||
try:
|
||||
regions = prowler_sdk_provider.get_available_aws_service_regions(
|
||||
"ec2", partition
|
||||
)
|
||||
|
||||
except KeyError:
|
||||
raise RuntimeError(
|
||||
f"No region data available for partition {partition!r}; "
|
||||
f"cannot determine regions to scan for "
|
||||
f"{prowler_api_provider.uid}"
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Could not enumerate enabled regions for AWS account "
|
||||
f"{prowler_api_provider.uid}; falling back to all regions in "
|
||||
f"partition {partition!r}"
|
||||
)
|
||||
|
||||
excluded = set(getattr(prowler_sdk_provider, "_excluded_regions", None) or ())
|
||||
return sorted(regions - excluded)
|
||||
|
||||
|
||||
def get_aioboto3_session(boto3_session: boto3.Session) -> aioboto3.Session:
|
||||
return aioboto3.Session(botocore_session=boto3_session._session)
|
||||
|
||||
@@ -313,3 +355,16 @@ def sync_aws_account(
|
||||
)
|
||||
|
||||
return failed_syncs
|
||||
|
||||
|
||||
def extract_short_uid(uid: str) -> str:
|
||||
"""Return the short identifier from an AWS ARN or resource ID.
|
||||
|
||||
Supported inputs end in one of:
|
||||
- `<type>/<id>` (e.g. `instance/i-xxx`)
|
||||
- `<type>:<id>` (e.g. `function:name`)
|
||||
- `<id>` (e.g. `bucket-name` or `i-xxx`)
|
||||
|
||||
If `uid` is already a short resource ID, it is returned unchanged.
|
||||
"""
|
||||
return uid.rsplit("/", 1)[-1].rsplit(":", 1)[-1]
|
||||
|
||||
@@ -18,28 +18,45 @@ logger = get_task_logger(__name__)
|
||||
|
||||
def cleanup_stale_attack_paths_scans() -> dict:
|
||||
"""
|
||||
Find `EXECUTING` `AttackPathsScan` scans whose workers are dead or that have
|
||||
exceeded the stale threshold, and mark them as `FAILED`.
|
||||
Mark stale `AttackPathsScan` rows as `FAILED`.
|
||||
|
||||
Two-pass detection:
|
||||
Covers two stuck-state scenarios:
|
||||
1. `EXECUTING` scans whose workers are dead, or that have exceeded the
|
||||
stale threshold while alive.
|
||||
2. `SCHEDULED` scans that never made it to a worker — parent scan
|
||||
crashed before dispatch, broker lost the message, etc. Detected by
|
||||
age plus the parent `Scan` no longer being in flight.
|
||||
"""
|
||||
threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
cutoff = now - threshold
|
||||
|
||||
cleaned_up: list[str] = []
|
||||
cleaned_up.extend(_cleanup_stale_executing_scans(cutoff))
|
||||
cleaned_up.extend(_cleanup_stale_scheduled_scans(cutoff))
|
||||
|
||||
logger.info(
|
||||
f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up"
|
||||
)
|
||||
return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up}
|
||||
|
||||
|
||||
def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]:
|
||||
"""
|
||||
Two-pass detection for `EXECUTING` scans:
|
||||
1. If `TaskResult.worker` exists, ping the worker.
|
||||
- Dead worker: cleanup immediately (any age).
|
||||
- Alive + past threshold: revoke the task, then cleanup.
|
||||
- Alive + within threshold: skip.
|
||||
2. If no worker field: fall back to time-based heuristic only.
|
||||
"""
|
||||
threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
cutoff = now - threshold
|
||||
|
||||
executing_scans = (
|
||||
executing_scans = list(
|
||||
AttackPathsScan.all_objects.using(MainRouter.admin_db)
|
||||
.filter(state=StateChoices.EXECUTING)
|
||||
.select_related("task__task_runner_task")
|
||||
)
|
||||
|
||||
# Cache worker liveness so each worker is pinged at most once
|
||||
executing_scans = list(executing_scans)
|
||||
workers = {
|
||||
tr.worker
|
||||
for scan in executing_scans
|
||||
@@ -48,7 +65,7 @@ def cleanup_stale_attack_paths_scans() -> dict:
|
||||
}
|
||||
worker_alive = {w: _is_worker_alive(w) for w in workers}
|
||||
|
||||
cleaned_up = []
|
||||
cleaned_up: list[str] = []
|
||||
|
||||
for scan in executing_scans:
|
||||
task_result = (
|
||||
@@ -65,9 +82,7 @@ def cleanup_stale_attack_paths_scans() -> dict:
|
||||
|
||||
# Alive but stale — revoke before cleanup
|
||||
_revoke_task(task_result)
|
||||
reason = (
|
||||
"Scan exceeded stale threshold — " "cleaned up by periodic task"
|
||||
)
|
||||
reason = "Scan exceeded stale threshold — cleaned up by periodic task"
|
||||
else:
|
||||
reason = "Worker dead — cleaned up by periodic task"
|
||||
else:
|
||||
@@ -82,10 +97,57 @@ def cleanup_stale_attack_paths_scans() -> dict:
|
||||
if _cleanup_scan(scan, task_result, reason):
|
||||
cleaned_up.append(str(scan.id))
|
||||
|
||||
logger.info(
|
||||
f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up"
|
||||
return cleaned_up
|
||||
|
||||
|
||||
def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]:
|
||||
"""
|
||||
Cleanup `SCHEDULED` scans that never reached a worker.
|
||||
|
||||
Detection:
|
||||
- `state == SCHEDULED`
|
||||
- `started_at < cutoff`
|
||||
- parent `Scan` is no longer in flight (terminal state or missing). This
|
||||
avoids cleaning up rows whose parent Prowler scan is legitimately still
|
||||
running.
|
||||
|
||||
For each match: revoke the queued task (best-effort; harmless if already
|
||||
consumed), atomically flip to `FAILED`, and mark the `TaskResult`. The
|
||||
temp Neo4j database is never created while `SCHEDULED`, so no drop is
|
||||
needed.
|
||||
"""
|
||||
scheduled_scans = list(
|
||||
AttackPathsScan.all_objects.using(MainRouter.admin_db)
|
||||
.filter(
|
||||
state=StateChoices.SCHEDULED,
|
||||
started_at__lt=cutoff,
|
||||
)
|
||||
.select_related("task__task_runner_task", "scan")
|
||||
)
|
||||
return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up}
|
||||
|
||||
cleaned_up: list[str] = []
|
||||
parent_terminal = (
|
||||
StateChoices.COMPLETED,
|
||||
StateChoices.FAILED,
|
||||
StateChoices.CANCELLED,
|
||||
)
|
||||
|
||||
for scan in scheduled_scans:
|
||||
parent_scan = scan.scan
|
||||
if parent_scan is not None and parent_scan.state not in parent_terminal:
|
||||
continue
|
||||
|
||||
task_result = (
|
||||
getattr(scan.task, "task_runner_task", None) if scan.task else None
|
||||
)
|
||||
if task_result:
|
||||
_revoke_task(task_result, terminate=False)
|
||||
|
||||
reason = "Scan never started — cleaned up by periodic task"
|
||||
if _cleanup_scheduled_scan(scan, task_result, reason):
|
||||
cleaned_up.append(str(scan.id))
|
||||
|
||||
return cleaned_up
|
||||
|
||||
|
||||
def _is_worker_alive(worker: str) -> bool:
|
||||
@@ -98,12 +160,17 @@ def _is_worker_alive(worker: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _revoke_task(task_result) -> None:
|
||||
"""Send `SIGTERM` to a hung Celery task. Non-fatal on failure."""
|
||||
def _revoke_task(task_result, terminate: bool = True) -> None:
|
||||
"""Revoke a Celery task. Non-fatal on failure.
|
||||
|
||||
`terminate=True` SIGTERMs the worker if the task is mid-execution; use
|
||||
for EXECUTING cleanup. `terminate=False` only marks the task id revoked
|
||||
across workers, so any worker pulling the queued message discards it;
|
||||
use for SCHEDULED cleanup where the task hasn't run yet.
|
||||
"""
|
||||
try:
|
||||
current_app.control.revoke(
|
||||
task_result.task_id, terminate=True, signal="SIGTERM"
|
||||
)
|
||||
kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {}
|
||||
current_app.control.revoke(task_result.task_id, **kwargs)
|
||||
logger.info(f"Revoked task {task_result.task_id}")
|
||||
except Exception:
|
||||
logger.exception(f"Failed to revoke task {task_result.task_id}")
|
||||
@@ -125,28 +192,64 @@ def _cleanup_scan(scan, task_result, reason: str) -> bool:
|
||||
except Exception:
|
||||
logger.exception(f"Failed to drop temp database {tmp_db_name}")
|
||||
|
||||
# 2. Lock row, verify still EXECUTING, mark FAILED — all atomic
|
||||
with rls_transaction(str(scan.tenant_id)):
|
||||
try:
|
||||
fresh_scan = AttackPathsScan.objects.select_for_update().get(id=scan.id)
|
||||
except AttackPathsScan.DoesNotExist:
|
||||
logger.warning(f"Scan {scan_id_str} no longer exists, skipping")
|
||||
return False
|
||||
fresh_scan = _finalize_failed_scan(scan, StateChoices.EXECUTING, reason)
|
||||
if fresh_scan is None:
|
||||
return False
|
||||
|
||||
if fresh_scan.state != StateChoices.EXECUTING:
|
||||
logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping")
|
||||
return False
|
||||
|
||||
_mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason})
|
||||
|
||||
# 3. Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock)
|
||||
# Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock)
|
||||
if task_result:
|
||||
task_result.status = states.FAILURE
|
||||
task_result.date_done = datetime.now(tz=timezone.utc)
|
||||
task_result.save(update_fields=["status", "date_done"])
|
||||
|
||||
# 4. Recover graph_data_ready if provider data still exists
|
||||
recover_graph_data_ready(fresh_scan)
|
||||
|
||||
logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}")
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_scheduled_scan(scan, task_result, reason: str) -> bool:
|
||||
"""
|
||||
Clean up a `SCHEDULED` scan that never reached a worker.
|
||||
|
||||
Skips the temp Neo4j drop — the database is only created once the worker
|
||||
enters `EXECUTING`, so dropping it here just produces noisy log output.
|
||||
|
||||
Returns `True` if the scan was actually cleaned up, `False` if skipped.
|
||||
"""
|
||||
scan_id_str = str(scan.id)
|
||||
|
||||
fresh_scan = _finalize_failed_scan(scan, StateChoices.SCHEDULED, reason)
|
||||
if fresh_scan is None:
|
||||
return False
|
||||
|
||||
if task_result:
|
||||
task_result.status = states.FAILURE
|
||||
task_result.date_done = datetime.now(tz=timezone.utc)
|
||||
task_result.save(update_fields=["status", "date_done"])
|
||||
|
||||
logger.info(f"Cleaned up scheduled scan {scan_id_str}: {reason}")
|
||||
return True
|
||||
|
||||
|
||||
def _finalize_failed_scan(scan, expected_state: str, reason: str):
|
||||
"""
|
||||
Atomically lock the row, verify it's still in `expected_state`, and
|
||||
mark it `FAILED`. Returns the locked row on success, `None` if the
|
||||
row is gone or has already moved on.
|
||||
"""
|
||||
scan_id_str = str(scan.id)
|
||||
with rls_transaction(str(scan.tenant_id)):
|
||||
try:
|
||||
fresh_scan = AttackPathsScan.objects.select_for_update().get(id=scan.id)
|
||||
except AttackPathsScan.DoesNotExist:
|
||||
logger.warning(f"Scan {scan_id_str} no longer exists, skipping")
|
||||
return None
|
||||
|
||||
if fresh_scan.state != expected_state:
|
||||
logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping")
|
||||
return None
|
||||
|
||||
_mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason})
|
||||
|
||||
return fresh_scan
|
||||
|
||||
@@ -37,6 +37,8 @@ class ProviderConfig:
|
||||
# Label for resources connected to the account node, enabling indexed finding lookups.
|
||||
resource_label: str # e.g., "_AWSResource"
|
||||
ingestion_function: Callable
|
||||
# Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance).
|
||||
short_uid_extractor: Callable[[str], str]
|
||||
|
||||
|
||||
# Provider Configurations
|
||||
@@ -48,6 +50,7 @@ AWS_CONFIG = ProviderConfig(
|
||||
uid_field="arn",
|
||||
resource_label="_AWSResource",
|
||||
ingestion_function=aws.start_aws_ingestion,
|
||||
short_uid_extractor=aws.extract_short_uid,
|
||||
)
|
||||
|
||||
PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
|
||||
@@ -116,6 +119,21 @@ def get_provider_resource_label(provider_type: str) -> str:
|
||||
return config.resource_label if config else "_UnknownProviderResource"
|
||||
|
||||
|
||||
def _identity_short_uid(uid: str) -> str:
|
||||
"""Fallback short-uid extractor for providers without a custom mapping."""
|
||||
return uid
|
||||
|
||||
|
||||
def get_short_uid_extractor(provider_type: str) -> Callable[[str], str]:
|
||||
"""Get the short-uid extractor for a provider type.
|
||||
|
||||
Returns an identity function when the provider is unknown, so callers can
|
||||
rely on a callable always being returned.
|
||||
"""
|
||||
config = PROVIDER_CONFIGS.get(provider_type)
|
||||
return config.short_uid_extractor if config else _identity_short_uid
|
||||
|
||||
|
||||
# Dynamic Isolation Label Helpers
|
||||
# --------------------------------
|
||||
|
||||
|
||||
@@ -67,25 +67,52 @@ def retrieve_attack_paths_scan(
|
||||
return None
|
||||
|
||||
|
||||
def set_attack_paths_scan_task_id(
|
||||
tenant_id: str,
|
||||
scan_pk: str,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Persist the Celery `task_id` on the `AttackPathsScan` row.
|
||||
|
||||
Called at dispatch time (when `apply_async` returns) so the row carries
|
||||
the task id even while still `SCHEDULED`. This lets the periodic
|
||||
cleanup revoke queued messages for scans that never reached a worker.
|
||||
"""
|
||||
with rls_transaction(tenant_id):
|
||||
ProwlerAPIAttackPathsScan.objects.filter(id=scan_pk).update(task_id=task_id)
|
||||
|
||||
|
||||
def starting_attack_paths_scan(
|
||||
attack_paths_scan: ProwlerAPIAttackPathsScan,
|
||||
task_id: str,
|
||||
cartography_config: CartographyConfig,
|
||||
) -> None:
|
||||
with rls_transaction(attack_paths_scan.tenant_id):
|
||||
attack_paths_scan.task_id = task_id
|
||||
attack_paths_scan.state = StateChoices.EXECUTING
|
||||
attack_paths_scan.started_at = datetime.now(tz=timezone.utc)
|
||||
attack_paths_scan.update_tag = cartography_config.update_tag
|
||||
) -> bool:
|
||||
"""Flip the row from `SCHEDULED` to `EXECUTING` atomically.
|
||||
|
||||
attack_paths_scan.save(
|
||||
update_fields=[
|
||||
"task_id",
|
||||
"state",
|
||||
"started_at",
|
||||
"update_tag",
|
||||
]
|
||||
)
|
||||
Returns `False` if the row is gone or has already moved past
|
||||
`SCHEDULED` (e.g., periodic cleanup raced ahead and marked it
|
||||
`FAILED` while the worker message was still in flight).
|
||||
"""
|
||||
with rls_transaction(attack_paths_scan.tenant_id):
|
||||
try:
|
||||
locked = ProwlerAPIAttackPathsScan.objects.select_for_update().get(
|
||||
id=attack_paths_scan.id
|
||||
)
|
||||
except ProwlerAPIAttackPathsScan.DoesNotExist:
|
||||
return False
|
||||
|
||||
if locked.state != StateChoices.SCHEDULED:
|
||||
return False
|
||||
|
||||
locked.state = StateChoices.EXECUTING
|
||||
locked.started_at = datetime.now(tz=timezone.utc)
|
||||
locked.update_tag = cartography_config.update_tag
|
||||
locked.save(update_fields=["state", "started_at", "update_tag"])
|
||||
|
||||
# Keep the in-memory object the caller is holding in sync.
|
||||
attack_paths_scan.state = locked.state
|
||||
attack_paths_scan.started_at = locked.started_at
|
||||
attack_paths_scan.update_tag = locked.update_tag
|
||||
return True
|
||||
|
||||
|
||||
def _mark_scan_finished(
|
||||
|
||||
@@ -8,7 +8,7 @@ This module handles:
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Generator
|
||||
from typing import Any, Callable, Generator
|
||||
from uuid import UUID
|
||||
|
||||
import neo4j
|
||||
@@ -21,6 +21,7 @@ from tasks.jobs.attack_paths.config import (
|
||||
get_node_uid_field,
|
||||
get_provider_resource_label,
|
||||
get_root_node_label,
|
||||
get_short_uid_extractor,
|
||||
)
|
||||
from tasks.jobs.attack_paths.queries import (
|
||||
ADD_RESOURCE_LABEL_TEMPLATE,
|
||||
@@ -57,7 +58,9 @@ _DB_QUERY_FIELDS = [
|
||||
]
|
||||
|
||||
|
||||
def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]:
|
||||
def _to_neo4j_dict(
|
||||
record: dict[str, Any], resource_uid: str, resource_short_uid: str
|
||||
) -> dict[str, Any]:
|
||||
"""Transform a Django `.values()` record into a `dict` ready for Neo4j ingestion."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
@@ -75,6 +78,7 @@ def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]:
|
||||
"muted": record["muted"],
|
||||
"muted_reason": record["muted_reason"],
|
||||
"resource_uid": resource_uid,
|
||||
"resource_short_uid": resource_short_uid,
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +174,8 @@ def load_findings(
|
||||
|
||||
batch_num = 0
|
||||
total_records = 0
|
||||
edges_merged = 0
|
||||
edges_dropped = 0
|
||||
for batch in findings_batches:
|
||||
batch_num += 1
|
||||
batch_size = len(batch)
|
||||
@@ -178,9 +184,15 @@ def load_findings(
|
||||
parameters["findings_data"] = batch
|
||||
|
||||
logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
|
||||
neo4j_session.run(query, parameters)
|
||||
summary = neo4j_session.run(query, parameters).single()
|
||||
if summary is not None:
|
||||
edges_merged += summary.get("merged_count", 0)
|
||||
edges_dropped += summary.get("dropped_count", 0)
|
||||
|
||||
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
|
||||
logger.info(
|
||||
f"Finished loading {total_records} records in {batch_num} batches "
|
||||
f"(edges_merged={edges_merged}, edges_dropped={edges_dropped})"
|
||||
)
|
||||
return total_records
|
||||
|
||||
|
||||
@@ -205,8 +217,9 @@ def stream_findings_with_resources(
|
||||
)
|
||||
|
||||
tenant_id = prowler_api_provider.tenant_id
|
||||
short_uid_extractor = get_short_uid_extractor(prowler_api_provider.provider)
|
||||
for batch in _paginate_findings(tenant_id, scan_id):
|
||||
enriched = _enrich_batch_with_resources(batch, tenant_id)
|
||||
enriched = _enrich_batch_with_resources(batch, tenant_id, short_uid_extractor)
|
||||
if enriched:
|
||||
yield enriched
|
||||
|
||||
@@ -269,6 +282,7 @@ def _fetch_findings_batch(
|
||||
def _enrich_batch_with_resources(
|
||||
findings_batch: list[dict[str, Any]],
|
||||
tenant_id: str,
|
||||
short_uid_extractor: Callable[[str], str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Enrich findings with their resource UIDs.
|
||||
@@ -280,7 +294,7 @@ def _enrich_batch_with_resources(
|
||||
resource_map = _build_finding_resource_map(finding_ids, tenant_id)
|
||||
|
||||
return [
|
||||
_to_neo4j_dict(finding, resource_uid)
|
||||
_to_neo4j_dict(finding, resource_uid, short_uid_extractor(resource_uid))
|
||||
for finding in findings_batch
|
||||
for resource_uid in resource_map.get(finding["id"], [])
|
||||
]
|
||||
|
||||
@@ -35,46 +35,56 @@ INSERT_FINDING_TEMPLATE = f"""
|
||||
UNWIND $findings_data AS finding_data
|
||||
|
||||
OPTIONAL MATCH (resource_by_uid:__RESOURCE_LABEL__ {{__NODE_UID_FIELD__: finding_data.resource_uid}})
|
||||
WITH finding_data, resource_by_uid
|
||||
|
||||
OPTIONAL MATCH (resource_by_id:__RESOURCE_LABEL__ {{id: finding_data.resource_uid}})
|
||||
WHERE resource_by_uid IS NULL
|
||||
WITH finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource
|
||||
WHERE resource IS NOT NULL
|
||||
OPTIONAL MATCH (resource_by_short:__RESOURCE_LABEL__ {{id: finding_data.resource_short_uid}})
|
||||
WHERE resource_by_uid IS NULL AND resource_by_id IS NULL
|
||||
WITH finding_data,
|
||||
resource_by_uid,
|
||||
resource_by_id,
|
||||
head(collect(resource_by_short)) AS resource_by_short
|
||||
WITH finding_data,
|
||||
COALESCE(resource_by_uid, resource_by_id, resource_by_short) AS resource
|
||||
|
||||
MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}})
|
||||
ON CREATE SET
|
||||
finding.id = finding_data.id,
|
||||
finding.uid = finding_data.uid,
|
||||
finding.inserted_at = finding_data.inserted_at,
|
||||
finding.updated_at = finding_data.updated_at,
|
||||
finding.first_seen_at = finding_data.first_seen_at,
|
||||
finding.scan_id = finding_data.scan_id,
|
||||
finding.delta = finding_data.delta,
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.severity = finding_data.severity,
|
||||
finding.check_id = finding_data.check_id,
|
||||
finding.check_title = finding_data.check_title,
|
||||
finding.muted = finding_data.muted,
|
||||
finding.muted_reason = finding_data.muted_reason,
|
||||
finding.firstseen = timestamp(),
|
||||
finding.lastupdated = $last_updated,
|
||||
finding._module_name = 'cartography:prowler',
|
||||
finding._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.lastupdated = $last_updated
|
||||
FOREACH (_ IN CASE WHEN resource IS NOT NULL THEN [1] ELSE [] END |
|
||||
MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}})
|
||||
ON CREATE SET
|
||||
finding.id = finding_data.id,
|
||||
finding.uid = finding_data.uid,
|
||||
finding.inserted_at = finding_data.inserted_at,
|
||||
finding.updated_at = finding_data.updated_at,
|
||||
finding.first_seen_at = finding_data.first_seen_at,
|
||||
finding.scan_id = finding_data.scan_id,
|
||||
finding.delta = finding_data.delta,
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.severity = finding_data.severity,
|
||||
finding.check_id = finding_data.check_id,
|
||||
finding.check_title = finding_data.check_title,
|
||||
finding.muted = finding_data.muted,
|
||||
finding.muted_reason = finding_data.muted_reason,
|
||||
finding.firstseen = timestamp(),
|
||||
finding.lastupdated = $last_updated,
|
||||
finding._module_name = 'cartography:prowler',
|
||||
finding._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
finding.status = finding_data.status,
|
||||
finding.status_extended = finding_data.status_extended,
|
||||
finding.lastupdated = $last_updated
|
||||
MERGE (resource)-[rel:HAS_FINDING]->(finding)
|
||||
ON CREATE SET
|
||||
rel.firstseen = timestamp(),
|
||||
rel.lastupdated = $last_updated,
|
||||
rel._module_name = 'cartography:prowler',
|
||||
rel._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
rel.lastupdated = $last_updated
|
||||
)
|
||||
|
||||
MERGE (resource)-[rel:HAS_FINDING]->(finding)
|
||||
ON CREATE SET
|
||||
rel.firstseen = timestamp(),
|
||||
rel.lastupdated = $last_updated,
|
||||
rel._module_name = 'cartography:prowler',
|
||||
rel._module_version = $prowler_version
|
||||
ON MATCH SET
|
||||
rel.lastupdated = $last_updated
|
||||
WITH sum(CASE WHEN resource IS NOT NULL THEN 1 ELSE 0 END) AS merged_count,
|
||||
sum(CASE WHEN resource IS NULL THEN 1 ELSE 0 END) AS dropped_count
|
||||
|
||||
RETURN merged_count, dropped_count
|
||||
"""
|
||||
|
||||
# Internet queries (used by internet.py)
|
||||
|
||||
@@ -97,6 +97,19 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
)
|
||||
attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id)
|
||||
|
||||
# Idempotency guard: cleanup may have flipped this row to a terminal state
|
||||
# while the message was still in flight. Bail out before touching state.
|
||||
if attack_paths_scan and attack_paths_scan.state in (
|
||||
StateChoices.FAILED,
|
||||
StateChoices.COMPLETED,
|
||||
StateChoices.CANCELLED,
|
||||
):
|
||||
logger.warning(
|
||||
f"Attack Paths scan {attack_paths_scan.id} already in terminal "
|
||||
f"state {attack_paths_scan.state}; skipping execution"
|
||||
)
|
||||
return {}
|
||||
|
||||
# Checks before starting the scan
|
||||
if not cartography_ingestion_function:
|
||||
ingestion_exceptions = {
|
||||
@@ -114,12 +127,17 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
|
||||
else:
|
||||
if not attack_paths_scan:
|
||||
# Safety net for in-flight messages or direct task invocations; dispatcher normally pre-creates the row.
|
||||
logger.warning(
|
||||
f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then"
|
||||
)
|
||||
attack_paths_scan = db_utils.create_attack_paths_scan(
|
||||
tenant_id, scan_id, prowler_api_provider.id
|
||||
)
|
||||
if attack_paths_scan and task_id:
|
||||
db_utils.set_attack_paths_scan_task_id(
|
||||
tenant_id, attack_paths_scan.id, task_id
|
||||
)
|
||||
|
||||
tmp_database_name = graph_database.get_database_name(
|
||||
attack_paths_scan.id, temporary=True
|
||||
@@ -141,9 +159,13 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
# Starting the Attack Paths scan
|
||||
db_utils.starting_attack_paths_scan(
|
||||
attack_paths_scan, task_id, tenant_cartography_config
|
||||
)
|
||||
if not db_utils.starting_attack_paths_scan(
|
||||
attack_paths_scan, tenant_cartography_config
|
||||
):
|
||||
logger.warning(
|
||||
f"Attack Paths scan {attack_paths_scan.id} no longer in SCHEDULED state; cleanup likely raced ahead"
|
||||
)
|
||||
return {}
|
||||
|
||||
scan_t0 = time.perf_counter()
|
||||
logger.info(
|
||||
|
||||
@@ -297,12 +297,15 @@ def backfill_daily_severity_summaries(tenant_id: str, days: int = None):
|
||||
}
|
||||
|
||||
|
||||
def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
|
||||
def aggregate_scan_category_summaries(tenant_id: str, scan_id: str):
|
||||
"""
|
||||
Backfill ScanCategorySummary for a completed scan.
|
||||
|
||||
Aggregates category counts from all findings in the scan and creates
|
||||
one ScanCategorySummary row per (category, severity) combination.
|
||||
Idempotent: re-runs replace the scan's existing rows so counts stay in
|
||||
sync with `Finding.muted` updates triggered outside scan completion
|
||||
(e.g. mute rules).
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID
|
||||
@@ -312,11 +315,6 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
|
||||
dict: Status indicating whether backfill was performed
|
||||
"""
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
if ScanCategorySummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
).exists():
|
||||
return {"status": "already backfilled"}
|
||||
|
||||
if not Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
id=scan_id,
|
||||
@@ -337,9 +335,6 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
|
||||
cache=category_counts,
|
||||
)
|
||||
|
||||
if not category_counts:
|
||||
return {"status": "no categories to backfill"}
|
||||
|
||||
category_summaries = [
|
||||
ScanCategorySummary(
|
||||
tenant_id=tenant_id,
|
||||
@@ -353,20 +348,38 @@ def backfill_scan_category_summaries(tenant_id: str, scan_id: str):
|
||||
for (category, severity), counts in category_counts.items()
|
||||
]
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
ScanCategorySummary.objects.bulk_create(
|
||||
category_summaries, batch_size=500, ignore_conflicts=True
|
||||
)
|
||||
if category_summaries:
|
||||
with rls_transaction(tenant_id):
|
||||
# Upsert so re-runs (post-mute reaggregation) don't trip
|
||||
# `unique_category_severity_per_scan`; race-safe under concurrent writers.
|
||||
ScanCategorySummary.objects.bulk_create(
|
||||
category_summaries,
|
||||
batch_size=500,
|
||||
update_conflicts=True,
|
||||
unique_fields=["tenant_id", "scan_id", "category", "severity"],
|
||||
update_fields=[
|
||||
"total_findings",
|
||||
"failed_findings",
|
||||
"new_failed_findings",
|
||||
],
|
||||
)
|
||||
|
||||
if not category_counts:
|
||||
return {"status": "no categories to backfill"}
|
||||
|
||||
return {"status": "backfilled", "categories_count": len(category_counts)}
|
||||
|
||||
|
||||
def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
|
||||
def aggregate_scan_resource_group_summaries(tenant_id: str, scan_id: str):
|
||||
"""
|
||||
Backfill ScanGroupSummary for a completed scan.
|
||||
|
||||
Aggregates resource group counts from all findings in the scan and creates
|
||||
one ScanGroupSummary row per (resource_group, severity) combination.
|
||||
Idempotent: re-runs replace the scan's existing rows so counts stay in
|
||||
sync with `Finding.muted` updates triggered outside scan completion
|
||||
(e.g. mute rules) and with resource-inventory views reading from this
|
||||
table.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID
|
||||
@@ -376,11 +389,6 @@ def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
|
||||
dict: Status indicating whether backfill was performed
|
||||
"""
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
if ScanGroupSummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
).exists():
|
||||
return {"status": "already backfilled"}
|
||||
|
||||
if not Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
id=scan_id,
|
||||
@@ -418,9 +426,6 @@ def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
|
||||
group_resources_cache=group_resources_cache,
|
||||
)
|
||||
|
||||
if not resource_group_counts:
|
||||
return {"status": "no resource groups to backfill"}
|
||||
|
||||
# Compute group-level resource counts (same value for all severity rows in a group)
|
||||
group_resource_counts = {
|
||||
grp: len(uids) for grp, uids in group_resources_cache.items()
|
||||
@@ -439,10 +444,25 @@ def backfill_scan_resource_group_summaries(tenant_id: str, scan_id: str):
|
||||
for (grp, severity), counts in resource_group_counts.items()
|
||||
]
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
ScanGroupSummary.objects.bulk_create(
|
||||
resource_group_summaries, batch_size=500, ignore_conflicts=True
|
||||
)
|
||||
if resource_group_summaries:
|
||||
with rls_transaction(tenant_id):
|
||||
# Upsert so re-runs (post-mute reaggregation) don't trip
|
||||
# `unique_resource_group_severity_per_scan`; race-safe under concurrent writers.
|
||||
ScanGroupSummary.objects.bulk_create(
|
||||
resource_group_summaries,
|
||||
batch_size=500,
|
||||
update_conflicts=True,
|
||||
unique_fields=["tenant_id", "scan_id", "resource_group", "severity"],
|
||||
update_fields=[
|
||||
"total_findings",
|
||||
"failed_findings",
|
||||
"new_failed_findings",
|
||||
"resources_count",
|
||||
],
|
||||
)
|
||||
|
||||
if not resource_group_counts:
|
||||
return {"status": "no resource groups to backfill"}
|
||||
|
||||
return {"status": "backfilled", "resource_groups_count": len(resource_group_counts)}
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import gc
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from uuid import UUID
|
||||
|
||||
import fcntl
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
|
||||
from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3
|
||||
from tasks.jobs.reports import (
|
||||
FRAMEWORK_REGISTRY,
|
||||
CISReportGenerator,
|
||||
CSAReportGenerator,
|
||||
ENSReportGenerator,
|
||||
NIS2ReportGenerator,
|
||||
@@ -14,12 +22,398 @@ from tasks.jobs.reports import (
|
||||
from tasks.jobs.threatscore import compute_threatscore_metrics
|
||||
from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_database
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Provider, ScanSummary, ThreatScoreSnapshot
|
||||
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS = 48
|
||||
STALE_TMP_OUTPUT_MAX_DELETIONS_PER_RUN = 50
|
||||
STALE_TMP_OUTPUT_THROTTLE_SECONDS = 60 * 60
|
||||
STALE_TMP_OUTPUT_LOCK_FILE_NAME = ".stale_tmp_cleanup.lock"
|
||||
|
||||
# Refuse to ever run rmtree against shared system roots; the configured
|
||||
# DJANGO_TMP_OUTPUT_DIRECTORY must be a dedicated subdirectory.
|
||||
_FORBIDDEN_CLEANUP_ROOTS = frozenset(
|
||||
Path(p).resolve()
|
||||
for p in ("/", "/tmp", "/var", "/var/tmp", "/home", "/root", "/etc", "/usr")
|
||||
)
|
||||
|
||||
|
||||
def _resolve_stale_tmp_safe_root() -> Path | None:
|
||||
"""Resolve the configured tmp output directory, rejecting unsafe roots."""
|
||||
try:
|
||||
configured_root = Path(DJANGO_TMP_OUTPUT_DIRECTORY).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
if configured_root in _FORBIDDEN_CLEANUP_ROOTS:
|
||||
return None
|
||||
return configured_root
|
||||
|
||||
|
||||
STALE_TMP_OUTPUT_SAFE_ROOT = _resolve_stale_tmp_safe_root()
|
||||
|
||||
# Matches CIS compliance_ids like "cis_1.4_aws", "cis_5.0_azure",
|
||||
# "cis_1.10_kubernetes", "cis_3.0.1_aws". Requires at least one dotted
|
||||
# component so malformed inputs like "cis_._aws" or "cis_5._aws" are rejected
|
||||
# at the regex stage, rather than by a later ValueError fallback.
|
||||
_CIS_VARIANT_RE = re.compile(r"^cis_(?P<version>\d+(?:\.\d+)+)_(?P<provider>.+)$")
|
||||
|
||||
|
||||
def _pick_latest_cis_variant(compliance_ids: Iterable[str]) -> str | None:
|
||||
"""Return the CIS compliance_id with the highest semantic version.
|
||||
|
||||
CIS ships many variants per provider (e.g. cis_1.4_aws, ..., cis_6.0_aws).
|
||||
A lexicographic sort is incorrect for version strings like ``1.10`` vs
|
||||
``1.2``; this helper parses the version into a tuple of ints so ``1.10``
|
||||
is correctly ordered after ``1.2``. Malformed names are skipped so a
|
||||
broken JSON cannot crash the whole CIS pipeline.
|
||||
|
||||
Args:
|
||||
compliance_ids: Iterable of CIS compliance identifiers. Expected to
|
||||
belong to a single provider (callers should pass the already
|
||||
filtered keys from ``Compliance.get_bulk(provider_type)``).
|
||||
|
||||
Returns:
|
||||
The compliance_id with the highest parsed version, or ``None`` if no
|
||||
well-formed CIS identifier was found.
|
||||
"""
|
||||
best_key: tuple[int, ...] | None = None
|
||||
best_name: str | None = None
|
||||
for name in compliance_ids:
|
||||
match = _CIS_VARIANT_RE.match(name)
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
key = tuple(int(part) for part in match.group("version").split("."))
|
||||
except ValueError:
|
||||
# Defensive: the regex already guarantees numeric chunks, but we
|
||||
# keep the guard so a future regex change cannot crash callers.
|
||||
continue
|
||||
if best_key is None or key > best_key:
|
||||
best_key = key
|
||||
best_name = name
|
||||
return best_name
|
||||
|
||||
|
||||
def _should_run_stale_cleanup(
|
||||
root_path: Path,
|
||||
throttle_seconds: int = STALE_TMP_OUTPUT_THROTTLE_SECONDS,
|
||||
) -> bool:
|
||||
"""Throttle stale cleanup to at most once per hour per host."""
|
||||
lock_file_path = root_path / STALE_TMP_OUTPUT_LOCK_FILE_NAME
|
||||
now_timestamp = int(time.time())
|
||||
|
||||
try:
|
||||
with lock_file_path.open("a+", encoding="ascii") as lock_file:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return False
|
||||
lock_file.seek(0)
|
||||
previous_value = lock_file.read().strip()
|
||||
try:
|
||||
last_run_timestamp = int(previous_value) if previous_value else 0
|
||||
except ValueError:
|
||||
last_run_timestamp = 0
|
||||
|
||||
if now_timestamp - last_run_timestamp < throttle_seconds:
|
||||
return False
|
||||
|
||||
lock_file.seek(0)
|
||||
lock_file.truncate()
|
||||
lock_file.write(str(now_timestamp))
|
||||
lock_file.flush()
|
||||
os.fsync(lock_file.fileno())
|
||||
except OSError as error:
|
||||
logger.warning("Skipping stale tmp cleanup: lock file error (%s)", error)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _is_scan_metadata_protected(
|
||||
scan_path: Path,
|
||||
scan_state: str | None,
|
||||
output_location: str | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Return True when metadata indicates the directory must not be deleted.
|
||||
|
||||
Protected cases:
|
||||
- Scan is still EXECUTING.
|
||||
- Scan has a local output artifact path (non-S3) under this scan directory.
|
||||
"""
|
||||
if scan_state == StateChoices.EXECUTING.value:
|
||||
return True
|
||||
|
||||
output_location = output_location or ""
|
||||
if output_location and not output_location.startswith("s3://"):
|
||||
try:
|
||||
resolved_output_location = Path(output_location).resolve()
|
||||
except OSError:
|
||||
# Conservative fallback: if we cannot resolve a local output path,
|
||||
# keep the directory to avoid deleting potentially needed artifacts.
|
||||
return True
|
||||
|
||||
if (
|
||||
resolved_output_location == scan_path
|
||||
or scan_path in resolved_output_location.parents
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_scan_directory_protected(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
scan_path: Path,
|
||||
) -> bool:
|
||||
"""
|
||||
DB-backed wrapper used when batch metadata is not already available.
|
||||
"""
|
||||
try:
|
||||
scan_uuid = UUID(scan_id)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
try:
|
||||
scan = (
|
||||
Scan.all_objects.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id, id=scan_uuid)
|
||||
.only("state", "output_location")
|
||||
.first()
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup for %s/%s due to scan lookup error: %s",
|
||||
tenant_id,
|
||||
scan_id,
|
||||
error,
|
||||
)
|
||||
return True
|
||||
|
||||
if not scan:
|
||||
return False
|
||||
|
||||
return _is_scan_metadata_protected(
|
||||
scan_path=scan_path,
|
||||
scan_state=scan.state,
|
||||
output_location=scan.output_location,
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_stale_tmp_output_directories(
|
||||
tmp_output_root: str,
|
||||
max_age_hours: int = STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
exclude_scan: tuple[str, str] | None = None,
|
||||
max_deletions_per_run: int = STALE_TMP_OUTPUT_MAX_DELETIONS_PER_RUN,
|
||||
) -> int:
|
||||
"""
|
||||
Opportunistically delete stale scan directories under the tmp output root.
|
||||
|
||||
Expected directory layout:
|
||||
<tmp_output_root>/<tenant_id>/<scan_id>/...
|
||||
|
||||
Each run that wins the per-host throttle sweeps every tenant directory so
|
||||
leftover artifacts cannot pile up for tenants whose own tasks happen to
|
||||
lose the throttle race.
|
||||
|
||||
Args:
|
||||
tmp_output_root: Base tmp output path.
|
||||
max_age_hours: Directory max age before deletion.
|
||||
exclude_scan: Optional (tenant_id, scan_id) that must never be deleted.
|
||||
max_deletions_per_run: Max number of scan directories deleted per run.
|
||||
|
||||
Returns:
|
||||
Number of deleted scan directories.
|
||||
"""
|
||||
try:
|
||||
if max_age_hours <= 0:
|
||||
return 0
|
||||
|
||||
try:
|
||||
root_path = Path(tmp_output_root).resolve()
|
||||
except OSError as error:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup: unable to resolve %s (%s)",
|
||||
tmp_output_root,
|
||||
error,
|
||||
)
|
||||
return 0
|
||||
|
||||
if (
|
||||
STALE_TMP_OUTPUT_SAFE_ROOT is None
|
||||
or root_path != STALE_TMP_OUTPUT_SAFE_ROOT
|
||||
):
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup: unsupported root %s (allowed: %s)",
|
||||
root_path,
|
||||
STALE_TMP_OUTPUT_SAFE_ROOT,
|
||||
)
|
||||
return 0
|
||||
|
||||
if not root_path.exists() or not root_path.is_dir():
|
||||
return 0
|
||||
|
||||
if max_deletions_per_run <= 0:
|
||||
return 0
|
||||
|
||||
if not _should_run_stale_cleanup(root_path):
|
||||
return 0
|
||||
|
||||
cutoff_timestamp = time.time() - (max_age_hours * 60 * 60)
|
||||
deleted_scan_dirs = 0
|
||||
|
||||
try:
|
||||
tenant_dirs = list(root_path.iterdir())
|
||||
except OSError as error:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup: unable to list %s (%s)",
|
||||
root_path,
|
||||
error,
|
||||
)
|
||||
return 0
|
||||
|
||||
for tenant_dir in tenant_dirs:
|
||||
if deleted_scan_dirs >= max_deletions_per_run:
|
||||
break
|
||||
|
||||
if not tenant_dir.is_dir() or tenant_dir.is_symlink():
|
||||
continue
|
||||
|
||||
try:
|
||||
scan_dirs = list(tenant_dir.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
stale_candidates: list[tuple[str, Path, UUID | None]] = []
|
||||
for scan_dir in scan_dirs:
|
||||
if not scan_dir.is_dir() or scan_dir.is_symlink():
|
||||
continue
|
||||
|
||||
if exclude_scan and (
|
||||
tenant_dir.name == exclude_scan[0]
|
||||
and scan_dir.name == exclude_scan[1]
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
if scan_dir.stat().st_mtime >= cutoff_timestamp:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
try:
|
||||
resolved_scan_dir = scan_dir.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if root_path not in resolved_scan_dir.parents:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup for path outside root: %s",
|
||||
resolved_scan_dir,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
scan_uuid: UUID | None = UUID(scan_dir.name)
|
||||
except ValueError:
|
||||
scan_uuid = None
|
||||
|
||||
stale_candidates.append((scan_dir.name, resolved_scan_dir, scan_uuid))
|
||||
|
||||
if not stale_candidates:
|
||||
continue
|
||||
|
||||
scan_metadata_by_id: dict[UUID, tuple[str | None, str | None]] = {}
|
||||
metadata_preload_succeeded = False
|
||||
candidate_scan_ids = [
|
||||
candidate[2] for candidate in stale_candidates if candidate[2]
|
||||
]
|
||||
if candidate_scan_ids:
|
||||
try:
|
||||
scan_rows = (
|
||||
Scan.all_objects.using(MainRouter.admin_db)
|
||||
.filter(
|
||||
tenant_id=tenant_dir.name,
|
||||
id__in=candidate_scan_ids,
|
||||
)
|
||||
.values_list("id", "state", "output_location")
|
||||
)
|
||||
scan_metadata_by_id = {
|
||||
scan_id: (scan_state, output_location)
|
||||
for scan_id, scan_state, output_location in scan_rows
|
||||
}
|
||||
metadata_preload_succeeded = True
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup metadata preload for tenant %s: %s",
|
||||
tenant_dir.name,
|
||||
error,
|
||||
)
|
||||
else:
|
||||
metadata_preload_succeeded = True
|
||||
|
||||
for scan_name, resolved_scan_dir, scan_uuid in stale_candidates:
|
||||
if deleted_scan_dirs >= max_deletions_per_run:
|
||||
break
|
||||
|
||||
should_check_scan_fallback = True
|
||||
if scan_uuid and metadata_preload_succeeded:
|
||||
should_check_scan_fallback = False
|
||||
scan_metadata = scan_metadata_by_id.get(scan_uuid)
|
||||
if scan_metadata:
|
||||
scan_state, output_location = scan_metadata
|
||||
if _is_scan_metadata_protected(
|
||||
scan_path=resolved_scan_dir,
|
||||
scan_state=scan_state,
|
||||
output_location=output_location,
|
||||
):
|
||||
continue
|
||||
|
||||
if should_check_scan_fallback and _is_scan_directory_protected(
|
||||
tenant_id=tenant_dir.name,
|
||||
scan_id=scan_name,
|
||||
scan_path=resolved_scan_dir,
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
rmtree(resolved_scan_dir, ignore_errors=True)
|
||||
deleted_scan_dirs += 1
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Error cleaning stale tmp directory %s: %s",
|
||||
resolved_scan_dir,
|
||||
error,
|
||||
)
|
||||
|
||||
if deleted_scan_dirs:
|
||||
logger.info(
|
||||
"Deleted %s stale tmp output directories older than %sh from %s",
|
||||
deleted_scan_dirs,
|
||||
max_age_hours,
|
||||
root_path,
|
||||
)
|
||||
if deleted_scan_dirs >= max_deletions_per_run:
|
||||
logger.info(
|
||||
"Stale tmp cleanup hit deletion limit (%s) for root %s",
|
||||
max_deletions_per_run,
|
||||
root_path,
|
||||
)
|
||||
|
||||
return deleted_scan_dirs
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup due to unexpected error: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def generate_threatscore_report(
|
||||
@@ -191,6 +585,53 @@ def generate_csa_report(
|
||||
)
|
||||
|
||||
|
||||
def generate_cis_report(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
compliance_id: str,
|
||||
output_path: str,
|
||||
provider_id: str,
|
||||
only_failed: bool = True,
|
||||
include_manual: bool = False,
|
||||
provider_obj: Provider | None = None,
|
||||
requirement_statistics: dict[str, dict[str, int]] | None = None,
|
||||
findings_cache: dict[str, list[FindingOutput]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Generate a PDF compliance report for a specific CIS Benchmark variant.
|
||||
|
||||
Unlike single-version frameworks (ENS, NIS2, CSA), CIS has multiple
|
||||
variants per provider (e.g., cis_1.4_aws, cis_5.0_aws, cis_6.0_aws). This
|
||||
wrapper is called once per variant, receiving the specific compliance_id.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant ID for Row-Level Security context.
|
||||
scan_id: ID of the scan executed by Prowler.
|
||||
compliance_id: ID of the specific CIS variant (e.g., "cis_5.0_aws").
|
||||
output_path: Output PDF file path.
|
||||
provider_id: Provider ID for the scan.
|
||||
only_failed: If True, only include failed requirements in detailed section.
|
||||
include_manual: If True, include manual requirements in detailed section.
|
||||
provider_obj: Pre-fetched Provider object to avoid duplicate queries.
|
||||
requirement_statistics: Pre-aggregated requirement statistics.
|
||||
findings_cache: Cache of already loaded findings to avoid duplicate queries.
|
||||
"""
|
||||
generator = CISReportGenerator(FRAMEWORK_REGISTRY["cis"])
|
||||
|
||||
generator.generate(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
compliance_id=compliance_id,
|
||||
output_path=output_path,
|
||||
provider_id=provider_id,
|
||||
provider_obj=provider_obj,
|
||||
requirement_statistics=requirement_statistics,
|
||||
findings_cache=findings_cache,
|
||||
only_failed=only_failed,
|
||||
include_manual=include_manual,
|
||||
)
|
||||
|
||||
|
||||
def generate_compliance_reports(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
@@ -199,6 +640,7 @@ def generate_compliance_reports(
|
||||
generate_ens: bool = True,
|
||||
generate_nis2: bool = True,
|
||||
generate_csa: bool = True,
|
||||
generate_cis: bool = True,
|
||||
only_failed_threatscore: bool = True,
|
||||
min_risk_level_threatscore: int = 4,
|
||||
include_manual_ens: bool = True,
|
||||
@@ -206,6 +648,8 @@ def generate_compliance_reports(
|
||||
only_failed_nis2: bool = True,
|
||||
only_failed_csa: bool = True,
|
||||
include_manual_csa: bool = False,
|
||||
only_failed_cis: bool = True,
|
||||
include_manual_cis: bool = False,
|
||||
) -> dict[str, dict[str, bool | str]]:
|
||||
"""
|
||||
Generate multiple compliance reports with shared database queries.
|
||||
@@ -215,6 +659,13 @@ def generate_compliance_reports(
|
||||
- Aggregating requirement statistics once (shared across all reports)
|
||||
- Reusing compliance framework data when possible
|
||||
|
||||
For CIS a single PDF is produced per run: the one matching the highest
|
||||
available CIS version for the scan's provider (picked dynamically from
|
||||
``Compliance.get_bulk`` via :func:`_pick_latest_cis_variant`). The
|
||||
returned ``results["cis"]`` entry has the same flat shape as the other
|
||||
single-version frameworks — the picked variant is an internal detail,
|
||||
not surfaced in the result.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant ID for Row-Level Security context.
|
||||
scan_id: The ID of the scan to generate reports for.
|
||||
@@ -223,6 +674,8 @@ def generate_compliance_reports(
|
||||
generate_ens: Whether to generate ENS report.
|
||||
generate_nis2: Whether to generate NIS2 report.
|
||||
generate_csa: Whether to generate CSA CCM report.
|
||||
generate_cis: Whether to generate a CIS Benchmark report for the
|
||||
latest CIS version available for the provider.
|
||||
only_failed_threatscore: For ThreatScore, only include failed requirements.
|
||||
min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements.
|
||||
include_manual_ens: For ENS, include manual requirements.
|
||||
@@ -230,22 +683,39 @@ def generate_compliance_reports(
|
||||
only_failed_nis2: For NIS2, only include failed requirements.
|
||||
only_failed_csa: For CSA CCM, only include failed requirements.
|
||||
include_manual_csa: For CSA CCM, include manual requirements.
|
||||
only_failed_cis: For CIS, only include failed requirements in detailed section.
|
||||
include_manual_cis: For CIS, include manual requirements in detailed section.
|
||||
|
||||
Returns:
|
||||
Dictionary with results for each report type.
|
||||
Dictionary with results for each report type. Every value has the
|
||||
same flat shape: ``{"upload": bool, "path": str, "error"?: str}``.
|
||||
"""
|
||||
logger.info(
|
||||
"Generating compliance reports for scan %s with provider %s"
|
||||
" (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s)",
|
||||
" (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s, CIS: %s)",
|
||||
scan_id,
|
||||
provider_id,
|
||||
generate_threatscore,
|
||||
generate_ens,
|
||||
generate_nis2,
|
||||
generate_csa,
|
||||
generate_cis,
|
||||
)
|
||||
|
||||
results = {}
|
||||
try:
|
||||
_cleanup_stale_tmp_output_directories(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
exclude_scan=(tenant_id, scan_id),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup before compliance reports for scan %s: %s",
|
||||
scan_id,
|
||||
error,
|
||||
)
|
||||
|
||||
results: dict = {}
|
||||
|
||||
# Validate that the scan has findings and get provider info
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
@@ -259,6 +729,8 @@ def generate_compliance_reports(
|
||||
results["nis2"] = {"upload": False, "path": ""}
|
||||
if generate_csa:
|
||||
results["csa"] = {"upload": False, "path": ""}
|
||||
if generate_cis:
|
||||
results["cis"] = {"upload": False, "path": ""}
|
||||
return results
|
||||
|
||||
provider_obj = Provider.objects.get(id=provider_id)
|
||||
@@ -299,11 +771,39 @@ def generate_compliance_reports(
|
||||
results["csa"] = {"upload": False, "path": ""}
|
||||
generate_csa = False
|
||||
|
||||
# For CIS we do NOT pre-check the provider against a hard-coded whitelist
|
||||
# (that list drifts the moment a new CIS JSON ships). Instead, we inspect
|
||||
# the dynamically loaded framework map and pick the latest available CIS
|
||||
# version, if any.
|
||||
latest_cis: str | None = None
|
||||
if generate_cis:
|
||||
try:
|
||||
frameworks_bulk = Compliance.get_bulk(provider_type)
|
||||
latest_cis = _pick_latest_cis_variant(
|
||||
name for name in frameworks_bulk.keys() if name.startswith("cis_")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error discovering CIS variants for %s: %s", provider_type, e)
|
||||
results["cis"] = {"upload": False, "path": "", "error": str(e)}
|
||||
generate_cis = False
|
||||
else:
|
||||
if latest_cis is None:
|
||||
logger.info("No CIS variants available for provider %s", provider_type)
|
||||
results["cis"] = {"upload": False, "path": ""}
|
||||
generate_cis = False
|
||||
else:
|
||||
logger.info(
|
||||
"Selected latest CIS variant for provider %s: %s",
|
||||
provider_type,
|
||||
latest_cis,
|
||||
)
|
||||
|
||||
if (
|
||||
not generate_threatscore
|
||||
and not generate_ens
|
||||
and not generate_nis2
|
||||
and not generate_csa
|
||||
and not generate_cis
|
||||
):
|
||||
return results
|
||||
|
||||
@@ -319,38 +819,56 @@ def generate_compliance_reports(
|
||||
findings_cache = {}
|
||||
logger.info("Created shared findings cache for all reports")
|
||||
|
||||
# Generate output directories
|
||||
generated_report_keys: list[str] = []
|
||||
output_paths: dict[str, str] = {}
|
||||
out_dir: str | None = None
|
||||
|
||||
# Generate output directories only for enabled and supported report types.
|
||||
try:
|
||||
logger.info("Generating output directories")
|
||||
threatscore_path = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="threatscore",
|
||||
)
|
||||
ens_path = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="ens",
|
||||
)
|
||||
nis2_path = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="nis2",
|
||||
)
|
||||
csa_path = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="csa",
|
||||
)
|
||||
out_dir = str(Path(threatscore_path).parent.parent)
|
||||
if generate_threatscore:
|
||||
output_paths["threatscore"] = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="threatscore",
|
||||
)
|
||||
if generate_ens:
|
||||
output_paths["ens"] = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="ens",
|
||||
)
|
||||
if generate_nis2:
|
||||
output_paths["nis2"] = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="nis2",
|
||||
)
|
||||
if generate_csa:
|
||||
output_paths["csa"] = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="csa",
|
||||
)
|
||||
if generate_cis and latest_cis:
|
||||
output_paths["cis"] = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="cis",
|
||||
)
|
||||
if output_paths:
|
||||
first_output_path = next(iter(output_paths.values()))
|
||||
out_dir = str(Path(first_output_path).parent.parent)
|
||||
except Exception as e:
|
||||
logger.error("Error generating output directory: %s", e)
|
||||
error_dict = {"error": str(e), "upload": False, "path": ""}
|
||||
@@ -362,10 +880,14 @@ def generate_compliance_reports(
|
||||
results["nis2"] = error_dict.copy()
|
||||
if generate_csa:
|
||||
results["csa"] = error_dict.copy()
|
||||
if generate_cis:
|
||||
results["cis"] = error_dict.copy()
|
||||
return results
|
||||
|
||||
# Generate ThreatScore report
|
||||
if generate_threatscore:
|
||||
generated_report_keys.append("threatscore")
|
||||
threatscore_path = output_paths["threatscore"]
|
||||
compliance_id_threatscore = f"prowler_threatscore_{provider_type}"
|
||||
pdf_path_threatscore = f"{threatscore_path}_threatscore_report.pdf"
|
||||
logger.info(
|
||||
@@ -467,6 +989,8 @@ def generate_compliance_reports(
|
||||
|
||||
# Generate ENS report
|
||||
if generate_ens:
|
||||
generated_report_keys.append("ens")
|
||||
ens_path = output_paths["ens"]
|
||||
compliance_id_ens = f"ens_rd2022_{provider_type}"
|
||||
pdf_path_ens = f"{ens_path}_ens_report.pdf"
|
||||
logger.info("Generating ENS report with compliance %s", compliance_id_ens)
|
||||
@@ -501,6 +1025,8 @@ def generate_compliance_reports(
|
||||
|
||||
# Generate NIS2 report
|
||||
if generate_nis2:
|
||||
generated_report_keys.append("nis2")
|
||||
nis2_path = output_paths["nis2"]
|
||||
compliance_id_nis2 = f"nis2_{provider_type}"
|
||||
pdf_path_nis2 = f"{nis2_path}_nis2_report.pdf"
|
||||
logger.info("Generating NIS2 report with compliance %s", compliance_id_nis2)
|
||||
@@ -536,6 +1062,8 @@ def generate_compliance_reports(
|
||||
|
||||
# Generate CSA CCM report
|
||||
if generate_csa:
|
||||
generated_report_keys.append("csa")
|
||||
csa_path = output_paths["csa"]
|
||||
compliance_id_csa = f"csa_ccm_4.0_{provider_type}"
|
||||
pdf_path_csa = f"{csa_path}_csa_report.pdf"
|
||||
logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa)
|
||||
@@ -569,14 +1097,75 @@ def generate_compliance_reports(
|
||||
logger.error("Error generating CSA CCM report: %s", e)
|
||||
results["csa"] = {"upload": False, "path": "", "error": str(e)}
|
||||
|
||||
# Clean up temporary files if all reports were uploaded successfully
|
||||
all_uploaded = all(
|
||||
result.get("upload", False)
|
||||
for result in results.values()
|
||||
if result.get("upload") is not None
|
||||
# Generate CIS Benchmark report for the latest available version only.
|
||||
# CIS ships multiple versions per provider (e.g. cis_1.4_aws, cis_5.0_aws,
|
||||
# cis_6.0_aws); we dynamically pick the highest semantic version at run
|
||||
# time rather than hard-coding a per-provider mapping.
|
||||
if generate_cis and latest_cis:
|
||||
generated_report_keys.append("cis")
|
||||
cis_path = output_paths["cis"]
|
||||
if out_dir is None:
|
||||
out_dir = str(Path(cis_path).parent.parent)
|
||||
pdf_path_cis = f"{cis_path}_cis_report.pdf"
|
||||
try:
|
||||
generate_cis_report(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
compliance_id=latest_cis,
|
||||
output_path=pdf_path_cis,
|
||||
provider_id=provider_id,
|
||||
only_failed=only_failed_cis,
|
||||
include_manual=include_manual_cis,
|
||||
provider_obj=provider_obj,
|
||||
requirement_statistics=requirement_statistics,
|
||||
findings_cache=findings_cache,
|
||||
)
|
||||
|
||||
upload_uri_cis = _upload_to_s3(
|
||||
tenant_id,
|
||||
scan_id,
|
||||
pdf_path_cis,
|
||||
f"cis/{Path(pdf_path_cis).name}",
|
||||
)
|
||||
|
||||
if upload_uri_cis:
|
||||
results["cis"] = {
|
||||
"upload": True,
|
||||
"path": upload_uri_cis,
|
||||
}
|
||||
logger.info(
|
||||
"CIS report %s uploaded to %s",
|
||||
latest_cis,
|
||||
upload_uri_cis,
|
||||
)
|
||||
else:
|
||||
results["cis"] = {"upload": False, "path": out_dir}
|
||||
logger.warning(
|
||||
"CIS report %s saved locally at %s",
|
||||
latest_cis,
|
||||
out_dir,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating CIS report %s: %s", latest_cis, e)
|
||||
results["cis"] = {
|
||||
"upload": False,
|
||||
"path": "",
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
# Free ReportLab/matplotlib memory before moving on.
|
||||
gc.collect()
|
||||
|
||||
# Clean up temporary files only if all generated reports were
|
||||
# uploaded successfully. Reports skipped for provider incompatibility
|
||||
# or missing CIS variants must not block cleanup.
|
||||
all_uploaded = bool(generated_report_keys) and all(
|
||||
results.get(report_key, {}).get("upload", False)
|
||||
for report_key in generated_report_keys
|
||||
)
|
||||
|
||||
if all_uploaded:
|
||||
if all_uploaded and out_dir:
|
||||
try:
|
||||
rmtree(Path(out_dir), ignore_errors=True)
|
||||
logger.info("Cleaned up temporary files at %s", out_dir)
|
||||
@@ -595,6 +1184,7 @@ def generate_compliance_reports_job(
|
||||
generate_ens: bool = True,
|
||||
generate_nis2: bool = True,
|
||||
generate_csa: bool = True,
|
||||
generate_cis: bool = True,
|
||||
) -> dict[str, dict[str, bool | str]]:
|
||||
"""
|
||||
Celery task wrapper for generate_compliance_reports.
|
||||
@@ -607,9 +1197,12 @@ def generate_compliance_reports_job(
|
||||
generate_ens: Whether to generate ENS report.
|
||||
generate_nis2: Whether to generate NIS2 report.
|
||||
generate_csa: Whether to generate CSA CCM report.
|
||||
generate_cis: Whether to generate the CIS Benchmark report for the
|
||||
latest CIS version available for the provider.
|
||||
|
||||
Returns:
|
||||
Dictionary with results for each report type.
|
||||
Dictionary with results for each report type. Every entry shares the
|
||||
same flat ``{"upload", "path", "error"?}`` shape.
|
||||
"""
|
||||
return generate_compliance_reports(
|
||||
tenant_id=tenant_id,
|
||||
@@ -619,4 +1212,5 @@ def generate_compliance_reports_job(
|
||||
generate_ens=generate_ens,
|
||||
generate_nis2=generate_nis2,
|
||||
generate_csa=generate_csa,
|
||||
generate_cis=generate_cis,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,9 @@ from .charts import (
|
||||
get_chart_color_for_percentage,
|
||||
)
|
||||
|
||||
# Framework-specific generators
|
||||
from .cis import CISReportGenerator
|
||||
|
||||
# Reusable components
|
||||
# Reusable components: Color helpers, Badge components, Risk component,
|
||||
# Table components, Section components
|
||||
@@ -31,10 +34,12 @@ from .components import (
|
||||
create_section_header,
|
||||
create_status_badge,
|
||||
create_summary_table,
|
||||
escape_html,
|
||||
get_color_for_compliance,
|
||||
get_color_for_risk_level,
|
||||
get_color_for_weight,
|
||||
get_status_color,
|
||||
truncate_text,
|
||||
)
|
||||
|
||||
# Framework configuration: Main configuration, Color constants, ENS colors,
|
||||
@@ -90,8 +95,6 @@ from .config import (
|
||||
FrameworkConfig,
|
||||
get_framework_config,
|
||||
)
|
||||
|
||||
# Framework-specific generators
|
||||
from .csa import CSAReportGenerator
|
||||
from .ens import ENSReportGenerator
|
||||
from .nis2 import NIS2ReportGenerator
|
||||
@@ -109,6 +112,7 @@ __all__ = [
|
||||
"ENSReportGenerator",
|
||||
"NIS2ReportGenerator",
|
||||
"CSAReportGenerator",
|
||||
"CISReportGenerator",
|
||||
# Configuration
|
||||
"FrameworkConfig",
|
||||
"FRAMEWORK_REGISTRY",
|
||||
@@ -182,6 +186,9 @@ __all__ = [
|
||||
# Section components
|
||||
"create_section_header",
|
||||
"create_summary_table",
|
||||
# Text helpers
|
||||
"truncate_text",
|
||||
"escape_html",
|
||||
# Chart functions
|
||||
"get_chart_color_for_percentage",
|
||||
"create_vertical_bar_chart",
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
|
||||
|
||||
from api.models import StatusChoices
|
||||
|
||||
from .base import (
|
||||
BaseComplianceReportGenerator,
|
||||
ComplianceData,
|
||||
RequirementData,
|
||||
get_requirement_metadata,
|
||||
)
|
||||
from .charts import (
|
||||
create_horizontal_bar_chart,
|
||||
create_pie_chart,
|
||||
create_stacked_bar_chart,
|
||||
get_chart_color_for_percentage,
|
||||
)
|
||||
from .components import ColumnConfig, create_data_table, escape_html, truncate_text
|
||||
from .config import (
|
||||
CHART_COLOR_GREEN_1,
|
||||
CHART_COLOR_RED,
|
||||
CHART_COLOR_YELLOW,
|
||||
COLOR_BG_BLUE,
|
||||
COLOR_BLUE,
|
||||
COLOR_BORDER_GRAY,
|
||||
COLOR_DARK_GRAY,
|
||||
COLOR_GRAY,
|
||||
COLOR_GRID_GRAY,
|
||||
COLOR_HIGH_RISK,
|
||||
COLOR_LIGHT_BLUE,
|
||||
COLOR_SAFE,
|
||||
COLOR_WHITE,
|
||||
)
|
||||
|
||||
# Ordered buckets used both in the executive summary tables and the charts
|
||||
# section. Exposed as module constants so the two call sites never drift.
|
||||
_PROFILE_BUCKET_ORDER: tuple[str, ...] = ("L1", "L2", "Other")
|
||||
_ASSESSMENT_BUCKET_ORDER: tuple[str, ...] = ("Automated", "Manual")
|
||||
|
||||
# Anchored matchers for profile normalization — substring checks on "L1"/"L2"
|
||||
# would happily match unrelated tokens like "CL2 Worker" or "HL2" coming from
|
||||
# future CIS profile enum values.
|
||||
_LEVEL_2_RE = re.compile(r"(?:\bLevel\s*2\b|\bL2\b|Level_2)")
|
||||
_LEVEL_1_RE = re.compile(r"(?:\bLevel\s*1\b|\bL1\b|Level_1)")
|
||||
|
||||
|
||||
def _normalize_profile(profile: Any) -> str:
|
||||
"""Bucket a CIS Profile enum/string into one of: ``L1``, ``L2``, ``Other``.
|
||||
|
||||
The ``CIS_Requirement_Attribute_Profile`` enum has values like
|
||||
``"Level 1"``, ``"Level 2"``, ``"E3 Level 1"``, ``"E5 Level 2"``. We
|
||||
collapse them into three buckets to keep charts and badges readable
|
||||
across CIS variants, using anchored regex matches so that future enum
|
||||
values cannot accidentally promote e.g. ``"CL2 Worker"`` into ``L2``.
|
||||
|
||||
Args:
|
||||
profile: The profile value (enum member, string, or ``None``).
|
||||
|
||||
Returns:
|
||||
One of ``"L1"``, ``"L2"``, ``"Other"``.
|
||||
"""
|
||||
if profile is None:
|
||||
return "Other"
|
||||
value = getattr(profile, "value", None) or str(profile)
|
||||
if _LEVEL_2_RE.search(value):
|
||||
return "L2"
|
||||
if _LEVEL_1_RE.search(value):
|
||||
return "L1"
|
||||
return "Other"
|
||||
|
||||
|
||||
def _profile_badge_text(bucket: str) -> str:
|
||||
"""Map a normalized profile bucket (L1/L2/Other) to a short badge label."""
|
||||
return {"L1": "Level 1", "L2": "Level 2"}.get(bucket, "Other")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CIS Report Generator
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CISReportGenerator(BaseComplianceReportGenerator):
|
||||
"""
|
||||
PDF report generator for CIS (Center for Internet Security) Benchmarks.
|
||||
|
||||
CIS differs from single-version frameworks (ENS, NIS2, CSA) in that:
|
||||
- Each provider has multiple CIS versions (e.g. AWS: 1.4, 1.5, ..., 6.0).
|
||||
- Section names differ across versions and providers and MUST be derived
|
||||
at runtime from the loaded compliance data.
|
||||
- Requirements carry Profile (Level 1/Level 2) and AssessmentStatus
|
||||
(Automated/Manual) attributes that drive the executive summary and
|
||||
charts.
|
||||
|
||||
This generator produces:
|
||||
- Cover page with Prowler logo and dynamic CIS version/provider metadata
|
||||
- Executive summary with overall compliance score, counts, and breakdowns
|
||||
by Profile and AssessmentStatus
|
||||
- Charts: overall status pie, pass rate by section (horizontal bar),
|
||||
Level 1 vs Level 2 pass/fail distribution (stacked bar)
|
||||
- Requirements index grouped by dynamic section
|
||||
- Detailed findings for FAIL requirements with CIS-specific audit /
|
||||
remediation / rationale details
|
||||
"""
|
||||
|
||||
# Per-run memoization cache for ``_compute_statistics``. ``generate()``
|
||||
# is the public entry point and is called once per PDF, so scoping the
|
||||
# cache to the last seen ComplianceData instance is enough to avoid the
|
||||
# double computation between executive summary and charts section.
|
||||
_stats_cache_key: int | None = None
|
||||
_stats_cache_value: dict | None = None
|
||||
|
||||
# Body section ordering — ensure every top-level section starts on its
|
||||
# own clean page. The base class only puts a PageBreak AFTER Charts and
|
||||
# Requirements Index, so Executive Summary and Charts end up sharing a
|
||||
# page. This override prepends a PageBreak so Compliance Analysis always
|
||||
# begins on a fresh page.
|
||||
def _build_body_sections(self, data: ComplianceData) -> list:
|
||||
return [PageBreak(), *super()._build_body_sections(data)]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Cover page override — shows dynamic CIS version + provider in the title
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def create_cover_page(self, data: ComplianceData) -> list:
|
||||
"""Create the CIS report cover page with Prowler + CIS logos side by side."""
|
||||
elements = []
|
||||
|
||||
# Create logos side by side (same pattern as NIS2 / ENS)
|
||||
prowler_logo_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../assets/img/prowler_logo.png"
|
||||
)
|
||||
cis_logo_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../assets/img/cis_logo.png"
|
||||
)
|
||||
|
||||
if os.path.exists(cis_logo_path):
|
||||
prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch)
|
||||
cis_logo = Image(cis_logo_path, width=2.3 * inch, height=1.1 * inch)
|
||||
logos_table = Table(
|
||||
[[prowler_logo, cis_logo]], colWidths=[4 * inch, 2.5 * inch]
|
||||
)
|
||||
logos_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("ALIGN", (0, 0), (0, 0), "LEFT"),
|
||||
("ALIGN", (1, 0), (1, 0), "RIGHT"),
|
||||
("VALIGN", (0, 0), (0, 0), "MIDDLE"),
|
||||
("VALIGN", (1, 0), (1, 0), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(logos_table)
|
||||
elif os.path.exists(prowler_logo_path):
|
||||
# Fallback: only the Prowler logo if the CIS asset is missing
|
||||
elements.append(Image(prowler_logo_path, width=5 * inch, height=1 * inch))
|
||||
|
||||
elements.append(Spacer(1, 0.5 * inch))
|
||||
|
||||
# Dynamic title: "CIS Benchmark v5.0 — AWS Compliance Report"
|
||||
provider_label = ""
|
||||
if data.provider_obj:
|
||||
provider_label = f" — {data.provider_obj.provider.upper()}"
|
||||
title_text = (
|
||||
f"CIS Benchmark v{data.version}{provider_label}<br/>Compliance Report"
|
||||
)
|
||||
elements.append(Paragraph(title_text, self.styles["title"]))
|
||||
elements.append(Spacer(1, 0.5 * inch))
|
||||
|
||||
# Metadata table via base class helper
|
||||
info_rows = self._build_info_rows(data, language=self.config.language)
|
||||
metadata_data = []
|
||||
for label, value in info_rows:
|
||||
if label in ("Name:", "Description:") and value:
|
||||
metadata_data.append(
|
||||
[label, Paragraph(str(value), self.styles["normal_center"])]
|
||||
)
|
||||
else:
|
||||
metadata_data.append([label, value])
|
||||
|
||||
metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch])
|
||||
metadata_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE),
|
||||
("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (0, -1), "FiraCode"),
|
||||
("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE),
|
||||
("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY),
|
||||
("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 11),
|
||||
("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(metadata_table)
|
||||
|
||||
return elements
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Executive Summary
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def create_executive_summary(self, data: ComplianceData) -> list:
|
||||
"""Create the CIS executive summary section."""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Executive Summary", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
stats = self._compute_statistics(data)
|
||||
|
||||
# --- Summary metrics table ---
|
||||
summary_data = [
|
||||
["Metric", "Value"],
|
||||
["Total Requirements", str(stats["total"])],
|
||||
["Passed", str(stats["passed"])],
|
||||
["Failed", str(stats["failed"])],
|
||||
["Manual", str(stats["manual"])],
|
||||
["Overall Compliance", f"{stats['overall_compliance']:.1f}%"],
|
||||
]
|
||||
summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
|
||||
summary_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE),
|
||||
("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK),
|
||||
("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY),
|
||||
("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 12),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 10),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 10),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(1, 1),
|
||||
(1, -1),
|
||||
[COLOR_WHITE, COLOR_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(summary_table)
|
||||
elements.append(Spacer(1, 0.25 * inch))
|
||||
|
||||
# --- Profile breakdown table ---
|
||||
elements.append(Paragraph("Breakdown by Profile", self.styles["h2"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
profile_counts = stats["profile_counts"]
|
||||
profile_table_data = [["Profile", "Passed", "Failed", "Manual", "Total"]]
|
||||
for bucket in _PROFILE_BUCKET_ORDER:
|
||||
counts = profile_counts.get(bucket, {"passed": 0, "failed": 0, "manual": 0})
|
||||
total = counts["passed"] + counts["failed"] + counts["manual"]
|
||||
if total == 0:
|
||||
continue
|
||||
profile_table_data.append(
|
||||
[
|
||||
_profile_badge_text(bucket),
|
||||
str(counts["passed"]),
|
||||
str(counts["failed"]),
|
||||
str(counts["manual"]),
|
||||
str(total),
|
||||
]
|
||||
)
|
||||
profile_table = Table(
|
||||
profile_table_data,
|
||||
colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch],
|
||||
)
|
||||
profile_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(0, 1),
|
||||
(-1, -1),
|
||||
[COLOR_WHITE, COLOR_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(profile_table)
|
||||
elements.append(Spacer(1, 0.25 * inch))
|
||||
|
||||
# --- Assessment status breakdown ---
|
||||
elements.append(Paragraph("Breakdown by Assessment Status", self.styles["h2"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
assessment_counts = stats["assessment_counts"]
|
||||
assessment_table_data = [["Assessment", "Passed", "Failed", "Manual", "Total"]]
|
||||
for bucket in _ASSESSMENT_BUCKET_ORDER:
|
||||
counts = assessment_counts.get(
|
||||
bucket, {"passed": 0, "failed": 0, "manual": 0}
|
||||
)
|
||||
total = counts["passed"] + counts["failed"] + counts["manual"]
|
||||
if total == 0:
|
||||
continue
|
||||
assessment_table_data.append(
|
||||
[
|
||||
bucket,
|
||||
str(counts["passed"]),
|
||||
str(counts["failed"]),
|
||||
str(counts["manual"]),
|
||||
str(total),
|
||||
]
|
||||
)
|
||||
assessment_table = Table(
|
||||
assessment_table_data,
|
||||
colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch],
|
||||
)
|
||||
assessment_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_LIGHT_BLUE),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(0, 1),
|
||||
(-1, -1),
|
||||
[COLOR_WHITE, COLOR_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(assessment_table)
|
||||
elements.append(Spacer(1, 0.25 * inch))
|
||||
|
||||
# --- Top 5 failing sections ---
|
||||
top_failing = stats["top_failing_sections"]
|
||||
if top_failing:
|
||||
elements.append(
|
||||
Paragraph("Top Sections with Lowest Compliance", self.styles["h2"])
|
||||
)
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
top_table_data = [["Section", "Passed", "Failed", "Compliance"]]
|
||||
for section_label, section_stats in top_failing:
|
||||
passed = section_stats["passed"]
|
||||
failed = section_stats["failed"]
|
||||
total = passed + failed
|
||||
pct = (passed / total * 100) if total > 0 else 100
|
||||
top_table_data.append(
|
||||
[
|
||||
truncate_text(section_label, 55),
|
||||
str(passed),
|
||||
str(failed),
|
||||
f"{pct:.1f}%",
|
||||
]
|
||||
)
|
||||
top_table = Table(
|
||||
top_table_data,
|
||||
colWidths=[3.5 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch],
|
||||
)
|
||||
top_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(0, 1),
|
||||
(-1, -1),
|
||||
[COLOR_WHITE, COLOR_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(top_table)
|
||||
|
||||
return elements
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Charts section
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def create_charts_section(self, data: ComplianceData) -> list:
|
||||
"""Create the CIS charts section."""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Compliance Analysis", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# --- Pie chart: overall Pass / Fail / Manual ---
|
||||
stats = self._compute_statistics(data)
|
||||
pie_labels = []
|
||||
pie_values = []
|
||||
pie_colors = []
|
||||
if stats["passed"] > 0:
|
||||
pie_labels.append(f"Pass ({stats['passed']})")
|
||||
pie_values.append(stats["passed"])
|
||||
pie_colors.append(CHART_COLOR_GREEN_1)
|
||||
if stats["failed"] > 0:
|
||||
pie_labels.append(f"Fail ({stats['failed']})")
|
||||
pie_values.append(stats["failed"])
|
||||
pie_colors.append(CHART_COLOR_RED)
|
||||
if stats["manual"] > 0:
|
||||
pie_labels.append(f"Manual ({stats['manual']})")
|
||||
pie_values.append(stats["manual"])
|
||||
pie_colors.append(CHART_COLOR_YELLOW)
|
||||
|
||||
if pie_values:
|
||||
elements.append(Paragraph("Overall Status Distribution", self.styles["h2"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
pie_buffer = create_pie_chart(
|
||||
labels=pie_labels,
|
||||
values=pie_values,
|
||||
colors=pie_colors,
|
||||
)
|
||||
pie_buffer.seek(0)
|
||||
elements.append(Image(pie_buffer, width=4.5 * inch, height=4.5 * inch))
|
||||
elements.append(Spacer(1, 0.2 * inch))
|
||||
|
||||
# --- Horizontal bar: pass rate by section ---
|
||||
section_stats = stats["section_stats"]
|
||||
if section_stats:
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Compliance by Section", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
elements.append(
|
||||
Paragraph(
|
||||
"The following chart shows compliance percentage for each CIS "
|
||||
"section based on automated checks:",
|
||||
self.styles["normal_center"],
|
||||
)
|
||||
)
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# Sort sections by pass rate descending for readability
|
||||
sorted_sections = sorted(
|
||||
section_stats.items(),
|
||||
key=lambda item: (
|
||||
(item[1]["passed"] / (item[1]["passed"] + item[1]["failed"]) * 100)
|
||||
if (item[1]["passed"] + item[1]["failed"]) > 0
|
||||
else 100
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
bar_labels = []
|
||||
bar_values = []
|
||||
for section_label, section_data in sorted_sections:
|
||||
total = section_data["passed"] + section_data["failed"]
|
||||
if total == 0:
|
||||
continue
|
||||
pct = (section_data["passed"] / total) * 100
|
||||
bar_labels.append(truncate_text(section_label, 60))
|
||||
bar_values.append(pct)
|
||||
|
||||
if bar_values:
|
||||
bar_buffer = create_horizontal_bar_chart(
|
||||
labels=bar_labels,
|
||||
values=bar_values,
|
||||
xlabel="Compliance (%)",
|
||||
color_func=get_chart_color_for_percentage,
|
||||
label_fontsize=9,
|
||||
)
|
||||
bar_buffer.seek(0)
|
||||
elements.append(Image(bar_buffer, width=6.5 * inch, height=5 * inch))
|
||||
|
||||
# --- Stacked bar: Level 1 vs Level 2 pass/fail ---
|
||||
profile_counts = stats["profile_counts"]
|
||||
has_profile_data = any(
|
||||
(counts["passed"] + counts["failed"]) > 0
|
||||
for counts in profile_counts.values()
|
||||
)
|
||||
if has_profile_data:
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Profile Breakdown", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
elements.append(
|
||||
Paragraph(
|
||||
"Distribution of Pass / Fail / Manual across CIS profile levels.",
|
||||
self.styles["normal_center"],
|
||||
)
|
||||
)
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
profile_labels = []
|
||||
pass_series = []
|
||||
fail_series = []
|
||||
manual_series = []
|
||||
for bucket in _PROFILE_BUCKET_ORDER:
|
||||
counts = profile_counts.get(bucket)
|
||||
if not counts:
|
||||
continue
|
||||
total = counts["passed"] + counts["failed"] + counts["manual"]
|
||||
if total == 0:
|
||||
continue
|
||||
profile_labels.append(_profile_badge_text(bucket))
|
||||
pass_series.append(counts["passed"])
|
||||
fail_series.append(counts["failed"])
|
||||
manual_series.append(counts["manual"])
|
||||
|
||||
if profile_labels:
|
||||
stacked_buffer = create_stacked_bar_chart(
|
||||
labels=profile_labels,
|
||||
data_series={
|
||||
"Pass": pass_series,
|
||||
"Fail": fail_series,
|
||||
"Manual": manual_series,
|
||||
},
|
||||
xlabel="Profile",
|
||||
ylabel="Requirements",
|
||||
)
|
||||
stacked_buffer.seek(0)
|
||||
elements.append(Image(stacked_buffer, width=6 * inch, height=4 * inch))
|
||||
|
||||
return elements
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Requirements Index
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def create_requirements_index(self, data: ComplianceData) -> list:
|
||||
"""Create the CIS requirements index grouped by dynamic section."""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Requirements Index", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
sections = self._derive_sections(data)
|
||||
by_section: dict[str, list[dict]] = defaultdict(list)
|
||||
for req in data.requirements:
|
||||
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
section = "Other"
|
||||
profile_bucket = "Other"
|
||||
assessment = ""
|
||||
if meta:
|
||||
section = getattr(meta, "Section", "Other") or "Other"
|
||||
profile_bucket = _normalize_profile(getattr(meta, "Profile", None))
|
||||
assessment_enum = getattr(meta, "AssessmentStatus", None)
|
||||
assessment = getattr(assessment_enum, "value", None) or str(
|
||||
assessment_enum or ""
|
||||
)
|
||||
by_section[section].append(
|
||||
{
|
||||
"id": req.id,
|
||||
"description": truncate_text(req.description, 80),
|
||||
"profile": _profile_badge_text(profile_bucket),
|
||||
"assessment": assessment or "-",
|
||||
"status": (req.status or "").upper(),
|
||||
}
|
||||
)
|
||||
|
||||
columns = [
|
||||
ColumnConfig("ID", 0.9 * inch, "id", align="LEFT"),
|
||||
ColumnConfig("Description", 3.0 * inch, "description", align="LEFT"),
|
||||
ColumnConfig("Profile", 0.9 * inch, "profile"),
|
||||
ColumnConfig("Assessment", 1 * inch, "assessment"),
|
||||
ColumnConfig("Status", 0.9 * inch, "status"),
|
||||
]
|
||||
|
||||
for section in sections:
|
||||
rows = by_section.get(section, [])
|
||||
if not rows:
|
||||
continue
|
||||
elements.append(Paragraph(truncate_text(section, 90), self.styles["h2"]))
|
||||
elements.append(Spacer(1, 0.05 * inch))
|
||||
table = create_data_table(
|
||||
data=rows,
|
||||
columns=columns,
|
||||
header_color=self.config.primary_color,
|
||||
normal_style=self.styles["normal_center"],
|
||||
)
|
||||
elements.append(table)
|
||||
elements.append(Spacer(1, 0.15 * inch))
|
||||
|
||||
return elements
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Detailed findings hook — inject CIS-specific rationale / audit content
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _render_requirement_detail_extras(
|
||||
self, req: RequirementData, data: ComplianceData
|
||||
) -> list:
|
||||
"""Render CIS rationale, impact, audit, remediation and references."""
|
||||
extras = []
|
||||
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if meta is None:
|
||||
return extras
|
||||
|
||||
field_map = [
|
||||
("Rationale", "RationaleStatement"),
|
||||
("Impact", "ImpactStatement"),
|
||||
("Audit Procedure", "AuditProcedure"),
|
||||
("Remediation", "RemediationProcedure"),
|
||||
("References", "References"),
|
||||
]
|
||||
|
||||
for label, attr_name in field_map:
|
||||
value = getattr(meta, attr_name, None)
|
||||
if not value:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
continue
|
||||
extras.append(Paragraph(f"<b>{label}:</b>", self.styles["h3"]))
|
||||
extras.append(Paragraph(escape_html(text), self.styles["normal"]))
|
||||
extras.append(Spacer(1, 0.08 * inch))
|
||||
|
||||
return extras
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _derive_sections(self, data: ComplianceData) -> list[str]:
|
||||
"""Extract ordered unique Section names from loaded compliance data."""
|
||||
seen: dict[str, bool] = {}
|
||||
for req in data.requirements:
|
||||
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if meta is None:
|
||||
continue
|
||||
section = getattr(meta, "Section", None) or "Other"
|
||||
if section not in seen:
|
||||
seen[section] = True
|
||||
return list(seen.keys())
|
||||
|
||||
def _compute_statistics(self, data: ComplianceData) -> dict:
|
||||
"""Aggregate all statistics needed for summary and charts.
|
||||
|
||||
Memoized per-``ComplianceData`` instance via ``_stats_cache_*``: the
|
||||
executive summary and the charts section both need the same numbers,
|
||||
so they would otherwise re-iterate the requirements twice. We key on
|
||||
``id(data)`` because ``ComplianceData`` is a dataclass and its
|
||||
instances are not hashable.
|
||||
|
||||
Returns a dict with:
|
||||
- total, passed, failed, manual: int
|
||||
- overall_compliance: float (percentage)
|
||||
- profile_counts: {"L1": {"passed", "failed", "manual"}, ...}
|
||||
- assessment_counts: {"Automated": {...}, "Manual": {...}}
|
||||
- section_stats: {section_name: {"passed", "failed", "manual"}, ...}
|
||||
- top_failing_sections: list[(section_name, stats)] (up to 5)
|
||||
"""
|
||||
cache_key = id(data)
|
||||
if self._stats_cache_key == cache_key and self._stats_cache_value is not None:
|
||||
return self._stats_cache_value
|
||||
stats = self._compute_statistics_uncached(data)
|
||||
self._stats_cache_key = cache_key
|
||||
self._stats_cache_value = stats
|
||||
return stats
|
||||
|
||||
def _compute_statistics_uncached(self, data: ComplianceData) -> dict:
|
||||
"""Actual aggregation kernel; call ``_compute_statistics`` instead."""
|
||||
total = len(data.requirements)
|
||||
passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS)
|
||||
failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL)
|
||||
manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL)
|
||||
|
||||
evaluated = passed + failed
|
||||
overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100.0
|
||||
|
||||
profile_counts: dict[str, dict[str, int]] = {
|
||||
"L1": {"passed": 0, "failed": 0, "manual": 0},
|
||||
"L2": {"passed": 0, "failed": 0, "manual": 0},
|
||||
"Other": {"passed": 0, "failed": 0, "manual": 0},
|
||||
}
|
||||
assessment_counts: dict[str, dict[str, int]] = {
|
||||
"Automated": {"passed": 0, "failed": 0, "manual": 0},
|
||||
"Manual": {"passed": 0, "failed": 0, "manual": 0},
|
||||
}
|
||||
section_stats: dict[str, dict[str, int]] = defaultdict(
|
||||
lambda: {"passed": 0, "failed": 0, "manual": 0}
|
||||
)
|
||||
|
||||
for req in data.requirements:
|
||||
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if meta is None:
|
||||
continue
|
||||
|
||||
profile_bucket = _normalize_profile(getattr(meta, "Profile", None))
|
||||
assessment_enum = getattr(meta, "AssessmentStatus", None)
|
||||
assessment_value = getattr(assessment_enum, "value", None) or str(
|
||||
assessment_enum or ""
|
||||
)
|
||||
assessment_bucket = (
|
||||
"Automated" if assessment_value == "Automated" else "Manual"
|
||||
)
|
||||
section = getattr(meta, "Section", None) or "Other"
|
||||
|
||||
status_key = {
|
||||
StatusChoices.PASS: "passed",
|
||||
StatusChoices.FAIL: "failed",
|
||||
StatusChoices.MANUAL: "manual",
|
||||
}.get(req.status)
|
||||
if status_key is None:
|
||||
continue
|
||||
|
||||
profile_counts[profile_bucket][status_key] += 1
|
||||
assessment_counts[assessment_bucket][status_key] += 1
|
||||
section_stats[section][status_key] += 1
|
||||
|
||||
# Top 5 sections with lowest pass rate (only sections with evaluated reqs)
|
||||
def _section_rate(item):
|
||||
_, stats_ = item
|
||||
evaluated_ = stats_["passed"] + stats_["failed"]
|
||||
if evaluated_ == 0:
|
||||
return 101 # sort evaluated=0 to the bottom
|
||||
return stats_["passed"] / evaluated_ * 100
|
||||
|
||||
top_failing_sections = sorted(
|
||||
(
|
||||
item
|
||||
for item in section_stats.items()
|
||||
if (item[1]["passed"] + item[1]["failed"]) > 0
|
||||
),
|
||||
key=_section_rate,
|
||||
)[:5]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"manual": manual,
|
||||
"overall_compliance": overall_compliance,
|
||||
"profile_counts": profile_counts,
|
||||
"assessment_counts": assessment_counts,
|
||||
"section_stats": dict(section_stats),
|
||||
"top_failing_sections": top_failing_sections,
|
||||
}
|
||||
@@ -26,6 +26,52 @@ from .config import (
|
||||
)
|
||||
|
||||
|
||||
def truncate_text(text: str, max_len: int) -> str:
|
||||
"""Truncate ``text`` to ``max_len`` characters, appending an ellipsis if cut.
|
||||
|
||||
Used by report generators that need to squeeze long descriptions, section
|
||||
titles or finding titles into a fixed-width table cell.
|
||||
|
||||
Args:
|
||||
text: Source string. ``None`` and non-string values are treated as empty.
|
||||
max_len: Maximum output length including the ellipsis. Values < 4 are
|
||||
clamped so the result never grows beyond ``max_len``.
|
||||
|
||||
Returns:
|
||||
The original string if short enough, otherwise ``text[: max_len - 3] + "..."``.
|
||||
When ``max_len < 4`` a plain substring of length ``max_len`` is returned
|
||||
so callers never get a string longer than they asked for.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
text = str(text)
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
if max_len < 4:
|
||||
return text[:max_len]
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def escape_html(text: str) -> str:
|
||||
"""Escape the minimal HTML entities required for safe ReportLab Paragraph rendering.
|
||||
|
||||
ReportLab's ``Paragraph`` parses a small HTML subset, so raw ``<``, ``>``
|
||||
and ``&`` in user-provided content (rationale, remediation, etc.) would
|
||||
break layout or be interpreted as tags. This helper mirrors
|
||||
``html.escape`` but avoids pulling in the stdlib dependency and keeps the
|
||||
output deterministic.
|
||||
|
||||
Args:
|
||||
text: Untrusted source string.
|
||||
|
||||
Returns:
|
||||
A string safe to embed inside a ReportLab Paragraph.
|
||||
"""
|
||||
return (
|
||||
str(text or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
def get_color_for_risk_level(risk_level: int) -> colors.Color:
|
||||
"""
|
||||
Get color based on risk level.
|
||||
|
||||
@@ -313,6 +313,32 @@ FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = {
|
||||
has_niveles=False,
|
||||
has_weight=False,
|
||||
),
|
||||
"cis": FrameworkConfig(
|
||||
name="cis",
|
||||
display_name="CIS Benchmark",
|
||||
logo_filename=None,
|
||||
primary_color=COLOR_BLUE,
|
||||
secondary_color=COLOR_LIGHT_BLUE,
|
||||
bg_color=COLOR_BG_BLUE,
|
||||
attribute_fields=[
|
||||
"Section",
|
||||
"SubSection",
|
||||
"Profile",
|
||||
"AssessmentStatus",
|
||||
"Description",
|
||||
"RationaleStatement",
|
||||
"ImpactStatement",
|
||||
"RemediationProcedure",
|
||||
"AuditProcedure",
|
||||
"References",
|
||||
],
|
||||
sections=None, # Derived dynamically per CIS variant (section names differ across versions/providers)
|
||||
language="en",
|
||||
has_risk_levels=False,
|
||||
has_dimensions=False,
|
||||
has_niveles=False,
|
||||
has_weight=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -336,5 +362,7 @@ def get_framework_config(compliance_id: str) -> FrameworkConfig | None:
|
||||
return FRAMEWORK_REGISTRY["nis2"]
|
||||
if "csa" in compliance_lower or "ccm" in compliance_lower:
|
||||
return FRAMEWORK_REGISTRY["csa_ccm"]
|
||||
if compliance_lower.startswith("cis_") or "cis" in compliance_lower:
|
||||
return FRAMEWORK_REGISTRY["cis"]
|
||||
|
||||
return None
|
||||
|
||||
@@ -10,16 +10,29 @@ from typing import Any
|
||||
|
||||
import sentry_sdk
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from config.env import env
|
||||
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
|
||||
from django.db import IntegrityError, OperationalError
|
||||
from django.db.models import Case, Count, IntegerField, Max, Min, Prefetch, Q, Sum, When
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
Exists,
|
||||
IntegerField,
|
||||
Max,
|
||||
Min,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Sum,
|
||||
When,
|
||||
)
|
||||
from django.utils import timezone as django_timezone
|
||||
from tasks.jobs.queries import (
|
||||
COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL,
|
||||
COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
|
||||
)
|
||||
from tasks.utils import CustomEncoder
|
||||
from tasks.utils import CustomEncoder, batched
|
||||
|
||||
from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE
|
||||
from api.constants import SEVERITY_ORDER
|
||||
@@ -1197,8 +1210,39 @@ def aggregate_findings(tenant_id: str, scan_id: str):
|
||||
muted_changed=agg["muted_changed"],
|
||||
)
|
||||
for agg in aggregation
|
||||
if agg["resources__service"] is not None
|
||||
and agg["resources__region"] is not None
|
||||
}
|
||||
ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000)
|
||||
# Upsert so re-runs (post-mute reaggregation) don't trip
|
||||
# `unique_scan_summary`; race-safe under concurrent writers.
|
||||
ScanSummary.objects.bulk_create(
|
||||
scan_aggregations,
|
||||
batch_size=3000,
|
||||
update_conflicts=True,
|
||||
unique_fields=[
|
||||
"tenant",
|
||||
"scan",
|
||||
"check_id",
|
||||
"service",
|
||||
"severity",
|
||||
"region",
|
||||
],
|
||||
update_fields=[
|
||||
"_pass",
|
||||
"fail",
|
||||
"muted",
|
||||
"total",
|
||||
"new",
|
||||
"changed",
|
||||
"unchanged",
|
||||
"fail_new",
|
||||
"fail_changed",
|
||||
"pass_new",
|
||||
"pass_changed",
|
||||
"muted_new",
|
||||
"muted_changed",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _aggregate_findings_by_region(
|
||||
@@ -1543,13 +1587,24 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create overview records
|
||||
if overview_objects:
|
||||
with rls_transaction(tenant_id):
|
||||
AttackSurfaceOverview.objects.bulk_create(overview_objects, batch_size=500)
|
||||
logger.info(
|
||||
f"Created {len(overview_objects)} attack surface overview records for scan {scan_id}"
|
||||
# Upsert so re-runs (post-mute reaggregation) don't trip
|
||||
# `unique_attack_surface_per_scan`; race-safe under concurrent writers.
|
||||
AttackSurfaceOverview.objects.bulk_create(
|
||||
overview_objects,
|
||||
batch_size=500,
|
||||
update_conflicts=True,
|
||||
unique_fields=["tenant_id", "scan_id", "attack_surface_type"],
|
||||
update_fields=[
|
||||
"total_findings",
|
||||
"failed_findings",
|
||||
"muted_failed_findings",
|
||||
],
|
||||
)
|
||||
logger.info(
|
||||
f"Upserted {len(overview_objects)} attack surface overview records for scan {scan_id}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"No attack surface overview records created for scan {scan_id}")
|
||||
|
||||
@@ -2027,3 +2082,169 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str):
|
||||
"created": created_count,
|
||||
"updated": updated_count,
|
||||
}
|
||||
|
||||
|
||||
def reset_ephemeral_resource_findings_count(tenant_id: str, scan_id: str) -> dict:
|
||||
"""Zero failed_findings_count for resources missing from a completed full-scope scan.
|
||||
|
||||
Resources that exist in the database for the scan's provider but were not
|
||||
touched by this scan are treated as ephemeral. We keep their historical
|
||||
findings, but reset the denormalized counter that drives the Resources page
|
||||
sort so they stop ranking at the top.
|
||||
|
||||
Skipped (no-op) when:
|
||||
- The scan is not in COMPLETED state.
|
||||
- The scan ran with any scoping filter in scanner_args (partial scope).
|
||||
|
||||
Query design (must scale to 500k+ resources per provider):
|
||||
Phase 1 — collect ephemeral IDs with one anti-join read.
|
||||
Outer filter ``(tenant_id, provider_id, failed_findings_count > 0)``
|
||||
uses ``resources_tenant_provider_idx``. The correlated
|
||||
``NOT EXISTS`` subquery hits the implicit unique index
|
||||
``(tenant_id, scan_id, resource_id)`` on ``ResourceScanSummary``.
|
||||
``NOT EXISTS`` (vs ``NOT IN``) is null-safe and lets the planner
|
||||
choose between hash anti-join and indexed nested-loop anti-join.
|
||||
``.iterator(chunk_size=...)`` skips the queryset cache so memory
|
||||
stays bounded while streaming UUIDs.
|
||||
Phase 2 — UPDATE in fixed-size batches.
|
||||
One large UPDATE would hold row-exclusive locks for seconds and
|
||||
create a WAL spike. Batched UPDATEs by ``id__in`` (~1k rows each)
|
||||
hit the primary key, keep each lock window ~50ms, bound WAL chunks,
|
||||
and let other writers proceed between batches.
|
||||
``failed_findings_count__gt=0`` in the UPDATE is idempotent under
|
||||
concurrent scans and skips no-op rewrites.
|
||||
Reads use the primary DB, not the replica: ``ResourceScanSummary`` rows
|
||||
were written by the same scan task that triggered this one, so replica
|
||||
lag could falsely classify scanned resources as ephemeral.
|
||||
|
||||
Scope detection (``Scan.is_full_scope()``) derives the set of scoping
|
||||
scanner_args from ``prowler.lib.scan.scan.Scan.__init__`` via
|
||||
introspection, so the API can never drift from the SDK's filter
|
||||
contract. Imported scans are also rejected by trigger — they may only
|
||||
cover a partial slice of resources.
|
||||
"""
|
||||
with rls_transaction(tenant_id):
|
||||
scan = Scan.objects.filter(tenant_id=tenant_id, id=scan_id).first()
|
||||
|
||||
if scan is None:
|
||||
logger.warning(f"Scan {scan_id} not found")
|
||||
return {"status": "skipped", "reason": "scan not found"}
|
||||
|
||||
if scan.state != StateChoices.COMPLETED:
|
||||
logger.info(f"Scan {scan_id} not completed; skipping ephemeral reset")
|
||||
return {"status": "skipped", "reason": "scan not completed"}
|
||||
|
||||
if not scan.is_full_scope():
|
||||
logger.info(
|
||||
f"Scan {scan_id} ran with scoping filters; skipping ephemeral reset"
|
||||
)
|
||||
return {"status": "skipped", "reason": "partial scan scope"}
|
||||
|
||||
# Race protection: if a newer completed full-scope scan exists for this
|
||||
# provider, our ResourceScanSummary set is stale relative to the resources'
|
||||
# current failed_findings_count values (which the newer scan already
|
||||
# refreshed). Wiping based on the older scan would zero counts the newer
|
||||
# scan just set. Skip and let the newer scan's reset task do the work; if
|
||||
# this task was delayed in the queue, that's the correct outcome.
|
||||
# `completed_at__isnull=False` is required: Postgres orders NULL first in
|
||||
# DESC, so a sibling COMPLETED scan with a missing completed_at would sort
|
||||
# as "newest" and incorrectly cause us to skip.
|
||||
with rls_transaction(tenant_id):
|
||||
latest_full_scope_scan_id = (
|
||||
Scan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=scan.provider_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
completed_at__isnull=False,
|
||||
)
|
||||
.order_by("-completed_at", "-inserted_at")
|
||||
.values_list("id", flat=True)
|
||||
.first()
|
||||
)
|
||||
if latest_full_scope_scan_id != scan.id:
|
||||
logger.info(
|
||||
f"Scan {scan_id} is not the latest completed scan for provider "
|
||||
f"{scan.provider_id}; skipping ephemeral reset"
|
||||
)
|
||||
return {"status": "skipped", "reason": "newer scan exists"}
|
||||
|
||||
# Defensive gate: ResourceScanSummary rows are written by perform_prowler_scan
|
||||
# via best-effort bulk_create. If those writes failed silently (or the scan
|
||||
# genuinely produced resources but no summaries were persisted), the
|
||||
# ~Exists(in_scan) anti-join below would classify EVERY resource for this
|
||||
# provider as ephemeral and zero their counts. Bail loudly instead.
|
||||
with rls_transaction(tenant_id):
|
||||
summaries_present = ResourceScanSummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
).exists()
|
||||
if scan.unique_resource_count > 0 and not summaries_present:
|
||||
logger.error(
|
||||
f"Scan {scan_id} reports {scan.unique_resource_count} unique "
|
||||
f"resources but no ResourceScanSummary rows are persisted; "
|
||||
f"skipping ephemeral reset to avoid wiping valid counts"
|
||||
)
|
||||
return {"status": "skipped", "reason": "summaries missing"}
|
||||
|
||||
# Stays on the primary DB intentionally. ResourceScanSummary rows are
|
||||
# written by perform_prowler_scan in the same chain that triggered this
|
||||
# task, so replica lag could return an empty/partial summary set; a stale
|
||||
# read here would classify every Resource as ephemeral and wipe valid
|
||||
# failed_findings_count values on the primary. Same rationale as
|
||||
# update_provider_compliance_scores below in this module.
|
||||
# Materializing the ID list (rather than streaming the iterator into
|
||||
# batched UPDATEs) is intentional: it lets the UPDATEs run in their own
|
||||
# short rls_transactions instead of one long transaction holding row locks
|
||||
# on every batch. At 500k UUIDs the peak memory is ~40 MB — acceptable for
|
||||
# a Celery worker — and is the better trade-off versus a multi-second
|
||||
# write-lock window blocking concurrent scans.
|
||||
with rls_transaction(tenant_id):
|
||||
in_scan = ResourceScanSummary.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
resource_id=OuterRef("pk"),
|
||||
)
|
||||
ephemeral_ids = list(
|
||||
Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=scan.provider_id,
|
||||
failed_findings_count__gt=0,
|
||||
)
|
||||
.filter(~Exists(in_scan))
|
||||
.values_list("id", flat=True)
|
||||
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
|
||||
)
|
||||
|
||||
if not ephemeral_ids:
|
||||
logger.info(f"No ephemeral resources for scan {scan_id}")
|
||||
return {
|
||||
"status": "completed",
|
||||
"scan_id": str(scan_id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
"reset": 0,
|
||||
}
|
||||
|
||||
total_updated = 0
|
||||
for batch, _ in batched(ephemeral_ids, DJANGO_FINDINGS_BATCH_SIZE):
|
||||
# batched() always yields a final tuple, which is empty when the input
|
||||
# length is an exact multiple of the batch size. Skip it so we don't
|
||||
# issue a no-op UPDATE ... WHERE id IN ().
|
||||
if not batch:
|
||||
continue
|
||||
with rls_transaction(tenant_id):
|
||||
total_updated += Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
id__in=batch,
|
||||
failed_findings_count__gt=0,
|
||||
).update(failed_findings_count=0)
|
||||
|
||||
logger.info(
|
||||
f"Ephemeral resource reset for scan {scan_id}: "
|
||||
f"{total_updated} resources zeroed for provider {scan.provider_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"scan_id": str(scan_id),
|
||||
"provider_id": str(scan.provider_id),
|
||||
"reset": total_updated,
|
||||
}
|
||||
|
||||
+125
-21
@@ -20,8 +20,8 @@ from tasks.jobs.backfill import (
|
||||
backfill_finding_group_summaries,
|
||||
backfill_provider_compliance_scores,
|
||||
backfill_resource_scan_summaries,
|
||||
backfill_scan_category_summaries,
|
||||
backfill_scan_resource_group_summaries,
|
||||
aggregate_scan_category_summaries,
|
||||
aggregate_scan_resource_group_summaries,
|
||||
)
|
||||
from tasks.jobs.connection import (
|
||||
check_integration_connection,
|
||||
@@ -46,7 +46,11 @@ from tasks.jobs.lighthouse_providers import (
|
||||
refresh_lighthouse_provider_models,
|
||||
)
|
||||
from tasks.jobs.muting import mute_historical_findings
|
||||
from tasks.jobs.report import generate_compliance_reports_job
|
||||
from tasks.jobs.report import (
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
_cleanup_stale_tmp_output_directories,
|
||||
generate_compliance_reports_job,
|
||||
)
|
||||
from tasks.jobs.scan import (
|
||||
aggregate_attack_surface,
|
||||
aggregate_daily_severity,
|
||||
@@ -54,6 +58,7 @@ from tasks.jobs.scan import (
|
||||
aggregate_findings,
|
||||
create_compliance_requirements,
|
||||
perform_prowler_scan,
|
||||
reset_ephemeral_resource_findings_count,
|
||||
update_provider_compliance_scores,
|
||||
)
|
||||
from tasks.utils import (
|
||||
@@ -73,6 +78,7 @@ from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@@ -154,6 +160,13 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
|
||||
generate_outputs_task.si(
|
||||
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
|
||||
),
|
||||
# post-scan task — runs in the parallel group so a
|
||||
# failure cannot cascade into reports or integrations. Its only
|
||||
# prerequisite is that perform_prowler_scan has committed
|
||||
# ResourceScanSummary, which is true by the time this chain fires.
|
||||
reset_ephemeral_resource_findings_count_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
),
|
||||
group(
|
||||
# Use optimized task that generates both reports with shared queries
|
||||
@@ -169,10 +182,25 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
|
||||
).apply_async()
|
||||
|
||||
if can_provider_run_attack_paths_scan(tenant_id, provider_id):
|
||||
perform_attack_paths_scan_task.apply_async(
|
||||
# Row is normally created upstream, so this is a safeguard so we can attach the task id below
|
||||
attack_paths_scan = attack_paths_db_utils.retrieve_attack_paths_scan(
|
||||
tenant_id, scan_id
|
||||
)
|
||||
if attack_paths_scan is None:
|
||||
attack_paths_scan = attack_paths_db_utils.create_attack_paths_scan(
|
||||
tenant_id, scan_id, provider_id
|
||||
)
|
||||
|
||||
# Persist the Celery task id so the periodic cleanup can revoke scans stuck in SCHEDULED
|
||||
result = perform_attack_paths_scan_task.apply_async(
|
||||
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
|
||||
)
|
||||
|
||||
if attack_paths_scan and result:
|
||||
attack_paths_db_utils.set_attack_paths_scan_task_id(
|
||||
tenant_id, attack_paths_scan.id, result.task_id
|
||||
)
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="provider-connection-check")
|
||||
@set_tenant
|
||||
@@ -374,7 +402,8 @@ class AttackPathsScanRLSTask(RLSTask):
|
||||
SDK initialization, or Neo4j configuration errors during setup).
|
||||
"""
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, _einfo):
|
||||
def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002
|
||||
del args # Required by Celery's Task.on_failure signature; not used.
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
scan_id = kwargs.get("scan_id")
|
||||
|
||||
@@ -440,6 +469,19 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
scan_id (str): The scan identifier.
|
||||
provider_id (str): The provider_id id to be used in generating outputs.
|
||||
"""
|
||||
try:
|
||||
_cleanup_stale_tmp_output_directories(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
exclude_scan=(tenant_id, scan_id),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Skipping stale tmp cleanup before output generation for scan %s: %s",
|
||||
scan_id,
|
||||
error,
|
||||
)
|
||||
|
||||
# Check if the scan has findings
|
||||
if not ScanSummary.objects.filter(scan_id=scan_id).exists():
|
||||
logger.info(f"No findings found for scan {scan_id}")
|
||||
@@ -659,9 +701,9 @@ def backfill_finding_group_summaries_task(tenant_id: str, days: int = None):
|
||||
return backfill_finding_group_summaries(tenant_id=tenant_id, days=days)
|
||||
|
||||
|
||||
@shared_task(name="backfill-scan-category-summaries", queue="backfill")
|
||||
@shared_task(name="scan-category-summaries", queue="overview")
|
||||
@handle_provider_deletion
|
||||
def backfill_scan_category_summaries_task(tenant_id: str, scan_id: str):
|
||||
def aggregate_scan_category_summaries_task(tenant_id: str, scan_id: str):
|
||||
"""
|
||||
Backfill ScanCategorySummary for a completed scan.
|
||||
|
||||
@@ -671,12 +713,12 @@ def backfill_scan_category_summaries_task(tenant_id: str, scan_id: str):
|
||||
tenant_id (str): The tenant identifier.
|
||||
scan_id (str): The scan identifier.
|
||||
"""
|
||||
return backfill_scan_category_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
return aggregate_scan_category_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
@shared_task(name="backfill-scan-resource-group-summaries", queue="backfill")
|
||||
@shared_task(name="scan-resource-group-summaries", queue="overview")
|
||||
@handle_provider_deletion
|
||||
def backfill_scan_resource_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
def aggregate_scan_resource_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
"""
|
||||
Backfill ScanGroupSummary for a completed scan.
|
||||
|
||||
@@ -686,7 +728,7 @@ def backfill_scan_resource_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
tenant_id (str): The tenant identifier.
|
||||
scan_id (str): The scan identifier.
|
||||
"""
|
||||
return backfill_scan_resource_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
return aggregate_scan_resource_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
@shared_task(name="backfill-provider-compliance-scores", queue="backfill")
|
||||
@@ -758,6 +800,32 @@ def aggregate_daily_severity_task(tenant_id: str, scan_id: str):
|
||||
return aggregate_daily_severity(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
@shared_task(name="scan-reset-ephemeral-resources", queue="overview")
|
||||
@handle_provider_deletion
|
||||
def reset_ephemeral_resource_findings_count_task(tenant_id: str, scan_id: str):
|
||||
"""Reset failed_findings_count for resources missing from a completed full-scope scan.
|
||||
|
||||
Failures are swallowed and returned as a status: this task lives inside the
|
||||
post-scan group, and Celery propagates group-member exceptions into the next
|
||||
chain step — meaning a crash here would block compliance reports and
|
||||
integrations. The reset is purely cosmetic (UI sort optimization), so a
|
||||
bad run is logged and absorbed rather than allowed to cascade.
|
||||
"""
|
||||
try:
|
||||
return reset_ephemeral_resource_findings_count(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — intentionally broad
|
||||
logger.exception(
|
||||
f"reset_ephemeral_resource_findings_count failed for scan {scan_id}: {exc}"
|
||||
)
|
||||
return {
|
||||
"status": "failed",
|
||||
"scan_id": str(scan_id),
|
||||
"reason": str(exc),
|
||||
}
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="scan-finding-group-summaries", queue="overview")
|
||||
@set_tenant(keep_tenant=True)
|
||||
@handle_provider_deletion
|
||||
@@ -771,15 +839,26 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
)
|
||||
@set_tenant(keep_tenant=True)
|
||||
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
|
||||
"""Reaggregate finding group summaries for every (provider, day) combination.
|
||||
"""Reaggregate every pre-aggregated summary table for this tenant.
|
||||
|
||||
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 daily summaries consistent with that update, this task
|
||||
re-runs the aggregator on the latest completed scan of every (provider,
|
||||
day) pair that exists in the database. Tasks are dispatched in parallel
|
||||
via a Celery group so the wallclock scales with the worker pool, not with
|
||||
the number of pairs.
|
||||
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(
|
||||
@@ -804,12 +883,32 @@ def reaggregate_all_finding_group_summaries_task(tenant_id: str):
|
||||
scan_ids = list(latest_scans.values())
|
||||
if scan_ids:
|
||||
logger.info(
|
||||
"Reaggregating finding group summaries for %d scans (provider x day)",
|
||||
"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(
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
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
|
||||
),
|
||||
),
|
||||
)
|
||||
for scan_id in scan_ids
|
||||
).apply_async()
|
||||
@@ -982,13 +1081,17 @@ def jira_integration_task(
|
||||
@handle_provider_deletion
|
||||
def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str):
|
||||
"""
|
||||
Optimized task to generate ThreatScore, ENS, NIS2, and CSA CCM reports with shared queries.
|
||||
Optimized task to generate ThreatScore, ENS, NIS2, CSA CCM and CIS reports with shared queries.
|
||||
|
||||
This task is more efficient than running separate report tasks because it reuses database queries:
|
||||
- Provider object fetched once (instead of multiple times)
|
||||
- Requirement statistics aggregated once (instead of multiple times)
|
||||
- Can reduce database load by up to 50-70%
|
||||
|
||||
CIS emits a single PDF per run: the one matching the highest CIS version
|
||||
available for the scan's provider, picked dynamically from
|
||||
``Compliance.get_bulk`` (no hard-coded provider → version mapping).
|
||||
|
||||
Args:
|
||||
tenant_id (str): The tenant identifier.
|
||||
scan_id (str): The scan identifier.
|
||||
@@ -1005,6 +1108,7 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
|
||||
generate_ens=True,
|
||||
generate_nis2=True,
|
||||
generate_csa=True,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ class TestAttackPathsRun:
|
||||
assert result == ingestion_result
|
||||
mock_retrieve_scan.assert_called_once_with(str(tenant.id), str(scan.id))
|
||||
mock_starting.assert_called_once()
|
||||
config = mock_starting.call_args[0][2]
|
||||
config = mock_starting.call_args[0][1]
|
||||
assert config.neo4j_database == "tenant-db"
|
||||
mock_get_db_name.assert_has_calls(
|
||||
[call(attack_paths_scan.id, temporary=True), call(provider.tenant_id)]
|
||||
@@ -1285,6 +1285,12 @@ class TestAttackPathsFindingsHelpers:
|
||||
config = SimpleNamespace(update_tag=12345)
|
||||
mock_session = MagicMock()
|
||||
|
||||
first_result = MagicMock()
|
||||
first_result.single.return_value = {"merged_count": 1, "dropped_count": 0}
|
||||
second_result = MagicMock()
|
||||
second_result.single.return_value = {"merged_count": 0, "dropped_count": 1}
|
||||
mock_session.run.side_effect = [first_result, second_result]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.findings.get_node_uid_field",
|
||||
@@ -1294,6 +1300,7 @@ class TestAttackPathsFindingsHelpers:
|
||||
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
|
||||
return_value="_AWSResource",
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.findings.logger") as mock_logger,
|
||||
):
|
||||
findings_module.load_findings(
|
||||
mock_session, findings_generator(), provider, config
|
||||
@@ -1305,6 +1312,14 @@ class TestAttackPathsFindingsHelpers:
|
||||
assert params["last_updated"] == config.update_tag
|
||||
assert "findings_data" in params
|
||||
|
||||
summary_log = next(
|
||||
call_args.args[0]
|
||||
for call_args in mock_logger.info.call_args_list
|
||||
if call_args.args and "Finished loading" in call_args.args[0]
|
||||
)
|
||||
assert "edges_merged=1" in summary_log
|
||||
assert "edges_dropped=1" in summary_log
|
||||
|
||||
def test_stream_findings_with_resources_returns_latest_scan_data(
|
||||
self,
|
||||
tenants_fixture,
|
||||
@@ -1484,11 +1499,12 @@ class TestAttackPathsFindingsHelpers:
|
||||
"default",
|
||||
):
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
[finding_dict], str(tenant.id), lambda uid: f"short:{uid}"
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["resource_uid"] == resource.uid
|
||||
assert result[0]["resource_short_uid"] == f"short:{resource.uid}"
|
||||
assert result[0]["id"] == str(finding.id)
|
||||
assert result[0]["status"] == "FAIL"
|
||||
|
||||
@@ -1572,7 +1588,7 @@ class TestAttackPathsFindingsHelpers:
|
||||
"default",
|
||||
):
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
[finding_dict], str(tenant.id), lambda uid: uid
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
@@ -1646,7 +1662,7 @@ class TestAttackPathsFindingsHelpers:
|
||||
patch("tasks.jobs.attack_paths.findings.logger") as mock_logger,
|
||||
):
|
||||
result = findings_module._enrich_batch_with_resources(
|
||||
[finding_dict], str(tenant.id)
|
||||
[finding_dict], str(tenant.id), lambda uid: uid
|
||||
)
|
||||
|
||||
assert len(result) == 0
|
||||
@@ -1693,6 +1709,63 @@ class TestAttackPathsFindingsHelpers:
|
||||
|
||||
mock_session.run.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uid, expected",
|
||||
[
|
||||
(
|
||||
"arn:aws:ec2:us-east-1:552455647653:instance/i-05075b63eb51baacb",
|
||||
"i-05075b63eb51baacb",
|
||||
),
|
||||
(
|
||||
"arn:aws:ec2:us-east-1:123456789012:volume/vol-0abcd1234ef567890",
|
||||
"vol-0abcd1234ef567890",
|
||||
),
|
||||
(
|
||||
"arn:aws:ec2:us-east-1:123456789012:security-group/sg-0123abcd",
|
||||
"sg-0123abcd",
|
||||
),
|
||||
("arn:aws:s3:::my-bucket-name", "my-bucket-name"),
|
||||
("arn:aws:iam::123456789012:role/MyRole", "MyRole"),
|
||||
(
|
||||
"arn:aws:lambda:us-east-1:123456789012:function:my-function",
|
||||
"my-function",
|
||||
),
|
||||
("i-05075b63eb51baacb", "i-05075b63eb51baacb"),
|
||||
],
|
||||
)
|
||||
def test_extract_short_uid_aws_variants(self, uid, expected):
|
||||
from tasks.jobs.attack_paths.aws import extract_short_uid
|
||||
|
||||
assert extract_short_uid(uid) == expected
|
||||
|
||||
def test_insert_finding_template_has_short_id_fallback(self):
|
||||
from tasks.jobs.attack_paths.queries import (
|
||||
INSERT_FINDING_TEMPLATE,
|
||||
render_cypher_template,
|
||||
)
|
||||
|
||||
rendered = render_cypher_template(
|
||||
INSERT_FINDING_TEMPLATE,
|
||||
{
|
||||
"__NODE_UID_FIELD__": "arn",
|
||||
"__RESOURCE_LABEL__": "_AWSResource",
|
||||
},
|
||||
)
|
||||
|
||||
assert (
|
||||
"resource_by_uid:_AWSResource {arn: finding_data.resource_uid}" in rendered
|
||||
)
|
||||
assert "resource_by_id:_AWSResource {id: finding_data.resource_uid}" in rendered
|
||||
assert (
|
||||
"resource_by_short:_AWSResource {id: finding_data.resource_short_uid}"
|
||||
in rendered
|
||||
)
|
||||
assert "head(collect(resource_by_short)) AS resource_by_short" in rendered
|
||||
assert (
|
||||
"COALESCE(resource_by_uid, resource_by_id, resource_by_short)" in rendered
|
||||
)
|
||||
assert "RETURN merged_count, dropped_count" in rendered
|
||||
|
||||
|
||||
class TestAddResourceLabel:
|
||||
def test_add_resource_label_applies_private_label(self):
|
||||
@@ -2659,3 +2732,143 @@ class TestCleanupStaleAttackPathsScans:
|
||||
assert result["cleaned_up_count"] == 2
|
||||
# Worker should be pinged exactly once — cache prevents second ping
|
||||
mock_alive.assert_called_once_with("shared-worker@host")
|
||||
|
||||
# `SCHEDULED` state cleanup
|
||||
def _create_scheduled_scan(
|
||||
self,
|
||||
tenant,
|
||||
provider,
|
||||
*,
|
||||
age_minutes,
|
||||
parent_state,
|
||||
with_task=True,
|
||||
):
|
||||
"""Create a SCHEDULED AttackPathsScan with a parent Scan in `parent_state`.
|
||||
|
||||
`age_minutes` controls how far in the past `started_at` is set, so
|
||||
callers can place rows safely past the cleanup cutoff.
|
||||
"""
|
||||
parent_scan = Scan.objects.create(
|
||||
name="Parent Prowler scan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=parent_state,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
ap_scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=parent_scan,
|
||||
state=StateChoices.SCHEDULED,
|
||||
started_at=datetime.now(tz=timezone.utc) - timedelta(minutes=age_minutes),
|
||||
)
|
||||
|
||||
task_result = None
|
||||
if with_task:
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(ap_scan.id),
|
||||
task_name="attack-paths-scan-perform",
|
||||
status="PENDING",
|
||||
)
|
||||
task = Task.objects.create(
|
||||
id=task_result.task_id,
|
||||
task_runner_task=task_result,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
ap_scan.task = task
|
||||
ap_scan.save(update_fields=["task_id"])
|
||||
|
||||
return ap_scan, task_result
|
||||
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.cleanup.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup._revoke_task")
|
||||
def test_cleans_up_scheduled_scan_when_parent_is_terminal(
|
||||
self,
|
||||
mock_revoke,
|
||||
mock_drop_db,
|
||||
mock_recover,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
|
||||
ap_scan, task_result = self._create_scheduled_scan(
|
||||
tenant,
|
||||
provider,
|
||||
age_minutes=24 * 60 * 3, # 3 days, safely past any threshold
|
||||
parent_state=StateChoices.FAILED,
|
||||
)
|
||||
|
||||
result = cleanup_stale_attack_paths_scans()
|
||||
|
||||
assert result["cleaned_up_count"] == 1
|
||||
assert str(ap_scan.id) in result["scan_ids"]
|
||||
|
||||
ap_scan.refresh_from_db()
|
||||
assert ap_scan.state == StateChoices.FAILED
|
||||
assert ap_scan.progress == 100
|
||||
assert ap_scan.completed_at is not None
|
||||
assert ap_scan.ingestion_exceptions == {
|
||||
"global_error": "Scan never started — cleaned up by periodic task"
|
||||
}
|
||||
|
||||
# SCHEDULED revoke must NOT terminate a running worker
|
||||
mock_revoke.assert_called_once()
|
||||
assert mock_revoke.call_args.kwargs == {"terminate": False}
|
||||
|
||||
# Temp DB never created for SCHEDULED, so no drop attempted
|
||||
mock_drop_db.assert_not_called()
|
||||
# Tenant Neo4j data is untouched in this path
|
||||
mock_recover.assert_not_called()
|
||||
|
||||
task_result.refresh_from_db()
|
||||
assert task_result.status == "FAILURE"
|
||||
assert task_result.date_done is not None
|
||||
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.cleanup.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup._revoke_task")
|
||||
def test_skips_scheduled_scan_when_parent_still_in_flight(
|
||||
self,
|
||||
mock_revoke,
|
||||
mock_drop_db,
|
||||
mock_recover,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
|
||||
ap_scan, _ = self._create_scheduled_scan(
|
||||
tenant,
|
||||
provider,
|
||||
age_minutes=24 * 60 * 3,
|
||||
parent_state=StateChoices.EXECUTING,
|
||||
)
|
||||
|
||||
result = cleanup_stale_attack_paths_scans()
|
||||
|
||||
assert result["cleaned_up_count"] == 0
|
||||
|
||||
ap_scan.refresh_from_db()
|
||||
assert ap_scan.state == StateChoices.SCHEDULED
|
||||
mock_revoke.assert_not_called()
|
||||
|
||||
@@ -7,8 +7,8 @@ from tasks.jobs.backfill import (
|
||||
backfill_compliance_summaries,
|
||||
backfill_provider_compliance_scores,
|
||||
backfill_resource_scan_summaries,
|
||||
backfill_scan_category_summaries,
|
||||
backfill_scan_resource_group_summaries,
|
||||
aggregate_scan_category_summaries,
|
||||
aggregate_scan_resource_group_summaries,
|
||||
)
|
||||
|
||||
from api.models import (
|
||||
@@ -183,6 +183,10 @@ class TestBackfillComplianceSummaries:
|
||||
def test_backfill_creates_compliance_summaries(
|
||||
self, tenants_fixture, scans_fixture, compliance_requirements_overviews_fixture
|
||||
):
|
||||
# Fixture seeds compliance rows the backfill aggregates over; pytest
|
||||
# injects it by parameter name, so we reference it explicitly here
|
||||
# to keep static analysers from flagging it as unused.
|
||||
del compliance_requirements_overviews_fixture
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
@@ -227,22 +231,86 @@ class TestBackfillComplianceSummaries:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBackfillScanCategorySummaries:
|
||||
def test_already_backfilled(self, scan_category_summary_fixture):
|
||||
def test_rerun_with_no_findings_is_noop(self, scan_category_summary_fixture):
|
||||
"""When the scan has no findings, the backfill is a no-op: it
|
||||
reports `no categories to backfill` and leaves the table
|
||||
untouched. The upsert path cannot drop rows it does not produce,
|
||||
so any pre-existing row survives (matching the scan-completion
|
||||
writer that used `ignore_conflicts=True`)."""
|
||||
tenant_id = scan_category_summary_fixture.tenant_id
|
||||
scan_id = scan_category_summary_fixture.scan_id
|
||||
|
||||
result = backfill_scan_category_summaries(str(tenant_id), str(scan_id))
|
||||
result = aggregate_scan_category_summaries(str(tenant_id), str(scan_id))
|
||||
|
||||
assert result == {"status": "already backfilled"}
|
||||
assert result == {"status": "no categories to backfill"}
|
||||
assert ScanCategorySummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id, category="existing-category"
|
||||
).exists()
|
||||
|
||||
def test_rerun_upserts_without_duplicating(self, findings_with_categories_fixture):
|
||||
"""Calling the backfill twice upserts rather than raising on
|
||||
`unique_category_severity_per_scan`; rows are updated in place
|
||||
(same primary keys)."""
|
||||
finding = findings_with_categories_fixture
|
||||
tenant_id = str(finding.tenant_id)
|
||||
scan_id = str(finding.scan_id)
|
||||
|
||||
aggregate_scan_category_summaries(tenant_id, scan_id)
|
||||
first_ids = set(
|
||||
ScanCategorySummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
aggregate_scan_category_summaries(tenant_id, scan_id)
|
||||
second_ids = set(
|
||||
ScanCategorySummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
assert first_ids == second_ids
|
||||
assert len(first_ids) == 2 # 2 categories x 1 severity
|
||||
|
||||
def test_rerun_reflects_mute_between_runs(self, findings_with_categories_fixture):
|
||||
"""Muting a finding between two backfill runs must move counters:
|
||||
`failed_findings` and `new_failed_findings` drop to zero (muted
|
||||
findings are excluded from those totals). Guards against a
|
||||
regression where the upsert keeps stale counts from the first run."""
|
||||
finding = findings_with_categories_fixture
|
||||
tenant_id = str(finding.tenant_id)
|
||||
scan_id = str(finding.scan_id)
|
||||
|
||||
aggregate_scan_category_summaries(tenant_id, scan_id)
|
||||
before = list(
|
||||
ScanCategorySummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
)
|
||||
assert all(s.failed_findings == 1 for s in before)
|
||||
assert all(s.new_failed_findings == 1 for s in before)
|
||||
assert all(s.total_findings == 1 for s in before)
|
||||
|
||||
Finding.all_objects.filter(pk=finding.pk).update(muted=True)
|
||||
|
||||
aggregate_scan_category_summaries(tenant_id, scan_id)
|
||||
after = list(
|
||||
ScanCategorySummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
)
|
||||
|
||||
assert {s.id for s in after} == {s.id for s in before}
|
||||
assert all(s.failed_findings == 0 for s in after)
|
||||
assert all(s.new_failed_findings == 0 for s in after)
|
||||
assert all(s.total_findings == 0 for s in after)
|
||||
|
||||
def test_not_completed_scan(self, get_not_completed_scans):
|
||||
for scan in get_not_completed_scans:
|
||||
result = backfill_scan_category_summaries(str(scan.tenant_id), str(scan.id))
|
||||
result = aggregate_scan_category_summaries(
|
||||
str(scan.tenant_id), str(scan.id)
|
||||
)
|
||||
assert result == {"status": "scan is not completed"}
|
||||
|
||||
def test_no_categories_to_backfill(self, scans_fixture):
|
||||
scan = scans_fixture[1] # Failed scan with no findings
|
||||
result = backfill_scan_category_summaries(str(scan.tenant_id), str(scan.id))
|
||||
result = aggregate_scan_category_summaries(str(scan.tenant_id), str(scan.id))
|
||||
assert result == {"status": "no categories to backfill"}
|
||||
|
||||
def test_successful_backfill(self, findings_with_categories_fixture):
|
||||
@@ -250,7 +318,7 @@ class TestBackfillScanCategorySummaries:
|
||||
tenant_id = str(finding.tenant_id)
|
||||
scan_id = str(finding.scan_id)
|
||||
|
||||
result = backfill_scan_category_summaries(tenant_id, scan_id)
|
||||
result = aggregate_scan_category_summaries(tenant_id, scan_id)
|
||||
|
||||
# 2 categories × 1 severity = 2 rows
|
||||
assert result == {"status": "backfilled", "categories_count": 2}
|
||||
@@ -311,24 +379,87 @@ def scan_resource_group_summary_fixture(scans_fixture):
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBackfillScanGroupSummaries:
|
||||
def test_already_backfilled(self, scan_resource_group_summary_fixture):
|
||||
def test_rerun_with_no_findings_is_noop(self, scan_resource_group_summary_fixture):
|
||||
"""When the scan has no findings, the backfill is a no-op: it
|
||||
reports `no resource groups to backfill` and leaves the table
|
||||
untouched. The upsert path cannot drop rows it does not produce,
|
||||
so any pre-existing row survives (matching the scan-completion
|
||||
writer that used `ignore_conflicts=True`)."""
|
||||
tenant_id = scan_resource_group_summary_fixture.tenant_id
|
||||
scan_id = scan_resource_group_summary_fixture.scan_id
|
||||
|
||||
result = backfill_scan_resource_group_summaries(str(tenant_id), str(scan_id))
|
||||
result = aggregate_scan_resource_group_summaries(str(tenant_id), str(scan_id))
|
||||
|
||||
assert result == {"status": "already backfilled"}
|
||||
assert result == {"status": "no resource groups to backfill"}
|
||||
assert ScanGroupSummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id, resource_group="existing-group"
|
||||
).exists()
|
||||
|
||||
def test_rerun_upserts_without_duplicating(self, findings_with_group_fixture):
|
||||
"""Calling the backfill twice upserts rather than raising on
|
||||
`unique_resource_group_severity_per_scan`; rows are updated in
|
||||
place (same primary keys)."""
|
||||
finding = findings_with_group_fixture
|
||||
tenant_id = str(finding.tenant_id)
|
||||
scan_id = str(finding.scan_id)
|
||||
|
||||
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
|
||||
first_ids = set(
|
||||
ScanGroupSummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
|
||||
second_ids = set(
|
||||
ScanGroupSummary.objects.filter(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
assert first_ids == second_ids
|
||||
assert len(first_ids) == 1 # 1 resource group x 1 severity
|
||||
|
||||
def test_rerun_reflects_mute_between_runs(self, findings_with_group_fixture):
|
||||
"""Muting a finding between two backfill runs must move counters:
|
||||
`failed_findings` and `new_failed_findings` drop to zero (muted
|
||||
findings are excluded from those totals). Guards against a
|
||||
regression where the upsert keeps stale counts from the first run."""
|
||||
finding = findings_with_group_fixture
|
||||
tenant_id = str(finding.tenant_id)
|
||||
scan_id = str(finding.scan_id)
|
||||
|
||||
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
|
||||
before = list(
|
||||
ScanGroupSummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
)
|
||||
assert len(before) == 1
|
||||
assert before[0].failed_findings == 1
|
||||
assert before[0].new_failed_findings == 1
|
||||
assert before[0].total_findings == 1
|
||||
|
||||
Finding.all_objects.filter(pk=finding.pk).update(muted=True)
|
||||
|
||||
aggregate_scan_resource_group_summaries(tenant_id, scan_id)
|
||||
after = list(
|
||||
ScanGroupSummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
|
||||
)
|
||||
|
||||
assert {s.id for s in after} == {s.id for s in before}
|
||||
assert after[0].failed_findings == 0
|
||||
assert after[0].new_failed_findings == 0
|
||||
assert after[0].total_findings == 0
|
||||
|
||||
def test_not_completed_scan(self, get_not_completed_scans):
|
||||
for scan in get_not_completed_scans:
|
||||
result = backfill_scan_resource_group_summaries(
|
||||
result = aggregate_scan_resource_group_summaries(
|
||||
str(scan.tenant_id), str(scan.id)
|
||||
)
|
||||
assert result == {"status": "scan is not completed"}
|
||||
|
||||
def test_no_resource_groups_to_backfill(self, scans_fixture):
|
||||
scan = scans_fixture[1] # Failed scan with no findings
|
||||
result = backfill_scan_resource_group_summaries(
|
||||
result = aggregate_scan_resource_group_summaries(
|
||||
str(scan.tenant_id), str(scan.id)
|
||||
)
|
||||
assert result == {"status": "no resource groups to backfill"}
|
||||
@@ -338,7 +469,7 @@ class TestBackfillScanGroupSummaries:
|
||||
tenant_id = str(finding.tenant_id)
|
||||
scan_id = str(finding.scan_id)
|
||||
|
||||
result = backfill_scan_resource_group_summaries(tenant_id, scan_id)
|
||||
result = aggregate_scan_resource_group_summaries(tenant_id, scan_id)
|
||||
|
||||
# 1 resource group × 1 severity = 1 row
|
||||
assert result == {"status": "backfilled", "resource_groups_count": 1}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import matplotlib
|
||||
import pytest
|
||||
from reportlab.lib import colors
|
||||
from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report
|
||||
from tasks.jobs.report import (
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
STALE_TMP_OUTPUT_LOCK_FILE_NAME,
|
||||
_cleanup_stale_tmp_output_directories,
|
||||
_is_scan_directory_protected,
|
||||
_pick_latest_cis_variant,
|
||||
_should_run_stale_cleanup,
|
||||
generate_compliance_reports,
|
||||
generate_threatscore_report,
|
||||
)
|
||||
from tasks.jobs.reports import (
|
||||
CHART_COLOR_GREEN_1,
|
||||
CHART_COLOR_GREEN_2,
|
||||
@@ -29,7 +40,13 @@ from tasks.jobs.threatscore_utils import (
|
||||
_load_findings_for_requirement_checks,
|
||||
)
|
||||
|
||||
from api.models import Finding, Resource, ResourceFindingMapping, StatusChoices
|
||||
from api.models import (
|
||||
Finding,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
)
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
matplotlib.use("Agg") # Use non-interactive backend for tests
|
||||
@@ -351,6 +368,366 @@ class TestLoadFindingsForChecks:
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestCleanupStaleTmpOutputDirectories:
|
||||
"""Unit tests for opportunistic stale cleanup under tmp output root."""
|
||||
|
||||
def test_removes_only_scan_dirs_older_than_ttl(self, tmp_path, monkeypatch):
|
||||
"""Should remove stale scan directories and keep recent ones."""
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
|
||||
old_scan_dir = root_dir / "tenant-a" / "scan-old"
|
||||
old_scan_dir.mkdir(parents=True)
|
||||
(old_scan_dir / "artifact.txt").write_text("old")
|
||||
|
||||
recent_scan_dir = root_dir / "tenant-a" / "scan-recent"
|
||||
recent_scan_dir.mkdir(parents=True)
|
||||
(recent_scan_dir / "artifact.txt").write_text("recent")
|
||||
|
||||
now = time.time()
|
||||
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
|
||||
os.utime(old_scan_dir, (stale_ts, stale_ts))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
|
||||
)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(
|
||||
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
|
||||
)
|
||||
|
||||
assert removed == 1
|
||||
assert not old_scan_dir.exists()
|
||||
assert recent_scan_dir.exists()
|
||||
|
||||
def test_skips_current_scan_even_when_stale(self, tmp_path, monkeypatch):
|
||||
"""Should not delete stale directory for the currently processed scan."""
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
|
||||
current_scan_dir = root_dir / "tenant-current" / "scan-current"
|
||||
current_scan_dir.mkdir(parents=True)
|
||||
(current_scan_dir / "artifact.txt").write_text("current")
|
||||
|
||||
other_stale_scan_dir = root_dir / "tenant-other" / "scan-old"
|
||||
other_stale_scan_dir.mkdir(parents=True)
|
||||
(other_stale_scan_dir / "artifact.txt").write_text("other")
|
||||
|
||||
now = time.time()
|
||||
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
|
||||
os.utime(current_scan_dir, (stale_ts, stale_ts))
|
||||
os.utime(other_stale_scan_dir, (stale_ts, stale_ts))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
|
||||
)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(
|
||||
str(root_dir),
|
||||
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
exclude_scan=("tenant-current", "scan-current"),
|
||||
)
|
||||
|
||||
assert removed == 1
|
||||
assert current_scan_dir.exists()
|
||||
assert not other_stale_scan_dir.exists()
|
||||
|
||||
def test_respects_max_deletions_per_run(self, tmp_path, monkeypatch):
|
||||
"""Cleanup should stop deleting when max_deletions_per_run is reached."""
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
|
||||
stale_dir_1 = root_dir / "tenant-a" / "scan-old-1"
|
||||
stale_dir_2 = root_dir / "tenant-a" / "scan-old-2"
|
||||
stale_dir_1.mkdir(parents=True)
|
||||
stale_dir_2.mkdir(parents=True)
|
||||
(stale_dir_1 / "artifact.txt").write_text("old-1")
|
||||
(stale_dir_2 / "artifact.txt").write_text("old-2")
|
||||
|
||||
now = time.time()
|
||||
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
|
||||
os.utime(stale_dir_1, (stale_ts, stale_ts))
|
||||
os.utime(stale_dir_2, (stale_ts, stale_ts))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
|
||||
)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(
|
||||
str(root_dir),
|
||||
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
max_deletions_per_run=1,
|
||||
)
|
||||
|
||||
assert removed == 1
|
||||
remaining = sum(
|
||||
1 for scan_dir in (stale_dir_1, stale_dir_2) if scan_dir.exists()
|
||||
)
|
||||
assert remaining == 1
|
||||
|
||||
def test_rejects_non_safe_root(self, tmp_path, monkeypatch):
|
||||
"""Cleanup must no-op when called with a root outside the allowed safe root."""
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
root_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT",
|
||||
(tmp_path / "another-root").resolve(),
|
||||
)
|
||||
|
||||
def _fail_should_run(*_args, **_kwargs):
|
||||
raise AssertionError("_should_run_stale_cleanup should not be called")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", _fail_should_run
|
||||
)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(str(root_dir), max_age_hours=48)
|
||||
|
||||
assert removed == 0
|
||||
|
||||
def test_ignores_symlink_scan_directories(self, tmp_path, monkeypatch):
|
||||
"""Symlinked scan directories must never be deleted by cleanup."""
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
stale_real_scan_dir = root_dir / "tenant-a" / "scan-old-real"
|
||||
stale_real_scan_dir.mkdir(parents=True)
|
||||
(stale_real_scan_dir / "artifact.txt").write_text("old")
|
||||
|
||||
symlink_target = tmp_path / "symlink-target"
|
||||
symlink_target.mkdir(parents=True)
|
||||
(symlink_target / "artifact.txt").write_text("target")
|
||||
symlink_scan_dir = root_dir / "tenant-a" / "scan-link"
|
||||
symlink_scan_dir.symlink_to(symlink_target, target_is_directory=True)
|
||||
|
||||
now = time.time()
|
||||
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
|
||||
os.utime(stale_real_scan_dir, (stale_ts, stale_ts))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
|
||||
)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(
|
||||
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
|
||||
)
|
||||
|
||||
assert removed == 1
|
||||
assert not stale_real_scan_dir.exists()
|
||||
assert symlink_scan_dir.exists()
|
||||
assert symlink_target.exists()
|
||||
|
||||
def test_handles_internal_exception_without_propagating(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Cleanup errors must be swallowed so callers are not interrupted."""
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
stale_scan_dir = root_dir / "tenant-a" / "scan-old"
|
||||
stale_scan_dir.mkdir(parents=True)
|
||||
|
||||
now = time.time()
|
||||
stale_ts = now - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
|
||||
os.utime(stale_scan_dir, (stale_ts, stale_ts))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", root_dir.resolve()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
|
||||
)
|
||||
|
||||
def _raise(*_args, **_kwargs):
|
||||
raise RuntimeError("db timeout")
|
||||
|
||||
monkeypatch.setattr("tasks.jobs.report._is_scan_directory_protected", _raise)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(
|
||||
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
|
||||
)
|
||||
|
||||
assert removed == 0
|
||||
assert stale_scan_dir.exists()
|
||||
|
||||
def test_safe_root_follows_custom_tmp_output_directory(self, tmp_path, monkeypatch):
|
||||
"""Custom DJANGO_TMP_OUTPUT_DIRECTORY must be honored as the safe root."""
|
||||
from tasks.jobs import report as report_module
|
||||
|
||||
custom_root = tmp_path / "custom_tmp_output"
|
||||
custom_root.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
report_module, "DJANGO_TMP_OUTPUT_DIRECTORY", str(custom_root)
|
||||
)
|
||||
|
||||
resolved_root = report_module._resolve_stale_tmp_safe_root()
|
||||
assert resolved_root == custom_root.resolve()
|
||||
|
||||
stale_scan_dir = custom_root / "tenant-a" / "scan-old"
|
||||
stale_scan_dir.mkdir(parents=True)
|
||||
(stale_scan_dir / "artifact.txt").write_text("old")
|
||||
|
||||
stale_ts = time.time() - ((STALE_TMP_OUTPUT_MAX_AGE_HOURS + 1) * 60 * 60)
|
||||
os.utime(stale_scan_dir, (stale_ts, stale_ts))
|
||||
|
||||
monkeypatch.setattr(report_module, "STALE_TMP_OUTPUT_SAFE_ROOT", resolved_root)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", lambda *_: True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._is_scan_directory_protected", lambda **_: False
|
||||
)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(
|
||||
str(custom_root), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
|
||||
)
|
||||
|
||||
assert removed == 1
|
||||
assert not stale_scan_dir.exists()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"forbidden_root",
|
||||
["/", "/tmp", "/var", "/var/tmp", "/home", "/root", "/etc", "/usr"],
|
||||
)
|
||||
def test_safe_root_rejects_forbidden_system_roots(
|
||||
self, forbidden_root, monkeypatch
|
||||
):
|
||||
"""Cleanup must refuse to operate against shared system roots."""
|
||||
from tasks.jobs import report as report_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
report_module, "DJANGO_TMP_OUTPUT_DIRECTORY", forbidden_root
|
||||
)
|
||||
|
||||
assert report_module._resolve_stale_tmp_safe_root() is None
|
||||
|
||||
def test_skips_cleanup_when_safe_root_is_none(self, tmp_path, monkeypatch):
|
||||
"""A None safe root (forbidden config) must short-circuit the cleanup."""
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
root_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr("tasks.jobs.report.STALE_TMP_OUTPUT_SAFE_ROOT", None)
|
||||
|
||||
def _fail_should_run(*_args, **_kwargs):
|
||||
raise AssertionError("_should_run_stale_cleanup should not be called")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.report._should_run_stale_cleanup", _fail_should_run
|
||||
)
|
||||
|
||||
removed = _cleanup_stale_tmp_output_directories(
|
||||
str(root_dir), max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS
|
||||
)
|
||||
|
||||
assert removed == 0
|
||||
|
||||
|
||||
class TestStaleCleanupProtectionHelpers:
|
||||
"""Unit tests for stale cleanup helper guard logic."""
|
||||
|
||||
def test_should_run_cleanup_is_throttled(self, tmp_path):
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
root_dir.mkdir(parents=True)
|
||||
|
||||
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is True
|
||||
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is False
|
||||
|
||||
lock_file = root_dir / STALE_TMP_OUTPUT_LOCK_FILE_NAME
|
||||
lock_file.write_text(str(int(time.time()) - 7200), encoding="ascii")
|
||||
|
||||
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is True
|
||||
|
||||
@patch("tasks.jobs.report.fcntl.flock", side_effect=BlockingIOError)
|
||||
def test_should_run_cleanup_returns_false_when_lock_is_busy(
|
||||
self, _mock_flock, tmp_path
|
||||
):
|
||||
root_dir = tmp_path / "prowler_api_output"
|
||||
root_dir.mkdir(parents=True)
|
||||
|
||||
assert _should_run_stale_cleanup(root_dir, throttle_seconds=3600) is False
|
||||
|
||||
@patch("tasks.jobs.report.Scan.all_objects.using")
|
||||
def test_is_scan_directory_protected_for_executing_scan(
|
||||
self, mock_scan_using, tmp_path
|
||||
):
|
||||
scan_id = str(uuid.uuid4())
|
||||
scan_path = tmp_path / scan_id
|
||||
scan_path.mkdir(parents=True)
|
||||
mock_scan_using.return_value.filter.return_value.only.return_value.first.return_value = Mock(
|
||||
state=StateChoices.EXECUTING, output_location=None
|
||||
)
|
||||
|
||||
assert (
|
||||
_is_scan_directory_protected(
|
||||
tenant_id="tenant-a",
|
||||
scan_id=scan_id,
|
||||
scan_path=scan_path,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.report.Scan.all_objects.using")
|
||||
def test_is_scan_directory_protected_for_local_output(
|
||||
self, mock_scan_using, tmp_path
|
||||
):
|
||||
scan_id = str(uuid.uuid4())
|
||||
scan_path = tmp_path / scan_id
|
||||
scan_path.mkdir(parents=True)
|
||||
local_output_path = scan_path / "outputs.zip"
|
||||
mock_scan_using.return_value.filter.return_value.only.return_value.first.return_value = Mock(
|
||||
state=StateChoices.COMPLETED, output_location=str(local_output_path)
|
||||
)
|
||||
|
||||
assert (
|
||||
_is_scan_directory_protected(
|
||||
tenant_id="tenant-a",
|
||||
scan_id=scan_id,
|
||||
scan_path=scan_path.resolve(),
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.report.Scan.all_objects.using")
|
||||
def test_is_scan_directory_not_protected_for_s3_output(
|
||||
self, mock_scan_using, tmp_path
|
||||
):
|
||||
scan_id = str(uuid.uuid4())
|
||||
scan_path = tmp_path / scan_id
|
||||
scan_path.mkdir(parents=True)
|
||||
mock_scan_using.return_value.filter.return_value.only.return_value.first.return_value = Mock(
|
||||
state=StateChoices.COMPLETED,
|
||||
output_location="s3://bucket/path/report.zip",
|
||||
)
|
||||
|
||||
assert (
|
||||
_is_scan_directory_protected(
|
||||
tenant_id="tenant-a",
|
||||
scan_id=scan_id,
|
||||
scan_path=scan_path,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateThreatscoreReportFunction:
|
||||
"""Test suite for generate_threatscore_report function."""
|
||||
@@ -422,6 +799,425 @@ class TestGenerateComplianceReportsOptimized:
|
||||
mock_ens.assert_not_called()
|
||||
mock_nis2.assert_not_called()
|
||||
|
||||
@patch(
|
||||
"tasks.jobs.report._cleanup_stale_tmp_output_directories",
|
||||
side_effect=RuntimeError("cleanup boom"),
|
||||
)
|
||||
def test_cleanup_exception_does_not_break_no_findings_flow(self, _mock_cleanup):
|
||||
"""Unexpected cleanup failures must not abort report generation."""
|
||||
random_tenant = str(uuid.uuid4())
|
||||
random_scan = str(uuid.uuid4())
|
||||
random_provider = str(uuid.uuid4())
|
||||
|
||||
with patch("tasks.jobs.report.ScanSummary.objects.filter") as mock_filter:
|
||||
mock_filter.return_value.exists.return_value = False
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=random_tenant,
|
||||
scan_id=random_scan,
|
||||
provider_id=random_provider,
|
||||
generate_threatscore=True,
|
||||
generate_ens=False,
|
||||
generate_nis2=False,
|
||||
generate_csa=False,
|
||||
generate_cis=False,
|
||||
)
|
||||
|
||||
assert result["threatscore"] == {"upload": False, "path": ""}
|
||||
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_cis_report")
|
||||
def test_no_findings_returns_flat_cis_entry(
|
||||
self,
|
||||
mock_cis,
|
||||
mock_upload,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Scan with no findings and ``generate_cis=True`` must yield a flat
|
||||
``{"upload": False, "path": ""}`` entry, consistent with the other
|
||||
frameworks (no nested dict, no sentinel keys)."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(provider.id),
|
||||
generate_threatscore=False,
|
||||
generate_ens=False,
|
||||
generate_nis2=False,
|
||||
generate_csa=False,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
assert result["cis"] == {"upload": False, "path": ""}
|
||||
mock_cis.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.report.rmtree")
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_threatscore_report")
|
||||
@patch("tasks.jobs.report._generate_compliance_output_directory")
|
||||
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
|
||||
@patch("tasks.jobs.report.Compliance.get_bulk")
|
||||
@patch("tasks.jobs.report.Provider.objects.get")
|
||||
@patch("tasks.jobs.report.ScanSummary.objects.filter")
|
||||
def test_cleanup_runs_when_supported_reports_upload_successfully(
|
||||
self,
|
||||
mock_scan_summary_filter,
|
||||
mock_provider_get,
|
||||
mock_get_bulk,
|
||||
mock_aggregate_stats,
|
||||
mock_generate_output_dir,
|
||||
mock_threatscore,
|
||||
mock_upload_to_s3,
|
||||
mock_rmtree,
|
||||
):
|
||||
"""Cleanup must run when all generated (supported) reports are uploaded."""
|
||||
mock_scan_summary_filter.return_value.exists.return_value = True
|
||||
mock_provider_get.return_value = Mock(uid="provider-uid", provider="m365")
|
||||
mock_get_bulk.return_value = {}
|
||||
mock_aggregate_stats.return_value = {}
|
||||
mock_generate_output_dir.return_value = (
|
||||
"/tmp/tenant/scan/threatscore/prowler-output-provider-20240101000000"
|
||||
)
|
||||
mock_upload_to_s3.return_value = (
|
||||
"s3://bucket/tenant/scan/threatscore/report.pdf"
|
||||
)
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
scan_id=str(uuid.uuid4()),
|
||||
provider_id=str(uuid.uuid4()),
|
||||
generate_threatscore=True,
|
||||
generate_ens=True,
|
||||
generate_nis2=True,
|
||||
generate_csa=True,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
assert result["threatscore"]["upload"] is True
|
||||
assert result["ens"]["upload"] is False
|
||||
assert result["nis2"]["upload"] is False
|
||||
assert result["csa"]["upload"] is False
|
||||
assert result["cis"] == {"upload": False, "path": ""}
|
||||
mock_generate_output_dir.assert_called_once()
|
||||
mock_threatscore.assert_called_once()
|
||||
mock_rmtree.assert_called_once()
|
||||
|
||||
@patch("tasks.jobs.report.rmtree")
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_threatscore_report")
|
||||
@patch("tasks.jobs.report._generate_compliance_output_directory")
|
||||
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
|
||||
@patch("tasks.jobs.report.Compliance.get_bulk")
|
||||
@patch("tasks.jobs.report.Provider.objects.get")
|
||||
@patch("tasks.jobs.report.ScanSummary.objects.filter")
|
||||
def test_cleanup_skipped_when_supported_upload_fails(
|
||||
self,
|
||||
mock_scan_summary_filter,
|
||||
mock_provider_get,
|
||||
mock_get_bulk,
|
||||
mock_aggregate_stats,
|
||||
mock_generate_output_dir,
|
||||
mock_threatscore,
|
||||
mock_upload_to_s3,
|
||||
mock_rmtree,
|
||||
):
|
||||
"""Cleanup must not run when a generated report upload fails."""
|
||||
mock_scan_summary_filter.return_value.exists.return_value = True
|
||||
mock_provider_get.return_value = Mock(uid="provider-uid", provider="m365")
|
||||
mock_get_bulk.return_value = {}
|
||||
mock_aggregate_stats.return_value = {}
|
||||
mock_generate_output_dir.return_value = (
|
||||
"/tmp/tenant/scan/threatscore/prowler-output-provider-20240101000000"
|
||||
)
|
||||
mock_upload_to_s3.return_value = None
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(uuid.uuid4()),
|
||||
scan_id=str(uuid.uuid4()),
|
||||
provider_id=str(uuid.uuid4()),
|
||||
generate_threatscore=True,
|
||||
generate_ens=True,
|
||||
generate_nis2=True,
|
||||
generate_csa=True,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
assert result["threatscore"]["upload"] is False
|
||||
assert result["cis"] == {"upload": False, "path": ""}
|
||||
mock_generate_output_dir.assert_called_once()
|
||||
mock_threatscore.assert_called_once()
|
||||
mock_rmtree.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateComplianceReportsCIS:
|
||||
"""Test suite covering the CIS branch of generate_compliance_reports."""
|
||||
|
||||
def _force_scan_has_findings(self, monkeypatch):
|
||||
"""Bypass the ScanSummary.exists() early-return guard."""
|
||||
|
||||
class _FakeManager:
|
||||
def filter(self, **kwargs):
|
||||
class _Q:
|
||||
def exists(self):
|
||||
return True
|
||||
|
||||
return _Q()
|
||||
|
||||
monkeypatch.setattr("tasks.jobs.report.ScanSummary.objects", _FakeManager())
|
||||
|
||||
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_cis_report")
|
||||
@patch("tasks.jobs.report.Compliance.get_bulk")
|
||||
def test_cis_picks_latest_version(
|
||||
self,
|
||||
mock_get_bulk,
|
||||
mock_cis,
|
||||
mock_upload,
|
||||
mock_stats,
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""CIS branch should generate a single PDF for the highest version.
|
||||
|
||||
The returned ``results["cis"]`` must have the same flat shape as the
|
||||
other single-version frameworks (``{"upload", "path"}``) — the picked
|
||||
variant is an internal detail and is not exposed in the result.
|
||||
"""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
|
||||
mock_stats.return_value = {}
|
||||
# Multiple CIS variants + a non-CIS framework that must be ignored.
|
||||
# Includes 1.10 to verify the selection is not lexicographic.
|
||||
mock_get_bulk.return_value = {
|
||||
"cis_1.4_aws": Mock(),
|
||||
"cis_1.10_aws": Mock(),
|
||||
"cis_2.0_aws": Mock(),
|
||||
"cis_5.0_aws": Mock(),
|
||||
"ens_rd2022_aws": Mock(),
|
||||
}
|
||||
mock_upload.return_value = "s3://bucket/path"
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(provider.id),
|
||||
generate_threatscore=False,
|
||||
generate_ens=False,
|
||||
generate_nis2=False,
|
||||
generate_csa=False,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
# Exactly one call for the latest version, never for older variants
|
||||
# or non-CIS frameworks.
|
||||
assert mock_cis.call_count == 1
|
||||
assert mock_cis.call_args.kwargs["compliance_id"] == "cis_5.0_aws"
|
||||
|
||||
assert result["cis"]["upload"] is True
|
||||
assert result["cis"]["path"] == "s3://bucket/path"
|
||||
assert "compliance_id" not in result["cis"]
|
||||
|
||||
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_cis_report")
|
||||
@patch("tasks.jobs.report.Compliance.get_bulk")
|
||||
def test_cis_latest_variant_failure_captured_in_results(
|
||||
self,
|
||||
mock_get_bulk,
|
||||
mock_cis,
|
||||
mock_upload,
|
||||
mock_stats,
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""A failure in the latest CIS variant must be surfaced in the flat results entry."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
|
||||
mock_stats.return_value = {}
|
||||
mock_get_bulk.return_value = {
|
||||
"cis_1.4_aws": Mock(),
|
||||
"cis_5.0_aws": Mock(),
|
||||
}
|
||||
mock_cis.side_effect = RuntimeError("boom")
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(provider.id),
|
||||
generate_threatscore=False,
|
||||
generate_ens=False,
|
||||
generate_nis2=False,
|
||||
generate_csa=False,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
# Only the latest variant is attempted; its failure lands in a flat
|
||||
# entry keyed under "cis" with the same shape as sibling frameworks.
|
||||
assert mock_cis.call_count == 1
|
||||
assert result["cis"]["upload"] is False
|
||||
assert result["cis"]["error"] == "boom"
|
||||
assert "compliance_id" not in result["cis"]
|
||||
|
||||
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
|
||||
@patch("tasks.jobs.report._upload_to_s3")
|
||||
@patch("tasks.jobs.report.generate_cis_report")
|
||||
@patch("tasks.jobs.report.Compliance.get_bulk")
|
||||
def test_cis_provider_without_cis_skipped_cleanly(
|
||||
self,
|
||||
mock_get_bulk,
|
||||
mock_cis,
|
||||
mock_upload,
|
||||
mock_stats,
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""When ``Compliance.get_bulk`` returns no CIS entry the CIS branch
|
||||
must skip cleanly and record a flat ``{"upload": False, "path": ""}``
|
||||
entry — no hard-coded provider whitelist is consulted."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
mock_stats.return_value = {}
|
||||
# No ``cis_*`` keys in the bulk → no variant picked.
|
||||
mock_get_bulk.return_value = {"ens_rd2022_aws": Mock()}
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(provider.id),
|
||||
generate_threatscore=False,
|
||||
generate_ens=False,
|
||||
generate_nis2=False,
|
||||
generate_csa=False,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
assert result["cis"] == {"upload": False, "path": ""}
|
||||
mock_cis.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
|
||||
@patch("tasks.jobs.report._generate_compliance_output_directory")
|
||||
@patch("tasks.jobs.report.Compliance.get_bulk")
|
||||
def test_cis_output_directory_failure_is_captured(
|
||||
self,
|
||||
mock_get_bulk,
|
||||
mock_generate_output_dir,
|
||||
mock_stats,
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
providers_fixture,
|
||||
):
|
||||
"""CIS output dir errors must be captured in results (not raised)."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
mock_stats.return_value = {}
|
||||
mock_get_bulk.return_value = {"cis_5.0_aws": Mock()}
|
||||
mock_generate_output_dir.side_effect = RuntimeError("dir boom")
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan.id),
|
||||
provider_id=str(provider.id),
|
||||
generate_threatscore=False,
|
||||
generate_ens=False,
|
||||
generate_nis2=False,
|
||||
generate_csa=False,
|
||||
generate_cis=True,
|
||||
)
|
||||
|
||||
assert result["cis"]["upload"] is False
|
||||
assert result["cis"]["error"] == "dir boom"
|
||||
|
||||
|
||||
class TestPickLatestCisVariant:
|
||||
"""Unit tests for `_pick_latest_cis_variant` helper."""
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
assert _pick_latest_cis_variant([]) is None
|
||||
|
||||
def test_single_variant(self):
|
||||
assert _pick_latest_cis_variant(["cis_5.0_aws"]) == "cis_5.0_aws"
|
||||
|
||||
def test_numeric_not_lexicographic(self):
|
||||
"""1.10 must beat 1.2 (lex sort would pick 1.2)."""
|
||||
variants = ["cis_1.2_kubernetes", "cis_1.10_kubernetes"]
|
||||
assert _pick_latest_cis_variant(variants) == "cis_1.10_kubernetes"
|
||||
|
||||
def test_major_version_wins(self):
|
||||
variants = ["cis_1.4_aws", "cis_2.0_aws", "cis_5.0_aws", "cis_6.0_aws"]
|
||||
assert _pick_latest_cis_variant(variants) == "cis_6.0_aws"
|
||||
|
||||
def test_minor_version_breaks_tie(self):
|
||||
variants = ["cis_3.0_aws", "cis_3.1_aws", "cis_2.9_aws"]
|
||||
assert _pick_latest_cis_variant(variants) == "cis_3.1_aws"
|
||||
|
||||
def test_three_part_version(self):
|
||||
"""Versions like 3.0.1 must win over 3.0."""
|
||||
variants = ["cis_3.0_aws", "cis_3.0.1_aws"]
|
||||
assert _pick_latest_cis_variant(variants) == "cis_3.0.1_aws"
|
||||
|
||||
def test_malformed_names_ignored(self):
|
||||
variants = ["notcis_1.0_aws", "cis_abc_aws", "cis_5.0_aws"]
|
||||
assert _pick_latest_cis_variant(variants) == "cis_5.0_aws"
|
||||
|
||||
def test_only_malformed_returns_none(self):
|
||||
variants = ["notcis_1.0_aws", "cis_abc_aws"]
|
||||
assert _pick_latest_cis_variant(variants) is None
|
||||
|
||||
def test_multidigit_provider_name(self):
|
||||
"""Provider name with underscores (e.g. googleworkspace) must parse."""
|
||||
variants = ["cis_1.3_googleworkspace"]
|
||||
assert _pick_latest_cis_variant(variants) == "cis_1.3_googleworkspace"
|
||||
|
||||
def test_accepts_iterator(self):
|
||||
"""The helper must accept any iterable, not just lists."""
|
||||
|
||||
def _gen():
|
||||
yield "cis_1.4_aws"
|
||||
yield "cis_5.0_aws"
|
||||
|
||||
assert _pick_latest_cis_variant(_gen()) == "cis_5.0_aws"
|
||||
|
||||
def test_rejects_single_integer_version(self):
|
||||
"""The regex requires at least one dotted component. ``cis_5_aws``
|
||||
without a minor version is malformed per the backend contract."""
|
||||
assert _pick_latest_cis_variant(["cis_5_aws"]) is None
|
||||
|
||||
def test_rejects_trailing_dot(self):
|
||||
"""Inputs like ``cis_5._aws`` must be rejected at the regex stage
|
||||
instead of silently normalising to ``(5, 0)``."""
|
||||
assert _pick_latest_cis_variant(["cis_5._aws", "cis_1.0_aws"]) == "cis_1.0_aws"
|
||||
|
||||
def test_rejects_lone_dot_version(self):
|
||||
"""``cis_._aws`` has no numeric component and must be skipped."""
|
||||
assert _pick_latest_cis_variant(["cis_._aws", "cis_1.0_aws"]) == "cis_1.0_aws"
|
||||
|
||||
|
||||
class TestOptimizationImprovements:
|
||||
"""Test suite for optimization-related functionality."""
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from reportlab.platypus import Image, LongTable, Paragraph, Table
|
||||
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
|
||||
from tasks.jobs.reports.cis import (
|
||||
CISReportGenerator,
|
||||
_normalize_profile,
|
||||
_profile_badge_text,
|
||||
)
|
||||
|
||||
from api.models import StatusChoices
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cis_generator():
|
||||
"""Create a CISReportGenerator instance for testing."""
|
||||
config = FRAMEWORK_REGISTRY["cis"]
|
||||
return CISReportGenerator(config)
|
||||
|
||||
|
||||
def _make_attr(
|
||||
section: str,
|
||||
profile_value: str = "Level 1",
|
||||
assessment_value: str = "Automated",
|
||||
sub_section: str = "",
|
||||
**extras,
|
||||
) -> Mock:
|
||||
"""Build a mock CIS_Requirement_Attribute with duck-typed fields."""
|
||||
attr = Mock()
|
||||
attr.Section = section
|
||||
attr.SubSection = sub_section
|
||||
# CIS enums have `.value`. Use a simple Mock that exposes `.value`.
|
||||
attr.Profile = Mock(value=profile_value)
|
||||
attr.AssessmentStatus = Mock(value=assessment_value)
|
||||
attr.Description = extras.get("description", "desc")
|
||||
attr.RationaleStatement = extras.get("rationale", "the rationale")
|
||||
attr.ImpactStatement = extras.get("impact", "the impact")
|
||||
attr.RemediationProcedure = extras.get("remediation", "the remediation")
|
||||
attr.AuditProcedure = extras.get("audit", "the audit")
|
||||
attr.AdditionalInformation = ""
|
||||
attr.DefaultValue = ""
|
||||
attr.References = extras.get("references", "https://example.com")
|
||||
return attr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_cis_compliance_data():
|
||||
"""Create basic ComplianceData for CIS testing (no requirements)."""
|
||||
return ComplianceData(
|
||||
tenant_id="tenant-123",
|
||||
scan_id="scan-456",
|
||||
provider_id="provider-789",
|
||||
compliance_id="cis_5.0_aws",
|
||||
framework="CIS",
|
||||
name="CIS Amazon Web Services Foundations Benchmark v5.0.0",
|
||||
version="5.0",
|
||||
description="Center for Internet Security AWS Foundations Benchmark",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_cis_compliance_data(basic_cis_compliance_data):
|
||||
"""CIS data with mixed requirements across 2 sections, Profile L1/L2, Pass/Fail/Manual."""
|
||||
data = basic_cis_compliance_data
|
||||
data.requirements = [
|
||||
RequirementData(
|
||||
id="1.1",
|
||||
description="Maintain current contact details",
|
||||
status=StatusChoices.PASS,
|
||||
passed_findings=5,
|
||||
failed_findings=0,
|
||||
total_findings=5,
|
||||
checks=["aws_check_1"],
|
||||
),
|
||||
RequirementData(
|
||||
id="1.2",
|
||||
description="Ensure root account has no access keys",
|
||||
status=StatusChoices.FAIL,
|
||||
passed_findings=0,
|
||||
failed_findings=3,
|
||||
total_findings=3,
|
||||
checks=["aws_check_2"],
|
||||
),
|
||||
RequirementData(
|
||||
id="1.3",
|
||||
description="Ensure MFA is enabled for all IAM users",
|
||||
status=StatusChoices.MANUAL,
|
||||
checks=[],
|
||||
),
|
||||
RequirementData(
|
||||
id="2.1",
|
||||
description="Ensure S3 Buckets are logging",
|
||||
status=StatusChoices.PASS,
|
||||
passed_findings=2,
|
||||
failed_findings=0,
|
||||
total_findings=2,
|
||||
checks=["aws_check_3"],
|
||||
),
|
||||
RequirementData(
|
||||
id="2.2",
|
||||
description="Ensure encryption at rest is enabled",
|
||||
status=StatusChoices.FAIL,
|
||||
passed_findings=0,
|
||||
failed_findings=4,
|
||||
total_findings=4,
|
||||
checks=["aws_check_4"],
|
||||
),
|
||||
]
|
||||
data.attributes_by_requirement_id = {
|
||||
"1.1": {
|
||||
"attributes": {
|
||||
"req_attributes": [
|
||||
_make_attr(
|
||||
"1 Identity and Access Management",
|
||||
profile_value="Level 1",
|
||||
assessment_value="Automated",
|
||||
)
|
||||
],
|
||||
"checks": ["aws_check_1"],
|
||||
}
|
||||
},
|
||||
"1.2": {
|
||||
"attributes": {
|
||||
"req_attributes": [
|
||||
_make_attr(
|
||||
"1 Identity and Access Management",
|
||||
profile_value="Level 1",
|
||||
assessment_value="Automated",
|
||||
)
|
||||
],
|
||||
"checks": ["aws_check_2"],
|
||||
}
|
||||
},
|
||||
"1.3": {
|
||||
"attributes": {
|
||||
"req_attributes": [
|
||||
_make_attr(
|
||||
"1 Identity and Access Management",
|
||||
profile_value="Level 2",
|
||||
assessment_value="Manual",
|
||||
)
|
||||
],
|
||||
"checks": [],
|
||||
}
|
||||
},
|
||||
"2.1": {
|
||||
"attributes": {
|
||||
"req_attributes": [
|
||||
_make_attr(
|
||||
"2 Storage",
|
||||
profile_value="Level 2",
|
||||
assessment_value="Automated",
|
||||
)
|
||||
],
|
||||
"checks": ["aws_check_3"],
|
||||
}
|
||||
},
|
||||
"2.2": {
|
||||
"attributes": {
|
||||
"req_attributes": [
|
||||
_make_attr(
|
||||
"2 Storage",
|
||||
profile_value="Level 1",
|
||||
assessment_value="Automated",
|
||||
)
|
||||
],
|
||||
"checks": ["aws_check_4"],
|
||||
}
|
||||
},
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper function tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestNormalizeProfile:
|
||||
"""Test suite for _normalize_profile helper."""
|
||||
|
||||
def test_level_1_string(self):
|
||||
assert _normalize_profile(Mock(value="Level 1")) == "L1"
|
||||
|
||||
def test_level_2_string(self):
|
||||
assert _normalize_profile(Mock(value="Level 2")) == "L2"
|
||||
|
||||
def test_e3_level_1(self):
|
||||
assert _normalize_profile(Mock(value="E3 Level 1")) == "L1"
|
||||
|
||||
def test_e5_level_2(self):
|
||||
assert _normalize_profile(Mock(value="E5 Level 2")) == "L2"
|
||||
|
||||
def test_none_returns_other(self):
|
||||
assert _normalize_profile(None) == "Other"
|
||||
|
||||
def test_substring_trap_rejected(self):
|
||||
"""Unrelated tokens containing the literal ``L2`` must NOT map to L2."""
|
||||
# A future enum value like "CL2 Kubernetes Worker" would be silently
|
||||
# misclassified by a naive substring check.
|
||||
assert _normalize_profile(Mock(value="CL2 Worker")) == "Other"
|
||||
assert _normalize_profile(Mock(value="HL2 Legacy")) == "Other"
|
||||
|
||||
def test_raw_string_level_1(self):
|
||||
# Mock without .value falls back to str(profile); use a real string
|
||||
class NoValue:
|
||||
def __str__(self):
|
||||
return "Level 1"
|
||||
|
||||
assert _normalize_profile(NoValue()) == "L1"
|
||||
|
||||
def test_unknown_profile_returns_other(self):
|
||||
assert _normalize_profile(Mock(value="Custom Profile")) == "Other"
|
||||
|
||||
|
||||
class TestProfileBadgeText:
|
||||
def test_l1_label(self):
|
||||
assert _profile_badge_text("L1") == "Level 1"
|
||||
|
||||
def test_l2_label(self):
|
||||
assert _profile_badge_text("L2") == "Level 2"
|
||||
|
||||
def test_other_label(self):
|
||||
assert _profile_badge_text("Other") == "Other"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Generator initialization
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCISGeneratorInitialization:
|
||||
def test_generator_created(self, cis_generator):
|
||||
assert cis_generator is not None
|
||||
assert cis_generator.config.name == "cis"
|
||||
|
||||
def test_generator_language(self, cis_generator):
|
||||
assert cis_generator.config.language == "en"
|
||||
|
||||
def test_generator_sections_dynamic(self, cis_generator):
|
||||
# CIS sections differ per variant so config.sections MUST be None
|
||||
assert cis_generator.config.sections is None
|
||||
|
||||
def test_attribute_fields_contain_cis_specific(self, cis_generator):
|
||||
for field in ("Profile", "AssessmentStatus", "RationaleStatement"):
|
||||
assert field in cis_generator.config.attribute_fields
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _derive_sections
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDeriveSections:
|
||||
def test_preserves_first_seen_order(
|
||||
self, cis_generator, populated_cis_compliance_data
|
||||
):
|
||||
sections = cis_generator._derive_sections(populated_cis_compliance_data)
|
||||
assert sections == [
|
||||
"1 Identity and Access Management",
|
||||
"2 Storage",
|
||||
]
|
||||
|
||||
def test_deduplicates_sections(self, cis_generator, basic_cis_compliance_data):
|
||||
basic_cis_compliance_data.requirements = [
|
||||
RequirementData(id="1.1", description="a", status=StatusChoices.PASS),
|
||||
RequirementData(id="1.2", description="b", status=StatusChoices.PASS),
|
||||
]
|
||||
attr = _make_attr("1 IAM")
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {
|
||||
"1.1": {"attributes": {"req_attributes": [attr], "checks": []}},
|
||||
"1.2": {"attributes": {"req_attributes": [attr], "checks": []}},
|
||||
}
|
||||
assert cis_generator._derive_sections(basic_cis_compliance_data) == ["1 IAM"]
|
||||
|
||||
def test_empty_data_returns_empty(self, cis_generator, basic_cis_compliance_data):
|
||||
basic_cis_compliance_data.requirements = []
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {}
|
||||
assert cis_generator._derive_sections(basic_cis_compliance_data) == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _compute_statistics
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestComputeStatistics:
|
||||
def test_totals(self, cis_generator, populated_cis_compliance_data):
|
||||
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
|
||||
assert stats["total"] == 5
|
||||
assert stats["passed"] == 2
|
||||
assert stats["failed"] == 2
|
||||
assert stats["manual"] == 1
|
||||
|
||||
def test_overall_compliance_excludes_manual(
|
||||
self, cis_generator, populated_cis_compliance_data
|
||||
):
|
||||
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
|
||||
# 2 passed / 4 evaluated (pass + fail) = 50%
|
||||
assert stats["overall_compliance"] == pytest.approx(50.0)
|
||||
|
||||
def test_overall_compliance_all_manual(
|
||||
self, cis_generator, basic_cis_compliance_data
|
||||
):
|
||||
basic_cis_compliance_data.requirements = [
|
||||
RequirementData(id="x", description="d", status=StatusChoices.MANUAL),
|
||||
]
|
||||
attr = _make_attr("1 IAM", profile_value="Level 1", assessment_value="Manual")
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {
|
||||
"x": {"attributes": {"req_attributes": [attr], "checks": []}},
|
||||
}
|
||||
stats = cis_generator._compute_statistics(basic_cis_compliance_data)
|
||||
# No evaluated → defaults to 100%
|
||||
assert stats["overall_compliance"] == 100.0
|
||||
|
||||
def test_profile_counts(self, cis_generator, populated_cis_compliance_data):
|
||||
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
|
||||
profile = stats["profile_counts"]
|
||||
# From fixture:
|
||||
# L1: 1.1 (PASS, Auto), 1.2 (FAIL, Auto), 2.2 (FAIL, Auto) → pass=1, fail=2, manual=0
|
||||
# L2: 1.3 (MANUAL, Manual), 2.1 (PASS, Auto) → pass=1, fail=0, manual=1
|
||||
assert profile["L1"] == {"passed": 1, "failed": 2, "manual": 0}
|
||||
assert profile["L2"] == {"passed": 1, "failed": 0, "manual": 1}
|
||||
|
||||
def test_assessment_counts(self, cis_generator, populated_cis_compliance_data):
|
||||
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
|
||||
assessment = stats["assessment_counts"]
|
||||
# Automated: 1.1 PASS, 1.2 FAIL, 2.1 PASS, 2.2 FAIL → pass=2, fail=2, manual=0
|
||||
# Manual: 1.3 MANUAL → pass=0, fail=0, manual=1
|
||||
assert assessment["Automated"] == {"passed": 2, "failed": 2, "manual": 0}
|
||||
assert assessment["Manual"] == {"passed": 0, "failed": 0, "manual": 1}
|
||||
|
||||
def test_top_failing_sections_includes_all_evaluated(
|
||||
self, cis_generator, populated_cis_compliance_data
|
||||
):
|
||||
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
|
||||
top = stats["top_failing_sections"]
|
||||
# Both sections have 1 PASS + 1 FAIL evaluated → tied at 50%. The
|
||||
# sort is stable, so both must appear and both must be capped at
|
||||
# 5 entries.
|
||||
assert len(top) == 2
|
||||
section_names = {name for name, _ in top}
|
||||
assert section_names == {
|
||||
"1 Identity and Access Management",
|
||||
"2 Storage",
|
||||
}
|
||||
|
||||
def test_compute_statistics_is_memoized(
|
||||
self, cis_generator, populated_cis_compliance_data
|
||||
):
|
||||
"""Calling ``_compute_statistics`` twice with the same data must
|
||||
reuse the cached value and not re-run the uncached kernel."""
|
||||
with patch.object(
|
||||
CISReportGenerator,
|
||||
"_compute_statistics_uncached",
|
||||
wraps=cis_generator._compute_statistics_uncached,
|
||||
) as spy:
|
||||
cis_generator._compute_statistics(populated_cis_compliance_data)
|
||||
cis_generator._compute_statistics(populated_cis_compliance_data)
|
||||
assert spy.call_count == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Executive summary
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCISExecutiveSummary:
|
||||
def test_title_present(self, cis_generator, populated_cis_compliance_data):
|
||||
elements = cis_generator.create_executive_summary(populated_cis_compliance_data)
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
text = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Executive Summary" in text
|
||||
|
||||
def test_tables_rendered(self, cis_generator, populated_cis_compliance_data):
|
||||
elements = cis_generator.create_executive_summary(populated_cis_compliance_data)
|
||||
tables = [e for e in elements if isinstance(e, Table)]
|
||||
# Exact count: Summary, Profile, Assessment, Top Failing Sections = 4.
|
||||
assert len(tables) == 4
|
||||
|
||||
def test_no_requirements(self, cis_generator, basic_cis_compliance_data):
|
||||
basic_cis_compliance_data.requirements = []
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {}
|
||||
elements = cis_generator.create_executive_summary(basic_cis_compliance_data)
|
||||
# With no requirements: Summary table always renders, and both Profile
|
||||
# and Assessment breakdown tables render with a 0-filled default row,
|
||||
# but Top Failing Sections is suppressed → exactly 3 tables.
|
||||
tables = [e for e in elements if isinstance(e, Table)]
|
||||
assert len(tables) == 3
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Charts section
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCISChartsSection:
|
||||
def test_charts_rendered(self, cis_generator, populated_cis_compliance_data):
|
||||
elements = cis_generator.create_charts_section(populated_cis_compliance_data)
|
||||
# At least 1 image for the pie + 1 for section bar + 1 for stacked
|
||||
images = [e for e in elements if isinstance(e, Image)]
|
||||
assert len(images) >= 1
|
||||
|
||||
def test_charts_no_data_no_crash(self, cis_generator, basic_cis_compliance_data):
|
||||
basic_cis_compliance_data.requirements = []
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {}
|
||||
elements = cis_generator.create_charts_section(basic_cis_compliance_data)
|
||||
# Must not raise; may or may not have any Image
|
||||
assert isinstance(elements, list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Requirements index
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCISRequirementsIndex:
|
||||
def test_title_present(self, cis_generator, populated_cis_compliance_data):
|
||||
elements = cis_generator.create_requirements_index(
|
||||
populated_cis_compliance_data
|
||||
)
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
text = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Requirements Index" in text
|
||||
|
||||
def test_groups_by_section(self, cis_generator, populated_cis_compliance_data):
|
||||
elements = cis_generator.create_requirements_index(
|
||||
populated_cis_compliance_data
|
||||
)
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
text = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "1 Identity and Access Management" in text
|
||||
assert "2 Storage" in text
|
||||
|
||||
def test_renders_tables_per_section(
|
||||
self, cis_generator, populated_cis_compliance_data
|
||||
):
|
||||
elements = cis_generator.create_requirements_index(
|
||||
populated_cis_compliance_data
|
||||
)
|
||||
# One table per section with requirements. ``create_data_table``
|
||||
# returns a LongTable when the row count exceeds its threshold and a
|
||||
# plain Table otherwise — both are valid.
|
||||
tables = [e for e in elements if isinstance(e, (Table, LongTable))]
|
||||
assert len(tables) == 2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Detailed findings extras hook
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRenderRequirementDetailExtras:
|
||||
def test_inserts_all_fields(self, cis_generator, populated_cis_compliance_data):
|
||||
req = populated_cis_compliance_data.requirements[1] # 1.2 FAIL
|
||||
extras = cis_generator._render_requirement_detail_extras(
|
||||
req, populated_cis_compliance_data
|
||||
)
|
||||
text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph))
|
||||
assert "Rationale" in text
|
||||
assert "Impact" in text
|
||||
assert "Audit Procedure" in text
|
||||
assert "Remediation" in text
|
||||
assert "References" in text
|
||||
|
||||
def test_missing_metadata_returns_empty(
|
||||
self, cis_generator, basic_cis_compliance_data
|
||||
):
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {}
|
||||
req = RequirementData(id="99", description="unknown", status=StatusChoices.FAIL)
|
||||
extras = cis_generator._render_requirement_detail_extras(
|
||||
req, basic_cis_compliance_data
|
||||
)
|
||||
assert extras == []
|
||||
|
||||
def test_escapes_html_chars(self, cis_generator, basic_cis_compliance_data):
|
||||
attr = _make_attr(
|
||||
"1 IAM",
|
||||
rationale="<script>alert('x')</script>",
|
||||
)
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {
|
||||
"1.1": {"attributes": {"req_attributes": [attr], "checks": []}}
|
||||
}
|
||||
req = RequirementData(id="1.1", description="d", status=StatusChoices.FAIL)
|
||||
extras = cis_generator._render_requirement_detail_extras(
|
||||
req, basic_cis_compliance_data
|
||||
)
|
||||
text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph))
|
||||
assert "<script>" not in text
|
||||
assert "<script>" in text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cover page
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCISCoverPage:
|
||||
@patch("tasks.jobs.reports.cis.Image")
|
||||
def test_cover_page_has_logo(
|
||||
self, mock_image, cis_generator, basic_cis_compliance_data
|
||||
):
|
||||
elements = cis_generator.create_cover_page(basic_cis_compliance_data)
|
||||
assert len(elements) > 0
|
||||
assert mock_image.call_count >= 1
|
||||
|
||||
def test_cover_page_title_includes_version(
|
||||
self, cis_generator, basic_cis_compliance_data
|
||||
):
|
||||
elements = cis_generator.create_cover_page(basic_cis_compliance_data)
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "CIS Benchmark" in content
|
||||
assert "5.0" in content
|
||||
|
||||
def test_cover_page_title_includes_provider_when_set(
|
||||
self, cis_generator, basic_cis_compliance_data
|
||||
):
|
||||
provider = Mock()
|
||||
provider.provider = "aws"
|
||||
provider.uid = "123456789012"
|
||||
provider.alias = "test-account"
|
||||
basic_cis_compliance_data.provider_obj = provider
|
||||
elements = cis_generator.create_cover_page(basic_cis_compliance_data)
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "AWS" in content
|
||||
@@ -24,6 +24,7 @@ from tasks.jobs.scan import (
|
||||
aggregate_findings,
|
||||
create_compliance_requirements,
|
||||
perform_prowler_scan,
|
||||
reset_ephemeral_resource_findings_count,
|
||||
update_provider_compliance_scores,
|
||||
)
|
||||
from tasks.utils import CustomEncoder
|
||||
@@ -35,7 +36,9 @@ from api.models import (
|
||||
MuteRule,
|
||||
Provider,
|
||||
Resource,
|
||||
ResourceScanSummary,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
)
|
||||
@@ -3358,6 +3361,175 @@ class TestAggregateFindings:
|
||||
regions = {s.region for s in summaries}
|
||||
assert regions == {"us-east-1", "us-west-2"}
|
||||
|
||||
@patch("tasks.jobs.scan.Finding.objects.filter")
|
||||
@patch("tasks.jobs.scan.ScanSummary.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
def test_aggregate_findings_skips_rows_with_null_service_or_region(
|
||||
self, mock_rls_transaction, mock_bulk_create, mock_findings_filter
|
||||
):
|
||||
"""Aggregation rows with NULL service or region (orphan Findings whose
|
||||
ResourceFindingMapping is missing) must be dropped before
|
||||
``bulk_create`` so the NOT NULL constraints on ``scan_summaries`` are
|
||||
not violated. Valid rows in the same batch must still be persisted."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
|
||||
base_counts = {
|
||||
"fail": 1,
|
||||
"_pass": 0,
|
||||
"muted_count": 0,
|
||||
"total": 1,
|
||||
"new": 0,
|
||||
"changed": 0,
|
||||
"unchanged": 1,
|
||||
"fail_new": 0,
|
||||
"fail_changed": 0,
|
||||
"pass_new": 0,
|
||||
"pass_changed": 0,
|
||||
"muted_new": 0,
|
||||
"muted_changed": 0,
|
||||
}
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.values.return_value = mock_queryset
|
||||
mock_queryset.annotate.return_value = [
|
||||
{
|
||||
"check_id": "check_valid",
|
||||
"resources__service": "s3",
|
||||
"severity": "high",
|
||||
"resources__region": "us-east-1",
|
||||
**base_counts,
|
||||
},
|
||||
{
|
||||
"check_id": "check_null_service",
|
||||
"resources__service": None,
|
||||
"severity": "high",
|
||||
"resources__region": "us-east-1",
|
||||
**base_counts,
|
||||
},
|
||||
{
|
||||
"check_id": "check_null_region",
|
||||
"resources__service": "ec2",
|
||||
"severity": "low",
|
||||
"resources__region": None,
|
||||
**base_counts,
|
||||
},
|
||||
{
|
||||
"check_id": "check_null_both",
|
||||
"resources__service": None,
|
||||
"severity": "medium",
|
||||
"resources__region": None,
|
||||
**base_counts,
|
||||
},
|
||||
]
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = None
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
mock_findings_filter.return_value = mock_queryset
|
||||
|
||||
aggregate_findings(tenant_id, scan_id)
|
||||
|
||||
mock_bulk_create.assert_called_once()
|
||||
args, _ = mock_bulk_create.call_args
|
||||
summaries = list(args[0])
|
||||
|
||||
assert len(summaries) == 1
|
||||
assert summaries[0].check_id == "check_valid"
|
||||
assert summaries[0].service == "s3"
|
||||
assert summaries[0].region == "us-east-1"
|
||||
|
||||
def test_aggregate_findings_is_idempotent_on_rerun(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
findings_fixture,
|
||||
):
|
||||
"""Re-running `aggregate_findings` for the same scan must not violate
|
||||
the `unique_scan_summary` constraint. The post-mute reaggregation
|
||||
pipeline re-dispatches `perform_scan_summary_task` against scans
|
||||
whose summaries already exist; upsert must update existing rows in
|
||||
place (same primary keys) rather than inserting duplicates."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
|
||||
value_columns = (
|
||||
"check_id",
|
||||
"service",
|
||||
"severity",
|
||||
"region",
|
||||
"fail",
|
||||
"_pass",
|
||||
"muted",
|
||||
"total",
|
||||
)
|
||||
|
||||
aggregate_findings(str(tenant.id), str(scan.id))
|
||||
first_run_ids = set(
|
||||
ScanSummary.all_objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
first_run_rows = list(
|
||||
ScanSummary.all_objects.filter(tenant_id=tenant.id, scan_id=scan.id).values(
|
||||
*value_columns
|
||||
)
|
||||
)
|
||||
|
||||
# Second invocation must not raise and must not duplicate rows.
|
||||
aggregate_findings(str(tenant.id), str(scan.id))
|
||||
second_run_ids = set(
|
||||
ScanSummary.all_objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
second_run_rows = list(
|
||||
ScanSummary.all_objects.filter(tenant_id=tenant.id, scan_id=scan.id).values(
|
||||
*value_columns
|
||||
)
|
||||
)
|
||||
|
||||
# Upsert preserves the original row identities; values stay stable
|
||||
# because the underlying Finding set is unchanged between runs.
|
||||
assert second_run_rows == first_run_rows
|
||||
assert first_run_ids == second_run_ids
|
||||
|
||||
def test_aggregate_findings_reflects_mute_between_runs(
|
||||
self,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
findings_fixture,
|
||||
):
|
||||
"""Re-running `aggregate_findings` after a finding is muted between
|
||||
runs must move counters: the matching ScanSummary row's `fail`
|
||||
decrements and `muted` increments. Guards against a regression where
|
||||
upsert silently keeps stale values from the first run."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
finding1, _ = findings_fixture # finding1 is FAIL and not muted.
|
||||
|
||||
aggregate_findings(str(tenant.id), str(scan.id))
|
||||
before = ScanSummary.all_objects.get(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
check_id=finding1.check_id,
|
||||
service="ec2",
|
||||
severity=finding1.severity,
|
||||
region="us-east-1",
|
||||
)
|
||||
assert before.fail == 1
|
||||
assert before.muted == 0
|
||||
|
||||
Finding.all_objects.filter(pk=finding1.pk).update(muted=True)
|
||||
|
||||
aggregate_findings(str(tenant.id), str(scan.id))
|
||||
after = ScanSummary.all_objects.get(pk=before.pk)
|
||||
|
||||
assert after.fail == 0
|
||||
assert after.muted == 1
|
||||
assert after.total == before.total
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAggregateFindingsByRegion:
|
||||
@@ -4165,3 +4337,315 @@ class TestUpdateProviderComplianceScores:
|
||||
assert any("provider_compliance_scores" in c for c in calls)
|
||||
assert any("tenant_compliance_summaries" in c for c in calls)
|
||||
assert any("pg_advisory_xact_lock" in c for c in calls)
|
||||
|
||||
|
||||
class TestScanIsFullScope:
|
||||
def _live_trigger(self):
|
||||
return Scan.TriggerChoices.MANUAL
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scanner_args",
|
||||
[
|
||||
{},
|
||||
{"unrelated": "value"},
|
||||
{"checks": None},
|
||||
{"services": []},
|
||||
{"severities": ""},
|
||||
],
|
||||
)
|
||||
def test_full_scope_when_no_filters_present(self, scanner_args):
|
||||
scan = Scan(scanner_args=scanner_args, trigger=self._live_trigger())
|
||||
assert scan.is_full_scope() is True
|
||||
|
||||
def test_full_scope_covers_every_sdk_kwarg(self):
|
||||
# Lock the predicate to whatever ProwlerScan's __init__ exposes today.
|
||||
# If the SDK adds a new filter, this test still passes via the
|
||||
# introspection-driven derivation; if it adds a non-filter kwarg
|
||||
# (e.g. provider-like), keep the exclusion list in sync in models.py.
|
||||
from prowler.lib.scan.scan import Scan as ProwlerScan
|
||||
import inspect
|
||||
|
||||
expected = tuple(
|
||||
name
|
||||
for name in inspect.signature(ProwlerScan.__init__).parameters
|
||||
if name not in ("self", "provider")
|
||||
)
|
||||
assert Scan.get_scoping_scanner_arg_keys() == expected
|
||||
# Spot-check a few well-known filters survive the introspection.
|
||||
assert "checks" in expected
|
||||
assert "services" in expected
|
||||
assert "severities" in expected
|
||||
|
||||
def test_partial_scope_for_each_sdk_filter(self):
|
||||
for key in Scan.get_scoping_scanner_arg_keys():
|
||||
scan = Scan(scanner_args={key: ["x"]}, trigger=self._live_trigger())
|
||||
assert scan.is_full_scope() is False, f"{key} should mark scan as partial"
|
||||
|
||||
def test_imported_scan_is_never_full_scope(self):
|
||||
# Forward-defensive: any trigger outside LIVE_SCAN_TRIGGERS (e.g. a
|
||||
# future "imported" trigger) must never qualify, even with empty args.
|
||||
scan = Scan(scanner_args={}, trigger="imported")
|
||||
assert scan.is_full_scope() is False
|
||||
|
||||
def test_handles_none_scanner_args(self):
|
||||
scan = Scan(scanner_args=None, trigger=self._live_trigger())
|
||||
assert scan.is_full_scope() is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestResetEphemeralResourceFindingsCount:
|
||||
def _make_scan_summary(self, tenant_id, scan_id, resource):
|
||||
return ResourceScanSummary.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
resource_id=resource.id,
|
||||
service=resource.service,
|
||||
region=resource.region,
|
||||
resource_type=resource.type,
|
||||
)
|
||||
|
||||
def test_resets_only_resources_missing_from_full_scope_scan(
|
||||
self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, scan2, *_ = scans_fixture
|
||||
resource1, resource2, resource3 = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource1.id).update(failed_findings_count=3)
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
Resource.objects.filter(id=resource3.id).update(failed_findings_count=7)
|
||||
|
||||
# Only resource1 was scanned in scan1; resource2 is ephemeral.
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["reset"] == 1
|
||||
|
||||
resource1.refresh_from_db()
|
||||
resource2.refresh_from_db()
|
||||
resource3.refresh_from_db()
|
||||
|
||||
assert resource1.failed_findings_count == 3
|
||||
assert resource2.failed_findings_count == 0
|
||||
# Other provider's resource is never touched.
|
||||
assert resource3.failed_findings_count == 7
|
||||
|
||||
def test_skips_when_scan_not_completed(
|
||||
self, tenants_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Scan.objects.filter(id=scan1.id).update(state=StateChoices.EXECUTING)
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "scan not completed"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 5
|
||||
|
||||
def test_skips_when_scan_has_scoping_filters(
|
||||
self, tenants_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
_, resource2, _ = resources_fixture
|
||||
|
||||
Scan.objects.filter(id=scan1.id).update(scanner_args={"checks": ["check1"]})
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "partial scan scope"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 5
|
||||
|
||||
def test_skips_when_scan_not_found(self, tenants_fixture):
|
||||
tenant, *_ = tenants_fixture
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(uuid.uuid4())
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "scan not found"
|
||||
|
||||
def test_skips_when_newer_scan_completed_for_same_provider(
|
||||
self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
|
||||
):
|
||||
# If a newer completed scan exists for the same provider, our
|
||||
# ResourceScanSummary set is stale relative to the resources' current
|
||||
# counts, and applying the diff would corrupt them.
|
||||
from datetime import timedelta
|
||||
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
provider, *_ = providers_fixture
|
||||
_, resource2, _ = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
|
||||
# Create a newer COMPLETED scan for the same provider, with an
|
||||
# explicit completed_at strictly after scan1's so ordering is
|
||||
# deterministic regardless of clock resolution.
|
||||
newer_completed_at = scan1.completed_at + timedelta(minutes=5)
|
||||
Scan.objects.create(
|
||||
name="Newer Scan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
tenant_id=tenant.id,
|
||||
started_at=newer_completed_at,
|
||||
completed_at=newer_completed_at,
|
||||
)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "newer scan exists"
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 5
|
||||
|
||||
def test_does_not_touch_other_providers_resources(
|
||||
self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
_, _, resource3 = resources_fixture
|
||||
|
||||
# resource3 belongs to provider2 with failed_findings_count > 0 and is
|
||||
# not in scan1's summary. It MUST NOT be reset.
|
||||
Resource.objects.filter(id=resource3.id).update(failed_findings_count=9)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["reset"] == 0
|
||||
|
||||
resource3.refresh_from_db()
|
||||
assert resource3.failed_findings_count == 9
|
||||
|
||||
def test_resources_already_zero_are_not_rewritten(
|
||||
self, tenants_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
# Both resources already at 0, neither in summary -> nothing to update.
|
||||
Resource.objects.filter(id=resource1.id).update(failed_findings_count=0)
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=0)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["reset"] == 0
|
||||
|
||||
def test_skips_when_summaries_missing_for_scan_with_resources(
|
||||
self, tenants_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
# Catastrophic guard: if a scan reports unique_resource_count > 0 but
|
||||
# no ResourceScanSummary rows are persisted (e.g. bulk_create silently
|
||||
# failed), the anti-join would classify EVERY resource as ephemeral
|
||||
# and zero their counts. The gate must skip and preserve the data.
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Scan.objects.filter(id=scan1.id).update(unique_resource_count=10)
|
||||
Resource.objects.filter(id=resource1.id).update(failed_findings_count=3)
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "summaries missing"
|
||||
|
||||
resource1.refresh_from_db()
|
||||
resource2.refresh_from_db()
|
||||
assert resource1.failed_findings_count == 3
|
||||
assert resource2.failed_findings_count == 5
|
||||
|
||||
def test_ignores_sibling_scan_with_null_completed_at(
|
||||
self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
|
||||
):
|
||||
# Postgres orders NULL first in DESC; a sibling COMPLETED scan with a
|
||||
# missing completed_at must not be treated as the latest scan and
|
||||
# cause us to incorrectly skip the reset.
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
provider, *_ = providers_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
|
||||
self._make_scan_summary(tenant.id, scan1.id, resource1)
|
||||
|
||||
Scan.objects.create(
|
||||
name="Ghost Scan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
tenant_id=tenant.id,
|
||||
started_at=scan1.completed_at,
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["reset"] == 1
|
||||
|
||||
resource2.refresh_from_db()
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
def test_batches_updates_when_many_ephemeral_resources(
|
||||
self, tenants_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
# Forces multiple batches to confirm the chunked UPDATE path executes
|
||||
# cleanly and the count is the sum across batches.
|
||||
tenant, *_ = tenants_fixture
|
||||
scan1, *_ = scans_fixture
|
||||
resource1, resource2, _ = resources_fixture
|
||||
|
||||
Resource.objects.filter(id=resource1.id).update(failed_findings_count=2)
|
||||
Resource.objects.filter(id=resource2.id).update(failed_findings_count=4)
|
||||
|
||||
# No ResourceScanSummary -> both resource1 and resource2 are ephemeral.
|
||||
# Force a 1-row batch via the shared findings batch size knob.
|
||||
with patch("tasks.jobs.scan.DJANGO_FINDINGS_BATCH_SIZE", 1):
|
||||
result = reset_ephemeral_resource_findings_count(
|
||||
tenant_id=str(tenant.id), scan_id=str(scan1.id)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["reset"] == 2
|
||||
|
||||
resource1.refresh_from_db()
|
||||
resource2.refresh_from_db()
|
||||
assert resource1.failed_findings_count == 0
|
||||
assert resource2.failed_findings_count == 0
|
||||
|
||||
@@ -13,6 +13,8 @@ from tasks.jobs.lighthouse_providers import (
|
||||
_extract_bedrock_credentials,
|
||||
)
|
||||
from tasks.tasks import (
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
_cleanup_orphan_scheduled_scans,
|
||||
_perform_scan_complete_tasks,
|
||||
check_integrations_task,
|
||||
@@ -236,7 +238,8 @@ class TestGenerateOutputs:
|
||||
self.provider_id = str(uuid.uuid4())
|
||||
self.tenant_id = str(uuid.uuid4())
|
||||
|
||||
def test_no_findings_returns_early(self):
|
||||
@patch("tasks.tasks._cleanup_stale_tmp_output_directories")
|
||||
def test_no_findings_returns_early(self, mock_cleanup_stale_tmp_output_directories):
|
||||
with patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter:
|
||||
mock_filter.return_value.exists.return_value = False
|
||||
|
||||
@@ -248,6 +251,34 @@ class TestGenerateOutputs:
|
||||
|
||||
assert result == {"upload": False}
|
||||
mock_filter.assert_called_once_with(scan_id=self.scan_id)
|
||||
mock_cleanup_stale_tmp_output_directories.assert_called_once_with(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
exclude_scan=(self.tenant_id, self.scan_id),
|
||||
)
|
||||
|
||||
@patch(
|
||||
"tasks.tasks._cleanup_stale_tmp_output_directories",
|
||||
side_effect=RuntimeError("cleanup boom"),
|
||||
)
|
||||
def test_cleanup_exception_does_not_break_no_findings_flow(
|
||||
self, mock_cleanup_stale_tmp_output_directories
|
||||
):
|
||||
with patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter:
|
||||
mock_filter.return_value.exists.return_value = False
|
||||
|
||||
result = generate_outputs_task(
|
||||
scan_id=self.scan_id,
|
||||
provider_id=self.provider_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
assert result == {"upload": False}
|
||||
mock_cleanup_stale_tmp_output_directories.assert_called_once_with(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
max_age_hours=STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
exclude_scan=(self.tenant_id, self.scan_id),
|
||||
)
|
||||
|
||||
@patch("tasks.tasks._upload_to_s3")
|
||||
@patch("tasks.tasks._compress_output_files")
|
||||
@@ -309,7 +340,7 @@ class TestGenerateOutputs:
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks.COMPLIANCE_CLASS_MAP",
|
||||
{"aws": [(lambda x: True, MagicMock(name="CSVCompliance"))]},
|
||||
{"aws": [(lambda _x: True, MagicMock(name="CSVCompliance"))]},
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks._generate_output_directory",
|
||||
@@ -361,7 +392,7 @@ class TestGenerateOutputs:
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks.COMPLIANCE_CLASS_MAP",
|
||||
{"aws": [(lambda x: True, MagicMock())]},
|
||||
{"aws": [(lambda _x: True, MagicMock())]},
|
||||
),
|
||||
patch("tasks.tasks._compress_output_files", return_value="/tmp/compressed"),
|
||||
patch("tasks.tasks._upload_to_s3", return_value=None),
|
||||
@@ -441,7 +472,7 @@ class TestGenerateOutputs:
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks.COMPLIANCE_CLASS_MAP",
|
||||
{"aws": [(lambda x: True, mock_compliance_class)]},
|
||||
{"aws": [(lambda _x: True, mock_compliance_class)]},
|
||||
),
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
@@ -470,6 +501,10 @@ class TestGenerateOutputs:
|
||||
|
||||
class TrackingWriter:
|
||||
def __init__(self, findings, file_path, file_extension, from_cli):
|
||||
self.findings = findings
|
||||
self.file_path = file_path
|
||||
self.file_extension = file_extension
|
||||
self.from_cli = from_cli
|
||||
self.transform_called = 0
|
||||
self.batch_write_data_to_file = MagicMock()
|
||||
self._data = []
|
||||
@@ -578,13 +613,13 @@ class TestGenerateOutputs:
|
||||
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
|
||||
patch(
|
||||
"tasks.tasks.FindingOutput.transform_api_finding",
|
||||
side_effect=lambda f, prov: f,
|
||||
side_effect=lambda f, _prov: f,
|
||||
),
|
||||
patch("tasks.tasks._compress_output_files", return_value="outdir.zip"),
|
||||
patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/outdir.zip"),
|
||||
patch(
|
||||
"tasks.tasks.Scan.all_objects.filter",
|
||||
return_value=MagicMock(update=lambda **kw: None),
|
||||
return_value=MagicMock(update=lambda **_kw: None),
|
||||
),
|
||||
patch("tasks.tasks.batched", return_value=two_batches),
|
||||
patch("tasks.tasks.OUTPUT_FORMATS_MAPPING", {}),
|
||||
@@ -666,7 +701,7 @@ class TestGenerateOutputs:
|
||||
),
|
||||
patch(
|
||||
"tasks.tasks.COMPLIANCE_CLASS_MAP",
|
||||
{"aws": [(lambda x: True, mock_compliance_class)]},
|
||||
{"aws": [(lambda _x: True, mock_compliance_class)]},
|
||||
),
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
@@ -748,7 +783,7 @@ class TestScanCompleteTasks:
|
||||
@patch("tasks.tasks.can_provider_run_attack_paths_scan", return_value=False)
|
||||
def test_scan_complete_tasks(
|
||||
self,
|
||||
mock_can_run_attack_paths,
|
||||
_mock_can_run_attack_paths,
|
||||
mock_attack_paths_task,
|
||||
mock_check_integrations_task,
|
||||
mock_compliance_reports_task,
|
||||
@@ -807,6 +842,72 @@ class TestScanCompleteTasks:
|
||||
# Attack Paths task should be skipped when provider cannot run it
|
||||
mock_attack_paths_task.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"row_pre_existing",
|
||||
[True, False],
|
||||
ids=["row-pre-existing", "row-missing-fallback"],
|
||||
)
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.create_compliance_requirements_task.si")
|
||||
@patch("tasks.tasks.update_provider_compliance_scores_task.si")
|
||||
@patch("tasks.tasks.perform_scan_summary_task.si")
|
||||
@patch("tasks.tasks.generate_outputs_task.si")
|
||||
@patch("tasks.tasks.generate_compliance_reports_task.si")
|
||||
@patch("tasks.tasks.check_integrations_task.si")
|
||||
@patch("tasks.tasks.attack_paths_db_utils.set_attack_paths_scan_task_id")
|
||||
@patch("tasks.tasks.attack_paths_db_utils.create_attack_paths_scan")
|
||||
@patch("tasks.tasks.attack_paths_db_utils.retrieve_attack_paths_scan")
|
||||
@patch("tasks.tasks.perform_attack_paths_scan_task.apply_async")
|
||||
@patch("tasks.tasks.can_provider_run_attack_paths_scan", return_value=True)
|
||||
def test_scan_complete_dispatches_attack_paths_scan(
|
||||
self,
|
||||
_mock_can_run_attack_paths,
|
||||
mock_attack_paths_task,
|
||||
mock_retrieve,
|
||||
mock_create,
|
||||
mock_set_task_id,
|
||||
mock_check_integrations_task,
|
||||
mock_compliance_reports_task,
|
||||
mock_outputs_task,
|
||||
mock_scan_summary_task,
|
||||
mock_update_compliance_scores_task,
|
||||
mock_compliance_requirements_task,
|
||||
mock_chain,
|
||||
mock_attack_surface_task,
|
||||
row_pre_existing,
|
||||
):
|
||||
"""When a provider can run Attack Paths, dispatch must:
|
||||
1. Reuse the existing row or create one if missing.
|
||||
2. Call apply_async on the Attack Paths task.
|
||||
3. Persist the returned Celery task id on the row.
|
||||
"""
|
||||
existing_row = MagicMock(id="ap-scan-id")
|
||||
if row_pre_existing:
|
||||
mock_retrieve.return_value = existing_row
|
||||
else:
|
||||
mock_retrieve.return_value = None
|
||||
mock_create.return_value = existing_row
|
||||
|
||||
async_result = MagicMock(task_id="celery-task-id")
|
||||
mock_attack_paths_task.return_value = async_result
|
||||
|
||||
_perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id")
|
||||
|
||||
mock_retrieve.assert_called_once_with("tenant-id", "scan-id")
|
||||
if row_pre_existing:
|
||||
mock_create.assert_not_called()
|
||||
else:
|
||||
mock_create.assert_called_once_with("tenant-id", "scan-id", "provider-id")
|
||||
|
||||
mock_attack_paths_task.assert_called_once_with(
|
||||
kwargs={"tenant_id": "tenant-id", "scan_id": "scan-id"}
|
||||
)
|
||||
|
||||
mock_set_task_id.assert_called_once_with(
|
||||
"tenant-id", "ap-scan-id", "celery-task-id"
|
||||
)
|
||||
|
||||
|
||||
class TestAttackPathsTasks:
|
||||
@staticmethod
|
||||
@@ -994,7 +1095,7 @@ class TestCheckIntegrationsTask:
|
||||
@patch("tasks.tasks.rmtree")
|
||||
def test_generate_outputs_with_asff_for_aws_with_security_hub(
|
||||
self,
|
||||
mock_rmtree,
|
||||
_mock_rmtree,
|
||||
mock_scan_update,
|
||||
mock_upload,
|
||||
mock_compress,
|
||||
@@ -1122,7 +1223,7 @@ class TestCheckIntegrationsTask:
|
||||
@patch("tasks.tasks.rmtree")
|
||||
def test_generate_outputs_no_asff_for_aws_without_security_hub(
|
||||
self,
|
||||
mock_rmtree,
|
||||
_mock_rmtree,
|
||||
mock_scan_update,
|
||||
mock_upload,
|
||||
mock_compress,
|
||||
@@ -1245,7 +1346,7 @@ class TestCheckIntegrationsTask:
|
||||
@patch("tasks.tasks.rmtree")
|
||||
def test_generate_outputs_no_asff_for_non_aws_provider(
|
||||
self,
|
||||
mock_rmtree,
|
||||
_mock_rmtree,
|
||||
mock_scan_update,
|
||||
mock_upload,
|
||||
mock_compress,
|
||||
@@ -2359,11 +2460,26 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
def setup_method(self):
|
||||
self.tenant_id = str(uuid.uuid4())
|
||||
|
||||
@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_dispatches_subtasks_for_each_provider_per_day(
|
||||
self, mock_scan_filter, mock_agg_task, mock_group
|
||||
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,
|
||||
):
|
||||
provider_id_1 = uuid.uuid4()
|
||||
provider_id_2 = uuid.uuid4()
|
||||
@@ -2373,8 +2489,13 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
today = datetime.now(tz=timezone.utc)
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
mock_group_result = MagicMock()
|
||||
mock_group.side_effect = lambda gen: (list(gen), mock_group_result)[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 = [
|
||||
{
|
||||
@@ -2397,23 +2518,49 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 3}
|
||||
assert mock_agg_task.si.call_count == 3
|
||||
mock_agg_task.si.assert_any_call(
|
||||
tenant_id=self.tenant_id, scan_id=str(scan_id_today_p1)
|
||||
)
|
||||
mock_agg_task.si.assert_any_call(
|
||||
tenant_id=self.tenant_id, scan_id=str(scan_id_today_p2)
|
||||
)
|
||||
mock_agg_task.si.assert_any_call(
|
||||
tenant_id=self.tenant_id, scan_id=str(scan_id_yesterday_p1)
|
||||
)
|
||||
mock_group_result.apply_async.assert_called_once()
|
||||
expected_scan_ids = {
|
||||
str(scan_id_today_p1),
|
||||
str(scan_id_today_p2),
|
||||
str(scan_id_yesterday_p1),
|
||||
}
|
||||
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_agg_task, mock_group
|
||||
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)."""
|
||||
@@ -2423,8 +2570,11 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
today_late = datetime.now(tz=timezone.utc)
|
||||
today_early = today_late - timedelta(hours=4)
|
||||
|
||||
mock_group_result = MagicMock()
|
||||
mock_group.side_effect = lambda gen: (list(gen), mock_group_result)[1]
|
||||
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 = [
|
||||
@@ -2443,17 +2593,30 @@ class TestReaggregateAllFindingGroupSummaries:
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 1}
|
||||
mock_agg_task.si.assert_called_once_with(
|
||||
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
|
||||
)
|
||||
mock_group_result.apply_async.assert_called_once()
|
||||
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,
|
||||
):
|
||||
task_mock.si.assert_called_once_with(
|
||||
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
|
||||
)
|
||||
mock_chain.assert_called_once()
|
||||
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):
|
||||
def test_no_completed_scans_skips_dispatch(
|
||||
self, mock_scan_filter, mock_group, mock_chain
|
||||
):
|
||||
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
|
||||
|
||||
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
|
||||
|
||||
assert result == {"scans_reaggregated": 0}
|
||||
mock_group.assert_not_called()
|
||||
mock_chain.assert_not_called()
|
||||
|
||||
@@ -203,10 +203,10 @@ For detailed authentication configuration, see the [Authentication documentation
|
||||
|
||||
## Regions
|
||||
|
||||
Alibaba Cloud has multiple regions across the globe. By default, Prowler audits all available regions. You can specify specific regions using the `--regions` CLI argument:
|
||||
Alibaba Cloud has multiple regions across the globe. By default, Prowler audits all available regions. You can specify specific regions using the `--region` CLI argument:
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --regions cn-hangzhou cn-shanghai
|
||||
prowler alibabacloud --region cn-hangzhou cn-shanghai
|
||||
```
|
||||
|
||||
The list of supported regions is maintained in [`prowler/providers/alibabacloud/config.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/alibabacloud/config.py).
|
||||
|
||||
@@ -215,3 +215,6 @@ Also is important to keep all code examples as short as possible, including the
|
||||
| e5 | M365 and Azure Entra checks enabled by or dependent on an E5 license (e.g., advanced threat protection, audit, DLP, and eDiscovery) |
|
||||
| privilege-escalation | Detects IAM policies or permissions that allow identities to elevate their privileges beyond their intended scope, potentially gaining administrator or higher-level access through specific action combinations |
|
||||
| ec2-imdsv1 | Identifies EC2 instances using Instance Metadata Service version 1 (IMDSv1), which is vulnerable to SSRF attacks and should be replaced with IMDSv2 for enhanced security |
|
||||
| vercel-hobby-plan | Vercel checks whose audited feature is available on the Hobby plan (and therefore also on Pro and Enterprise plans) |
|
||||
| vercel-pro-plan | Vercel checks whose audited feature requires a Pro plan or higher, including features also available on Enterprise or via supported paid add-ons for Pro plans |
|
||||
| vercel-enterprise-plan | Vercel checks whose audited feature requires the Enterprise plan |
|
||||
|
||||
@@ -27,14 +27,28 @@ The most common high level steps to create a new check are:
|
||||
|
||||
### Naming Format for Checks
|
||||
|
||||
Checks must be named following the format: `service_subservice_resource_action`.
|
||||
If you already know the check name when creating a request or implementing a check, use a descriptive identifier with lowercase letters and underscores only.
|
||||
|
||||
Recommended patterns:
|
||||
|
||||
- `<service>_<resource>_<best_practice>`
|
||||
|
||||
The name components are:
|
||||
|
||||
- `service` – The main service being audited (e.g., ec2, entra, iam, etc.)
|
||||
- `subservice` – An individual component or subset of functionality within the service that is being audited. This may correspond to a shortened version of the class attribute accessed within the check. If there is no subservice, just omit.
|
||||
- `resource` – The specific resource type being evaluated (e.g., instance, policy, role, etc.)
|
||||
- `action` – The security aspect or configuration being checked (e.g., public, encrypted, enabled, etc.)
|
||||
- `service` – The main service or product area being audited (e.g., ec2, entra, iam, bedrock).
|
||||
- `resource` – The resource, feature, or configuration being evaluated. It can be a single word or a compound phrase joined with underscores (e.g., instance, policy, guardrail, sensitive_information_filter).
|
||||
- `best_practice` – The expected secure state or best practice being checked (e.g., enabled, encrypted, restricted, configured, not_publicly_accessible).
|
||||
|
||||
Additional guidance:
|
||||
|
||||
- Use underscores only. Do not use hyphens.
|
||||
- Keep the name specific enough to describe the behavior of the check.
|
||||
- The first segment should match the service or product area whenever possible.
|
||||
|
||||
Examples:
|
||||
|
||||
- `s3_bucket_versioning_enabled`
|
||||
- `bedrock_guardrail_sensitive_information_filter_enabled`
|
||||
|
||||
### File Creation
|
||||
|
||||
@@ -387,7 +401,7 @@ Provides both code examples and best practice recommendations for addressing the
|
||||
|
||||
#### Categories
|
||||
|
||||
One or more functional groupings used for execution filtering (e.g., `internet-exposed`). You can define new categories just by adding to this field.
|
||||
One or more functional groupings used for execution filtering (e.g., `internet-exposed`). Categories must match the predefined values enforced by `CheckMetadata`; adding a new category requires updating the validator and the metadata documentation.
|
||||
|
||||
For the complete list of available categories, see [Categories Guidelines](/developer-guide/check-metadata-guidelines#categories-guidelines).
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: 'Prowler Studio'
|
||||
---
|
||||
|
||||
**Prowler Studio is an AI workflow that ensures Claude Code follows Prowler's skills, guardrails, and best practices when creating new security checks.** What lands in the resulting pull request is consistent, tested, and ready for human review — not half-correct boilerplate that needs to be rewritten.
|
||||
|
||||
<Info>
|
||||
**Contributor Tool**: Prowler Studio is a workflow for advanced contributors adding new Prowler security checks. It is not part of Prowler Cloud, Prowler App, or Prowler CLI.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
**Preview Feature**: Prowler Studio is under active development and breaking changes are expected. Please report issues or share feedback on [GitHub](https://github.com/prowler-cloud/prowler-studio/issues) or in the [Slack community](https://goto.prowler.com/slack).
|
||||
</Warning>
|
||||
|
||||
<Card title="Prowler Studio Repository" icon="github" href="https://github.com/prowler-cloud/prowler-studio" horizontal>
|
||||
Clone the source code, install Prowler Studio, and explore the agent workflow in detail.
|
||||
</Card>
|
||||
|
||||
## The Problem
|
||||
|
||||
Adding a new check to [Prowler](https://github.com/prowler-cloud/prowler) is more than writing detection logic. A correct check has to:
|
||||
|
||||
- Match Prowler's exact service and check folder structure and naming conventions
|
||||
- Wire up metadata, severity, remediation, tests, and compliance mappings
|
||||
- Mirror the patterns used by the hundreds of existing checks in the same provider
|
||||
- Actually load when Prowler scans for available checks — silent structural mistakes are easy to make
|
||||
|
||||
Asking a general-purpose AI assistant to do this usually means guessing. It misses conventions, skips tests, or invents structure that looks right but does not load. The result is a half-correct PR that needs to be reviewed line by line or rewritten.
|
||||
|
||||
## The Solution
|
||||
|
||||
Prowler Studio enforces the workflow end-to-end. Describe the check once — a markdown ticket, a Jira issue, or a GitHub issue — and the workflow:
|
||||
|
||||
1. **Loads Prowler-specific skills into every agent.** Every step starts with the same context an experienced Prowler engineer would have in mind. See [AI Skills System](/developer-guide/ai-skills) for how skills are structured.
|
||||
2. **Runs specialized agents in sequence.** Implementation → testing → compliance mapping → review → PR creation. Each agent has one job and a tight scope.
|
||||
3. **Verifies as it goes.** The check must load in Prowler. Tests must pass. If something fails, the agent fixes it and re-runs (up to a bounded number of attempts) before moving on.
|
||||
4. **Produces a complete pull request.** Branch, passing check, tests, compliance mappings, and a pull request waiting for human review.
|
||||
|
||||
The result is a consistent starting point, every time, on every supported provider.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install
|
||||
|
||||
Prowler Studio requires [`uv`](https://docs.astral.sh/uv/getting-started/installation/) — see the official [installation guide](https://docs.astral.sh/uv/getting-started/installation/).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/prowler-cloud/prowler-studio
|
||||
cd prowler-studio
|
||||
uv sync
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Describe the Check
|
||||
|
||||
A ticket is a structured markdown description of the check to create. It is the only input the workflow needs; every agent (implementation, testing, compliance mapping, review, PR creation) uses it as the source of truth, so the more concrete it is, the closer the first PR will land to the desired outcome.
|
||||
|
||||
The ticket can be supplied in three ways:
|
||||
|
||||
- **Local markdown file** → `--ticket path/to/ticket.md`
|
||||
- **Jira issue** → `--jira-url https://...` (uses the issue body)
|
||||
- **GitHub issue** → `--github-url https://...` (uses the issue body)
|
||||
|
||||
The content should follow the **New Check Request** template:
|
||||
|
||||
- The local copy at [`check_ticket_template.md`](https://github.com/prowler-cloud/prowler-studio/blob/main/check_ticket_template.md) covers `--ticket` and Jira tickets.
|
||||
- A prefilled GitHub form is also available: [Create a New Check Request issue](https://github.com/prowler-cloud/prowler/issues/new?template=new-check-request.yml).
|
||||
|
||||
Sections marked *Optional* can be skipped; everything else helps the agents make the right decisions.
|
||||
|
||||
### Run the Workflow
|
||||
|
||||
From a local markdown ticket:
|
||||
|
||||
```bash
|
||||
prowler-studio --ticket check_ticket.md
|
||||
```
|
||||
|
||||
From a Jira ticket:
|
||||
|
||||
```bash
|
||||
prowler-studio --jira-url https://mycompany.atlassian.net/browse/PROJ-123
|
||||
```
|
||||
|
||||
From a GitHub issue:
|
||||
|
||||
```bash
|
||||
prowler-studio --github-url https://github.com/owner/repo/issues/123
|
||||
```
|
||||
|
||||
<Note>
|
||||
Provide exactly one of `--ticket`, `--jira-url`, or `--github-url`.
|
||||
</Note>
|
||||
|
||||
Keep changes local (no push, no pull request):
|
||||
|
||||
```bash
|
||||
prowler-studio -b feat/my-check --ticket check_ticket.md --local
|
||||
```
|
||||
|
||||
### What You Get
|
||||
|
||||
After a successful run the working environment contains:
|
||||
|
||||
- A new branch on a clean Prowler worktree containing the check, metadata, tests, and compliance mappings
|
||||
- A pull request opened against Prowler (skipped with `--local`)
|
||||
- A timestamped log file under `logs/` capturing every step the agents took
|
||||
|
||||
## CLI Options
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--branch` | `-b` | Branch name (default: `feat/<ticket>-<check_name>` or `feat/<check_name>`) |
|
||||
| `--ticket` | `-t` | Path to a markdown check ticket file |
|
||||
| `--jira-url` | `-j` | Jira ticket URL (e.g., `https://mycompany.atlassian.net/browse/PROJ-123`) |
|
||||
| `--github-url` | `-g` | GitHub issue URL (e.g., `https://github.com/owner/repo/issues/123`) |
|
||||
| `--working-dir` | `-w` | Working directory for the Prowler clone (default: `./working`) |
|
||||
| `--no-worktree` | | Legacy mode — work directly on the main clone instead of using worktrees |
|
||||
| `--cleanup-worktree` | | Remove the worktree after a successful pull request is created |
|
||||
| `--local` | | Keep changes local — skip push and pull request creation |
|
||||
|
||||
## Configuration
|
||||
|
||||
Set these environment variables depending on the input source:
|
||||
|
||||
| Variable | When Needed | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| `GITHUB_TOKEN` | `--github-url` (recommended) | Higher GitHub API rate limits and access to private issues |
|
||||
| `JIRA_SITE_URL` | `--jira-url` | Jira site, e.g. `https://mycompany.atlassian.net` |
|
||||
| `JIRA_EMAIL` | `--jira-url` | Email of the Jira account used to fetch the ticket |
|
||||
| `JIRA_API_TOKEN` | `--jira-url` | API token for the Jira account |
|
||||
+9
-1
@@ -164,6 +164,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "CI/CD",
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app-github-action",
|
||||
"user-guide/cookbooks/cicd-pipeline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "CLI",
|
||||
"pages": [
|
||||
@@ -358,7 +365,8 @@
|
||||
"developer-guide/security-compliance-framework",
|
||||
"developer-guide/lighthouse-architecture",
|
||||
"developer-guide/mcp-server",
|
||||
"developer-guide/ai-skills"
|
||||
"developer-guide/ai-skills",
|
||||
"developer-guide/prowler-studio"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -121,8 +121,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.24.0"
|
||||
PROWLER_API_VERSION="5.24.0"
|
||||
PROWLER_UI_VERSION="5.25.1"
|
||||
PROWLER_API_VERSION="5.25.1"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -159,6 +159,40 @@ When these environment variables are set, the API will use them directly instead
|
||||
A fix addressing this permission issue is being evaluated in [PR #9953](https://github.com/prowler-cloud/prowler/pull/9953).
|
||||
</Note>
|
||||
|
||||
### Scan Stuck in Executing State After Worker Crash
|
||||
|
||||
When running Prowler App via Docker Compose, a scan may remain indefinitely in the `executing` state if the worker process crashes (for example, due to an Out of Memory condition) before it can update the scan status. Since it is not currently possible to cancel a scan in `executing` state through the UI, the workaround is to manually update the scan record in the database.
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
The Celery worker process terminates unexpectedly (OOM, node failure, etc.) before transitioning the scan state to `completed` or `failed`. The scan record remains in `executing` with no active process to advance it.
|
||||
|
||||
**Solution:**
|
||||
|
||||
Connect to the database using the `prowler_admin` user. Due to Row-Level Security (RLS), the default database user cannot see scan records — you must use `prowler_admin`:
|
||||
|
||||
```bash
|
||||
psql -U prowler_admin -d prowler_db
|
||||
```
|
||||
|
||||
Identify the stuck scan by filtering for scans in `executing` state:
|
||||
|
||||
```sql
|
||||
SELECT id, name, state, started_at FROM scans WHERE state = 'executing';
|
||||
```
|
||||
|
||||
Update the scan state to `failed` using the scan ID:
|
||||
|
||||
```sql
|
||||
UPDATE scans SET state = 'failed' WHERE id = '<scan-id>';
|
||||
```
|
||||
|
||||
After this change, the scan will appear as failed in the UI and you can launch a new scan.
|
||||
|
||||
<Note>
|
||||
A feature to cancel executing scans directly from the UI is being tracked in [GitHub Issue #6893](https://github.com/prowler-cloud/prowler/issues/6893).
|
||||
</Note>
|
||||
|
||||
### SAML/OAuth ACS URL Incorrect When Running Behind a Proxy or Load Balancer
|
||||
|
||||
See [GitHub Issue #9724](https://github.com/prowler-cloud/prowler/issues/9724) for more details.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
title: 'Prowler Check Kreator'
|
||||
---
|
||||
|
||||
<Note>
|
||||
Currently, this tool is only available for creating checks for the AWS provider.
|
||||
|
||||
</Note>
|
||||
<Note>
|
||||
If you are looking for a way to create new checks for all the supported providers, you can use [Prowler Studio](https://github.com/prowler-cloud/prowler-studio), it is an AI-powered toolkit for generating and managing security checks for Prowler (better version of the Check Kreator).
|
||||
|
||||
</Note>
|
||||
## Introduction
|
||||
|
||||
**Prowler Check Kreator** is a utility designed to streamline the creation of new checks for Prowler. This tool generates all necessary files required to add a new check to the Prowler repository. Specifically, it creates:
|
||||
|
||||
- A dedicated folder for the check.
|
||||
- The main check script.
|
||||
- A metadata file with essential details.
|
||||
- A folder and file structure for testing the check.
|
||||
|
||||
## Usage
|
||||
|
||||
To use the tool, execute the main script with the following command:
|
||||
|
||||
```bash
|
||||
python util/prowler_check_kreator/prowler_check_kreator.py <prowler_provider> <check_name>
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `<prowler_provider>`: Currently only AWS is supported.
|
||||
- `<check_name>`: The name you wish to assign to the new check.
|
||||
|
||||
## AI integration
|
||||
|
||||
This tool optionally integrates AI to assist in generating the check code and metadata file content. When AI assistance is chosen, the tool uses [Gemini](https://gemini.google.com/) to produce preliminary code and metadata.
|
||||
|
||||
<Note>
|
||||
For this feature to work, you must have the library `google-generativeai` installed in your Python environment.
|
||||
|
||||
</Note>
|
||||
<Warning>
|
||||
AI-generated code and metadata might contain errors or require adjustments to align with specific Prowler requirements. Carefully review all AI-generated content before committing.
|
||||
|
||||
</Warning>
|
||||
To enable AI assistance, simply confirm when prompted by the tool. Additionally, ensure that the `GEMINI_API_KEY` environment variable is set with a valid Gemini API key. For instructions on obtaining your API key, refer to the [Gemini documentation](https://ai.google.dev/gemini-api/docs/api-key).
|
||||
@@ -2,6 +2,10 @@
|
||||
title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud'
|
||||
---
|
||||
|
||||
<Warning>
|
||||
For new projects, use the official [Prowler GitHub Action](/user-guide/tutorials/prowler-app-github-action) — a Docker-based reusable action that runs scans, optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning. The GitHub Actions examples below document the legacy pip-based flow.
|
||||
</Warning>
|
||||
|
||||
This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). Examples cover GitHub Actions and GitLab CI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: 'Alibaba Cloud Authentication in Prowler'
|
||||
---
|
||||
|
||||
Prowler requires Alibaba Cloud credentials to perform security checks. Authentication is supported via multiple methods, prioritized as follows:
|
||||
Prowler supports multiple Alibaba Cloud authentication flows. If more than one is configured at the same time, the provider resolves them in this order:
|
||||
|
||||
1. **Credentials URI**
|
||||
2. **OIDC Role Authentication**
|
||||
@@ -12,119 +12,325 @@ Prowler requires Alibaba Cloud credentials to perform security checks. Authentic
|
||||
6. **Permanent Access Keys**
|
||||
7. **Default Credential Chain**
|
||||
|
||||
## Authentication Methods
|
||||
<Warning>
|
||||
Do not use the AccessKey pair of the main Alibaba Cloud account for Prowler. Use a RAM user, a RAM role, or another temporary credential flow instead.
|
||||
</Warning>
|
||||
|
||||
### Credentials URI (Recommended for Centralized Services)
|
||||
## Choose The Right Method
|
||||
|
||||
Prowler can retrieve credentials from an external URI endpoint. Provide the URI via the `--credentials-uri` flag or the `ALIBABA_CLOUD_CREDENTIALS_URI` environment variable. The URI must return credentials in the standard JSON format.
|
||||
| Where Prowler runs | What you need to create | Recommended method |
|
||||
| --- | --- | --- |
|
||||
| Local workstation | RAM user + AccessKey pair | [RAM User And AccessKey](#ram-user-and-accesskey) |
|
||||
| CI runner outside Alibaba Cloud | RAM user + AccessKey pair, optionally a target RAM role | [RAM Role Assumption](#ram-role-assumption-recommended) |
|
||||
| ECS instance | ECS RAM role attached to the instance | [ECS RAM Role](#ecs-ram-role) |
|
||||
| ACK / Kubernetes | OIDC IdP + RAM role + OIDC token file | [OIDC Role Authentication](#oidc-role-authentication) |
|
||||
| Internal credential broker | An HTTP endpoint that returns STS credentials | [Credentials URI](#credentials-uri) |
|
||||
|
||||
## RAM User And AccessKey
|
||||
|
||||
This is the simplest setup for a workstation or a basic CI runner.
|
||||
|
||||
### Create The RAM User
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Identities` > `Users`.
|
||||
3. Click `Create User`.
|
||||
4. Enter a logon name and display name.
|
||||
5. In `Access Configuration`, select `Permanent AccessKey`.
|
||||
|
||||

|
||||
|
||||
6. Save the generated `AccessKey ID` and `AccessKey Secret` immediately. Alibaba Cloud only shows the secret once.
|
||||
7. Grant the user the read permissions required for the Alibaba Cloud services you want Prowler to scan.
|
||||
|
||||

|
||||
|
||||
Alibaba Cloud walkthroughs with current console screenshots:
|
||||
|
||||
- [Create a RAM user](https://www.alibabacloud.com/help/en/ram/user-guide/create-a-ram-user)
|
||||
- [Create an AccessKey pair](https://www.alibabacloud.com/help/en/ram/user-guide/create-an-accesskey-pair)
|
||||
- [Grant permissions to a RAM user](https://www.alibabacloud.com/help/en/ram/user-guide/grant-permissions-to-the-ram-user)
|
||||
|
||||
### Use The AccessKey With Prowler
|
||||
|
||||
```bash
|
||||
# Using CLI flag
|
||||
prowler alibabacloud --credentials-uri http://localhost:8080/credentials
|
||||
|
||||
# Or using environment variable
|
||||
export ALIBABA_CLOUD_CREDENTIALS_URI="http://localhost:8080/credentials"
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
### OIDC Role Authentication (Recommended for ACK/Kubernetes)
|
||||
|
||||
OIDC authentication assumes the specified role using an OIDC token. This is the most secure method for containerized applications running in ACK (Alibaba Container Service for Kubernetes) with RRSA enabled.
|
||||
|
||||
The role ARN can be provided via the `--oidc-role-arn` flag or the `ALIBABA_CLOUD_ROLE_ARN` environment variable. The OIDC provider ARN and token file must be set via environment variables:
|
||||
|
||||
- `ALIBABA_CLOUD_OIDC_PROVIDER_ARN`
|
||||
- `ALIBABA_CLOUD_OIDC_TOKEN_FILE`
|
||||
|
||||
```bash
|
||||
# Using CLI flag for role ARN
|
||||
export ALIBABA_CLOUD_OIDC_PROVIDER_ARN="acs:ram::123456789012:oidc-provider/ack-rrsa-provider"
|
||||
export ALIBABA_CLOUD_OIDC_TOKEN_FILE="/var/run/secrets/tokens/oidc-token"
|
||||
prowler alibabacloud --oidc-role-arn acs:ram::123456789012:role/YourRole
|
||||
|
||||
# Or using all environment variables
|
||||
export ALIBABA_CLOUD_ROLE_ARN="acs:ram::123456789012:role/YourRole"
|
||||
export ALIBABA_CLOUD_OIDC_PROVIDER_ARN="acs:ram::123456789012:oidc-provider/ack-rrsa-provider"
|
||||
export ALIBABA_CLOUD_OIDC_TOKEN_FILE="/var/run/secrets/tokens/oidc-token"
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
### ECS RAM Role (Recommended for ECS Instances)
|
||||
|
||||
When running on an ECS instance with an attached RAM role, Prowler can obtain credentials from the ECS instance metadata service.
|
||||
|
||||
```bash
|
||||
# Using CLI argument
|
||||
prowler alibabacloud --ecs-ram-role RoleName
|
||||
|
||||
# Or using environment variable
|
||||
export ALIBABA_CLOUD_ECS_METADATA="RoleName"
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
### RAM Role Assumption (Recommended for Cross-Account)
|
||||
|
||||
For cross-account access, use RAM role assumption. Provide the initial credentials (access keys) via environment variables and the target role ARN via the `--role-arn` flag or the `ALIBABA_CLOUD_ROLE_ARN` environment variable.
|
||||
|
||||
The `--role-session-name` flag customizes the session identifier (defaults to `ProwlerAssessmentSession`).
|
||||
|
||||
```bash
|
||||
# Using CLI flags
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id"
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret"
|
||||
prowler alibabacloud --role-arn acs:ram::123456789012:role/ProwlerAuditRole --role-session-name MyAuditSession
|
||||
|
||||
# Or using all environment variables
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id"
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret"
|
||||
export ALIBABA_CLOUD_ROLE_ARN="acs:ram::123456789012:role/ProwlerAuditRole"
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
### STS Temporary Credentials
|
||||
Prowler also accepts `ALIYUN_ACCESS_KEY_ID` and `ALIYUN_ACCESS_KEY_SECRET` for compatibility, but `ALIBABA_CLOUD_*` is the preferred naming.
|
||||
|
||||
If you already have temporary STS credentials, you can provide them via environment variables.
|
||||
### Use The Default Credential Chain
|
||||
|
||||
If you prefer not to export credentials in every shell, you can store them with the Alibaba Cloud CLI and let Prowler reuse the default credential chain from `~/.aliyun/config.json`.
|
||||
|
||||
```bash
|
||||
aliyun configure --mode AK
|
||||
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
For profile management details, see Alibaba Cloud's [CLI credential management guide](https://www.alibabacloud.com/help/en/cli/other-configure-command-operations).
|
||||
|
||||
## RAM Role Assumption (Recommended)
|
||||
|
||||
Use this when:
|
||||
|
||||
- you want short-lived credentials instead of long-lived AccessKeys in Prowler,
|
||||
- you are scanning another Alibaba Cloud account, or
|
||||
- you are configuring Alibaba Cloud in Prowler Cloud and want to provide a `Role ARN`.
|
||||
|
||||
This flow has two parts:
|
||||
|
||||
1. A source identity that can call `sts:AssumeRole`.
|
||||
2. A target RAM role that has the scan permissions.
|
||||
|
||||
### Create The Source Identity
|
||||
|
||||
Create a RAM user with an AccessKey pair by following the steps in [RAM User And AccessKey](#ram-user-and-accesskey), or reuse an existing automation identity.
|
||||
|
||||
### Create The Target Role
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Identities` > `Roles`.
|
||||
3. Click `Create Role`.
|
||||
4. Set `Principal Type` to `Cloud Account`.
|
||||
5. Choose:
|
||||
- `Current Account` if the RAM user and the role are in the same account.
|
||||
- `Other Account` if the RAM user belongs to a different Alibaba Cloud account.
|
||||
6. Give the role a name such as `ProwlerAuditRole`.
|
||||
7. Attach the scan permissions to the role.
|
||||
8. Copy the role ARN in the format `acs:ram::<account-id>:role/<role-name>`.
|
||||
|
||||
If you want to restrict the role so that only one RAM user or one RAM role can assume it, edit the trust policy accordingly.
|
||||
|
||||
Helpful references:
|
||||
|
||||
- [Create a RAM role for a trusted Alibaba Cloud account](https://www.alibabacloud.com/help/en/ram/user-guide/create-a-ram-role-for-a-trusted-alibaba-cloud-account)
|
||||
- [Assume a RAM role](https://www.alibabacloud.com/help/doc-detail/116820.html)
|
||||
|
||||
### Allow The Source Identity To Assume The Role
|
||||
|
||||
The source RAM user must be able to call `sts:AssumeRole`.
|
||||
|
||||
The easiest starting point is to attach Alibaba Cloud's `AliyunSTSAssumeRoleAccess` policy to that RAM user. If you want tighter scope, attach a custom policy limited to the target role ARN.
|
||||
|
||||
### Run Prowler
|
||||
|
||||
```bash
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID="source-user-access-key-id"
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET="source-user-access-key-secret"
|
||||
|
||||
prowler alibabacloud \
|
||||
--role-arn acs:ram::123456789012:role/ProwlerAuditRole \
|
||||
--role-session-name ProwlerAssessmentSession
|
||||
```
|
||||
|
||||
You can also set the role ARN with `ALIBABA_CLOUD_ROLE_ARN`, but the source AccessKey pair is still required for this flow.
|
||||
|
||||
## STS Temporary Credentials
|
||||
|
||||
Use this if another tool already gives you a temporary `AccessKey ID`, `AccessKey Secret`, and `SecurityToken`.
|
||||
|
||||
This is common when:
|
||||
|
||||
- a CI platform brokers Alibaba credentials for the job,
|
||||
- your internal tooling already calls `AssumeRole`, or
|
||||
- you want to test with a short-lived session before switching to a RAM role flow.
|
||||
|
||||
```bash
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID="your-sts-access-key-id"
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-sts-access-key-secret"
|
||||
export ALIBABA_CLOUD_SECURITY_TOKEN="your-sts-security-token"
|
||||
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
### Permanent Access Keys
|
||||
|
||||
You can use standard permanent access keys via environment variables.
|
||||
You can also store the session in the Alibaba CLI configuration:
|
||||
|
||||
```bash
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID="your-access-key-id"
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret"
|
||||
aliyun configure --mode StsToken
|
||||
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
## Required Permissions
|
||||
<Note>
|
||||
Prowler does not mint standalone STS sessions for you. If you use this method, you must provide all three STS values from your external workflow.
|
||||
</Note>
|
||||
|
||||
The credentials used by Prowler should have the minimum required permissions to audit the resources. At a minimum, the following permissions are recommended:
|
||||
## ECS RAM Role
|
||||
|
||||
- `ram:GetUser`
|
||||
- `ram:ListUsers`
|
||||
- `ram:GetPasswordPolicy`
|
||||
- `ram:GetAccountSummary`
|
||||
- `ram:ListVirtualMFADevices`
|
||||
- `ram:ListGroups`
|
||||
- `ram:ListPolicies`
|
||||
- `ram:ListAccessKeys`
|
||||
- `ram:GetLoginProfile`
|
||||
- `ram:ListPoliciesForUser`
|
||||
- `ram:ListGroupsForUser`
|
||||
- `actiontrail:DescribeTrails`
|
||||
- `oss:GetBucketLogging`
|
||||
- `oss:GetBucketAcl`
|
||||
- `rds:DescribeDBInstances`
|
||||
- `rds:DescribeDBInstanceAttribute`
|
||||
- `ecs:DescribeInstances`
|
||||
- `vpc:DescribeVpcs`
|
||||
- `sls:ListProject`
|
||||
- `sls:ListAlerts`
|
||||
- `sls:ListLogStores`
|
||||
- `sls:GetLogStore`
|
||||
Use this when Prowler runs on an ECS instance and you do not want to store any AccessKeys on disk.
|
||||
|
||||
### Create And Attach The Role
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Identities` > `Roles`.
|
||||
3. Click `Create Role`.
|
||||
4. Set the trusted entity to `Alibaba Cloud Service`.
|
||||
5. Select `ECS` as the trusted service.
|
||||
6. Attach the read permissions required for the scan.
|
||||
7. Attach that RAM role to the ECS instance that runs Prowler.
|
||||
|
||||
Alibaba Cloud guide:
|
||||
|
||||
- [Instance RAM roles](https://www.alibabacloud.com/help/en/doc-detail/54579.html)
|
||||
|
||||
### Run Prowler
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --ecs-ram-role ProwlerEcsRole
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
export ALIBABA_CLOUD_ECS_METADATA="ProwlerEcsRole"
|
||||
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
## OIDC Role Authentication
|
||||
|
||||
Use this when Prowler runs in ACK or another Kubernetes environment that provides an OIDC token file.
|
||||
|
||||
### Create The OIDC Identity Provider
|
||||
|
||||
1. Open the [RAM console](https://ram.console.alibabacloud.com/).
|
||||
2. Go to `Integrations` > `SSO`.
|
||||
3. Select `Role-based SSO`, then the `OIDC` tab.
|
||||
4. Click `Create IdP`.
|
||||
5. Fill in:
|
||||
- `IdP Name`
|
||||
- `Issuer URL`
|
||||
- `Fingerprint`
|
||||
- `Client ID`
|
||||
6. Create the IdP and note its ARN.
|
||||
|
||||
Alibaba Cloud guides:
|
||||
|
||||
- [Manage an OIDC IdP](https://www.alibabacloud.com/help/en/ram/manage-an-oidc-idp)
|
||||
- [Overview of role-based OIDC SSO](https://www.alibabacloud.com/help/en/ram/overview-of-oidc-based-sso)
|
||||
|
||||
### Create The RAM Role Trusted By That IdP
|
||||
|
||||
Create a RAM role whose trusted entity is the OIDC IdP, then attach the scan permissions to that role.
|
||||
|
||||
If you are running in ACK with RRSA, this is typically the role bound to the service account that runs Prowler.
|
||||
|
||||
### Provide The OIDC Variables To Prowler
|
||||
|
||||
Prowler currently expects:
|
||||
|
||||
- `--oidc-role-arn` for the RAM role ARN,
|
||||
- `ALIBABA_CLOUD_OIDC_PROVIDER_ARN` for the OIDC provider ARN,
|
||||
- `ALIBABA_CLOUD_OIDC_TOKEN_FILE` for the token file path.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export ALIBABA_CLOUD_OIDC_PROVIDER_ARN="acs:ram::123456789012:oidc-provider/ack-rrsa-provider"
|
||||
export ALIBABA_CLOUD_OIDC_TOKEN_FILE="/var/run/secrets/ack.alibabacloud.com/rrsa-tokens/token"
|
||||
|
||||
prowler alibabacloud --oidc-role-arn acs:ram::123456789012:role/ProwlerAckRole
|
||||
```
|
||||
|
||||
If you use ACK RRSA, Alibaba's `ack-pod-identity-webhook` can inject the three required environment variables and mount the token file into the pod automatically:
|
||||
|
||||
- [ack-pod-identity-webhook](https://www.alibabacloud.com/help/en/cs/user-guide/ack-pod-identity-webhook)
|
||||
- [Use RRSA to authorize different pods to access different cloud services](https://www.alibabacloud.com/help/doc-detail/356611.html)
|
||||
|
||||
<Note>
|
||||
Even if your pod already exposes `ALIBABA_CLOUD_ROLE_ARN`, use `--oidc-role-arn` with Prowler. The provider currently reads the role ARN for OIDC from the CLI argument.
|
||||
</Note>
|
||||
|
||||
## Credentials URI
|
||||
|
||||
Use this only if you already operate an internal credential broker that returns temporary Alibaba Cloud credentials over HTTP.
|
||||
|
||||
The endpoint must return a JSON body with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"Code": "Success",
|
||||
"AccessKeyId": "STS.xxxxx",
|
||||
"AccessKeySecret": "xxxxx",
|
||||
"SecurityToken": "xxxxx",
|
||||
"Expiration": "2026-04-23T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Run Prowler with:
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --credentials-uri http://localhost:8080/credentials
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
export ALIBABA_CLOUD_CREDENTIALS_URI="http://localhost:8080/credentials"
|
||||
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
For the expected response format, see Alibaba Cloud's SDK guide for [URI credentials](https://www.alibabacloud.com/help/en/sdk/developer-reference/v2-manage-access-credentials).
|
||||
|
||||
## Permissions Guidance
|
||||
|
||||
The exact minimum policy depends on the checks and services you enable.
|
||||
|
||||
If you are using the RAM console's `Grant Permission` screen, search for the **system policy names** below. Alibaba Cloud often uses product policy names that differ from the service name shown in Prowler.
|
||||
|
||||
### System Policies In The RAM Console
|
||||
|
||||
| Prowler use case | Policy name in RAM console | Notes |
|
||||
| --- | --- | --- |
|
||||
| Source user for `--role-arn` | `AliyunSTSAssumeRoleAccess` | Grants `sts:AssumeRole` so the source identity can assume the scan role. |
|
||||
| RAM checks | `AliyunRAMReadOnlyAccess` | Covers RAM read APIs such as users, groups, policies, MFA devices, and account alias. |
|
||||
| ECS checks | `AliyunECSReadOnlyAccess` | Read-only ECS access. |
|
||||
| VPC checks | `AliyunVPCReadOnlyAccess` | Read-only VPC access. |
|
||||
| OSS checks | `AliyunOSSReadOnlyAccess` | Read-only OSS access. |
|
||||
| ActionTrail checks | `AliyunActionTrailReadOnlyAccess` | Read-only ActionTrail access. |
|
||||
| SLS checks | `AliyunLogReadOnlyAccess` | In the RAM console, Simple Log Service appears as `Log`. |
|
||||
| RDS checks | `AliyunRDSReadOnlyAccess` | Read-only RDS access. |
|
||||
| ACK / Container Service checks | `AliyunCSReadOnlyAccess` | In the RAM console, ACK permissions appear under `CS`. |
|
||||
| Security Center checks | `AliyunYundunSASReadOnlyAccess` | In the RAM console, Security Center appears under `Yundun SAS`. |
|
||||
|
||||
### Recommended Starting Point
|
||||
|
||||
For a broad Alibaba Cloud scan, the identity used by Prowler usually needs read access to the services Prowler currently audits, including:
|
||||
|
||||
- `RAM`
|
||||
- `ECS`
|
||||
- `VPC`
|
||||
- `OSS`
|
||||
- `ActionTrail`
|
||||
- `Simple Log Service (SLS)`
|
||||
- `RDS`
|
||||
- `Container Service / ACK`
|
||||
- `Security Center`
|
||||
|
||||
Use the following setup as a practical starting point:
|
||||
|
||||
- If you use **static AccessKeys**, attach the read-only policies above directly to the RAM user used by Prowler.
|
||||
- If you use **RAM role assumption**, attach `AliyunSTSAssumeRoleAccess` to the source RAM user and attach the read-only policies above to the target scan role.
|
||||
- If you use **ECS RAM role** or **OIDC/RRSA**, attach the read-only policies above to the role assumed by Prowler.
|
||||
|
||||
If you prefer a tighter custom policy instead of system policies, the current provider relies on read APIs such as:
|
||||
|
||||
- `ram:Get*`, `ram:List*`
|
||||
- `ecs:Describe*`
|
||||
- `vpc:Describe*`
|
||||
- `oss:Get*`, `oss:List*`
|
||||
- `actiontrail:Describe*`
|
||||
- `log:Get*`, `log:List*`, `log:Query*`
|
||||
- `rds:Describe*`
|
||||
- `cs:Get*`, `cs:List*`, `cs:Describe*`
|
||||
- `yundun-sas:Get*`, `yundun-sas:Describe*`, `yundun-sas:List*`
|
||||
|
||||
<Note>
|
||||
If a service is denied, Prowler can still start, but checks for that service may fail or return incomplete results.
|
||||
</Note>
|
||||
|
||||
@@ -12,9 +12,9 @@ Before you begin, make sure you have:
|
||||
|
||||
1. An **Alibaba Cloud Account ID** (visible in the Alibaba Cloud Console under your profile).
|
||||
2. **Credentials** with appropriate permissions:
|
||||
- **RAM User with Access Keys**: For static credential authentication.
|
||||
- **RAM Role**: For cross-account access using role assumption (recommended).
|
||||
3. The required permissions for Prowler to audit your resources. See the [Alibaba Cloud Authentication](/user-guide/providers/alibabacloud/authentication) guide for the full list of required permissions.
|
||||
- **RAM User with Access Keys**: For local CLI usage or simple CI setups. See [RAM User and AccessKey](/user-guide/providers/alibabacloud/authentication#ram-user-and-accesskey).
|
||||
- **RAM Role**: For role assumption and Prowler Cloud onboarding. See [RAM Role Assumption](/user-guide/providers/alibabacloud/authentication#ram-role-assumption-recommended).
|
||||
3. The required permissions for Prowler to audit your resources. See the [Alibaba Cloud Authentication](/user-guide/providers/alibabacloud/authentication) guide for setup steps and permission guidance.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
|
||||
@@ -64,7 +64,7 @@ After the Account ID is in place, select the authentication method that matches
|
||||
|
||||
#### RAM Role Assumption (Recommended)
|
||||
|
||||
Use this method for secure cross-account access. For detailed instructions on how to create the RAM role, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#ram-role-assumption-recommended-for-cross-account).
|
||||
Use this method for secure cross-account access. For detailed instructions on how to create the RAM role, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#ram-role-assumption-recommended).
|
||||
|
||||
1. Enter the **Role ARN** (format: `acs:ram::<account-id>:role/<role-name>`)
|
||||
2. Enter the **Access Key ID** and **Access Key Secret** of the RAM user that will assume the role
|
||||
@@ -77,7 +77,7 @@ The RAM user whose credentials you provide must have permission to assume the ta
|
||||
|
||||
#### Credentials (Static Access Keys)
|
||||
|
||||
Use static credentials for quick scans (not recommended for production). For detailed setup, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#permanent-access-keys).
|
||||
Use static credentials for quick scans (not recommended for production). For detailed setup, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication#ram-user-and-accesskey).
|
||||
|
||||
1. Enter the **Access Key ID** and **Access Key Secret**
|
||||
|
||||
@@ -104,7 +104,7 @@ You can also run Alibaba Cloud assessments directly from the CLI. Both command-l
|
||||
|
||||
### Step 1: Select an Authentication Method
|
||||
|
||||
Choose one of the following authentication methods. For the complete list and detailed configuration, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication).
|
||||
Choose one of the following authentication methods. For step-by-step credential creation and the full list of supported authentication modes, see the [Authentication guide](/user-guide/providers/alibabacloud/authentication).
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
@@ -114,6 +114,13 @@ export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-access-key-secret"
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
#### Default Credential Chain
|
||||
|
||||
```bash
|
||||
aliyun configure --mode AK
|
||||
prowler alibabacloud
|
||||
```
|
||||
|
||||
#### RAM Role Assumption
|
||||
|
||||
```bash
|
||||
@@ -146,7 +153,7 @@ prowler alibabacloud
|
||||
#### Scan specific regions
|
||||
|
||||
```bash
|
||||
prowler alibabacloud --regions cn-hangzhou cn-shanghai
|
||||
prowler alibabacloud --region cn-hangzhou cn-shanghai
|
||||
```
|
||||
|
||||
#### Run specific checks
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
@@ -17,6 +17,7 @@ Prowler requests the following read-only OAuth 2.0 scopes:
|
||||
| `https://www.googleapis.com/auth/admin.directory.user.readonly` | Read access to user accounts and their admin status |
|
||||
| `https://www.googleapis.com/auth/admin.directory.domain.readonly` | Read access to domain information |
|
||||
| `https://www.googleapis.com/auth/admin.directory.customer.readonly` | Read access to customer information (Customer ID) |
|
||||
| `https://www.googleapis.com/auth/admin.directory.orgunit.readonly` | Read access to organizational unit hierarchy (identifies the root OU for policy filtering) |
|
||||
| `https://www.googleapis.com/auth/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar service checks) |
|
||||
| `https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly` | Read access to admin roles and role assignments |
|
||||
|
||||
@@ -86,7 +87,7 @@ This JSON key grants access to your Google Workspace organization. Never commit
|
||||
6. In the **OAuth scopes** field, enter the following scopes as a comma-separated list:
|
||||
|
||||
```
|
||||
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly,https://www.googleapis.com/auth/cloud-identity.policies.readonly,https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly
|
||||
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly,https://www.googleapis.com/auth/admin.directory.orgunit.readonly,https://www.googleapis.com/auth/cloud-identity.policies.readonly,https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly
|
||||
```
|
||||
|
||||
7. Click **Authorize**
|
||||
|
||||
@@ -160,3 +160,25 @@ Prowler for Vercel includes security checks across the following services:
|
||||
| **Project** | Deployment protection, environment variable security, fork protection, and skew protection |
|
||||
| **Security** | Web Application Firewall (WAF), rate limiting, IP blocking, and managed rulesets |
|
||||
| **Team** | SSO enforcement, directory sync, member access, and invitation hygiene |
|
||||
|
||||
## Checks With Explicit Plan-Based Behavior
|
||||
|
||||
Prowler currently includes 26 Vercel checks. The 11 checks below have explicit billing-plan handling in the provider metadata or check logic. When the scanned scope reports a billing plan, Prowler adds plan-aware context to findings for these checks. If the API does not expose the required configuration, Prowler may return `MANUAL` and require verification in the Vercel dashboard.
|
||||
|
||||
| Check ID | Hobby | Pro | Enterprise | Notes |
|
||||
|----------|-------|-----|------------|-------|
|
||||
| `project_password_protection_enabled` | Not available | Available as a paid add-on | Available | Checks password protection for deployments |
|
||||
| `project_production_deployment_protection_enabled` | Not available | Available with supported paid deployment protection options | Available | Checks protection for production deployments |
|
||||
| `project_skew_protection_enabled` | Not available | Available | Available | Checks skew protection during rollouts |
|
||||
| `security_custom_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
|
||||
| `security_ip_blocking_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
|
||||
| `team_saml_sso_enabled` | Not available | Available | Available | Checks team SAML SSO configuration |
|
||||
| `team_saml_sso_enforced` | Not available | Available | Available | Checks SAML SSO enforcement for all team members |
|
||||
| `team_directory_sync_enabled` | Not available | Not available | Available | Checks SCIM directory sync |
|
||||
| `security_managed_rulesets_enabled` | Bot Protection and AI Bots managed rulesets | Bot Protection and AI Bots managed rulesets | All managed rulesets, including OWASP Core Ruleset | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
|
||||
| `security_rate_limiting_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
|
||||
| `security_waf_enabled` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
|
||||
|
||||
<Note>
|
||||
The five firewall-related checks (`security_waf_enabled`, `security_custom_rules_configured`, `security_ip_blocking_rules_configured`, `security_rate_limiting_configured`, and `security_managed_rulesets_enabled`) return `MANUAL` when the firewall configuration endpoint is not accessible from the API. The other 15 current Vercel checks do not currently include plan-specific handling in provider logic, but every Vercel check includes exactly one billing-plan metadata category (`vercel-hobby-plan`, `vercel-pro-plan`, or `vercel-enterprise-plan`) alongside its functional security category.
|
||||
</Note>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
title: 'GitHub Action'
|
||||
description: 'Run Prowler scans in GitHub Actions using the official Docker-based action'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.25.0" />
|
||||
|
||||
The official **Prowler GitHub Action** runs Prowler scans inside your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. It supports every [Prowler provider](/user-guide/providers/) (AWS, Azure, GCP, Kubernetes, GitHub, Cloudflare, IaC, and more), optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning so findings appear in the **Security** tab and as inline PR annotations.
|
||||
|
||||
Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · Marketplace listing: [Prowler Security Scan](https://github.com/marketplace/actions/prowler-security-scan).
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `provider` | yes | — | Cloud provider to scan (`aws`, `azure`, `gcp`, `github`, `kubernetes`, `iac`, `cloudflare`, etc.) |
|
||||
| `image-tag` | no | `stable` | Docker image tag — `stable` (latest release), `latest` (master, not stable), or `<x.y.z>` (pinned). See [available tags](https://hub.docker.com/r/prowlercloud/prowler/tags). |
|
||||
| `output-formats` | no | `json-ocsf` | Output format(s) for scan results. Space-separated (e.g. `sarif json-ocsf`) |
|
||||
| `push-to-cloud` | no | `false` | Push findings to [Prowler Cloud](/user-guide/tutorials/prowler-app-import-findings). When `true`, `PROWLER_CLOUD_API_KEY` is auto-forwarded |
|
||||
| `flags` | no | `""` | Additional CLI flags (e.g. `--severity critical high`). Values with spaces can be quoted: `--resource-tag 'Environment=My Server'` |
|
||||
| `extra-env` | no | `""` | Space-, newline-, or comma-separated list of env var **names** to forward to the container (see [Authentication](#authentication)) |
|
||||
| `upload-sarif` | no | `false` | Upload SARIF results to GitHub Code Scanning |
|
||||
| `sarif-file` | no | `""` | Path to SARIF file (auto-detected from `output/` if not set) |
|
||||
| `sarif-category` | no | `prowler` | Category for the SARIF upload (distinguishes multiple analyses) |
|
||||
| `fail-on-findings` | no | `false` | Fail the workflow step when findings are detected (exit code 3) |
|
||||
|
||||
## Usage
|
||||
|
||||
### AWS scan
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: aws
|
||||
extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
|
||||
```
|
||||
|
||||
### Push findings to Prowler Cloud
|
||||
|
||||
Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-app-import-findings) for centralized visibility, compliance tracking, and team collaboration.
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: aws
|
||||
push-to-cloud: true
|
||||
extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
|
||||
PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
|
||||
```
|
||||
|
||||
<Info>
|
||||
When `push-to-cloud: true`, `PROWLER_CLOUD_API_KEY` is forwarded automatically — set it in `env:` but don't list it in `extra-env`. Requires a Prowler Cloud subscription and an API key with the **Manage Ingestions** permission. See [API Keys](/user-guide/tutorials/prowler-app-api-keys).
|
||||
</Info>
|
||||
|
||||
### Upload SARIF to GitHub Code Scanning
|
||||
|
||||
Findings appear in the **Security** tab and as **inline PR annotations** when SARIF upload is enabled.
|
||||
|
||||
```yaml
|
||||
name: Prowler IaC Scan
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
prowler:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: iac
|
||||
output-formats: sarif json-ocsf
|
||||
upload-sarif: true
|
||||
flags: --severity critical high
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Requirements:**
|
||||
- Include `sarif` in `output-formats` (the action warns if this is missing).
|
||||
- The workflow needs `security-events: write` and `actions: read` permissions.
|
||||
- GitHub Code Scanning is free for public repositories. Private repositories require a [GitHub Code Security](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security) license.
|
||||
</Warning>
|
||||
|
||||
### Combine push-to-cloud with SARIF upload
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: aws
|
||||
output-formats: sarif json-ocsf
|
||||
push-to-cloud: true
|
||||
upload-sarif: true
|
||||
extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }}
|
||||
PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
|
||||
```
|
||||
|
||||
### Scan the current repository with the GitHub provider
|
||||
|
||||
```yaml
|
||||
name: Prowler GitHub Scan
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
prowler:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: github
|
||||
flags: --repository ${{ github.repository }}
|
||||
extra-env: GITHUB_PERSONAL_ACCESS_TOKEN
|
||||
env:
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.PROWLER_GITHUB_PAT }}
|
||||
```
|
||||
|
||||
<Info>
|
||||
`--repository` scans a single repo. Use `--organization <name>` instead to include org-level checks (MFA, security policies, etc.). See the [GitHub provider authentication](/user-guide/providers/github/authentication) for required token permissions.
|
||||
</Info>
|
||||
|
||||
### Fail the PR on findings
|
||||
|
||||
By default the action tolerates findings (exit code 3) and succeeds. Set `fail-on-findings: true` to fail the workflow step when Prowler detects findings. Combine with `--severity` to control which severity levels trigger the failure:
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: iac
|
||||
output-formats: sarif
|
||||
upload-sarif: true
|
||||
fail-on-findings: true
|
||||
flags: --severity critical high
|
||||
```
|
||||
|
||||
The scan step fails if critical/high findings are detected, blocking the PR via required checks. SARIF is still uploaded (the upload step runs with `if: always()`) so findings appear in the Security tab regardless.
|
||||
|
||||
## Authentication
|
||||
|
||||
Each provider requires its own credentials passed as environment variables. Credentials are **not forwarded automatically** — list every env var name you need in the `extra-env` input, and set its value via `env:` at the step, job, or workflow level (typically from `secrets.*`).
|
||||
|
||||
Refer to the [Prowler provider docs](/user-guide/providers/) for the full list of variables each provider supports. Common ones:
|
||||
|
||||
| Provider | Typical `extra-env` |
|
||||
|----------|---------------------|
|
||||
| AWS | `AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_DEFAULT_REGION` (OIDC exports these automatically) |
|
||||
| Azure | `AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_TENANT_ID` |
|
||||
| GCP | `GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_ACCESS_TOKEN GOOGLE_CLOUD_PROJECT` |
|
||||
| GitHub | `GITHUB_PERSONAL_ACCESS_TOKEN` *(or `GITHUB_OAUTH_APP_TOKEN`, or `GITHUB_APP_ID GITHUB_APP_KEY`)* |
|
||||
| Kubernetes | `KUBECONFIG` |
|
||||
| Cloudflare | `CLOUDFLARE_API_TOKEN` *(or `CLOUDFLARE_API_KEY CLOUDFLARE_API_EMAIL`)* |
|
||||
|
||||
<Info>
|
||||
`PROWLER_CLOUD_API_KEY` is auto-forwarded when `push-to-cloud: true` — no need to add it to `extra-env`.
|
||||
</Info>
|
||||
|
||||
### AWS
|
||||
|
||||
Use [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) with OIDC (recommended) or pass static credentials. OIDC sets `AWS_*` env vars on the runner, so you only forward them:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::123456789012:role/ProwlerRole
|
||||
aws-region: eu-west-1
|
||||
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: aws
|
||||
extra-env: AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_DEFAULT_REGION
|
||||
```
|
||||
|
||||
### Azure
|
||||
|
||||
Use [azure/login](https://github.com/Azure/login) with a service principal or pass credentials directly:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: azure/login@v2
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_CREDENTIALS }}
|
||||
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: azure
|
||||
extra-env: AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_TENANT_ID
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
```
|
||||
|
||||
### GCP
|
||||
|
||||
Use [google-github-actions/auth](https://github.com/google-github-actions/auth) with Workload Identity Federation (recommended):
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/my-pool/providers/my-provider
|
||||
service_account: prowler@my-project.iam.gserviceaccount.com
|
||||
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: gcp
|
||||
extra-env: GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_ACCESS_TOKEN GOOGLE_CLOUD_PROJECT
|
||||
```
|
||||
|
||||
### Cloudflare
|
||||
|
||||
Create a Cloudflare API Token with `Zone:Read`, `Zone Settings:Read`, and `DNS:Read` permissions ([provider auth docs](/user-guide/providers/cloudflare/authentication)). Then:
|
||||
|
||||
```yaml
|
||||
- uses: prowler-cloud/prowler@5.25
|
||||
with:
|
||||
provider: cloudflare
|
||||
extra-env: CLOUDFLARE_API_TOKEN
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
```
|
||||
|
||||
## Outputs
|
||||
|
||||
Scan results are written to `output/` in the workspace and uploaded as artifacts named `prowler-<provider>` with 30-day retention.
|
||||
|
||||
When `upload-sarif` is enabled, SARIF results are also uploaded to GitHub Code Scanning and appear on the repository's **Security → Code scanning** tab, filtered by the branch that ran the scan.
|
||||
|
||||
### Step summary
|
||||
|
||||
The action writes a summary to the run page with a per-severity breakdown of failing checks, artifact and Code Scanning links, and (when `push-to-cloud: false`) a pointer to [Prowler Cloud](https://cloud.prowler.com) for continuous monitoring.
|
||||
|
||||
<img src="/images/github-action/scan-summary.png" alt="GitHub Actions run page showing the Prowler IaC Scan Summary with failing and passing counts, severity breakdown, scan log link, artifact link, and GitHub Code Security link" width="1400" />
|
||||
@@ -365,6 +365,10 @@ Prowler must be installed in the CI/CD environment before running scans. Refer t
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
<Info>
|
||||
For new projects, use the official [Prowler GitHub Action](/user-guide/tutorials/prowler-app-github-action) — a Docker-based reusable action that runs scans, optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning. The example below documents the legacy pip-based flow.
|
||||
</Info>
|
||||
|
||||
```yaml
|
||||
- name: Install Prowler
|
||||
run: pip install prowler
|
||||
|
||||
@@ -227,6 +227,7 @@ Assign administrative permissions by selecting from the following options:
|
||||
| Manage Integrations | All | Add or modify the Prowler Integrations. |
|
||||
| Manage Ingestions | Prowler Cloud | Allow or deny the ability to submit findings ingestion batches via the API. |
|
||||
| Manage Billing | Prowler Cloud | Access and manage billing settings and subscription information. |
|
||||
| Manage Alerts | Prowler Cloud | Create, edit, and delete alert rules and recipients. |
|
||||
|
||||
<Note>
|
||||
The **Scope** column indicates where each permission applies. **All** means the permission is available in both Prowler Cloud and Self-Managed deployments. **Prowler Cloud** indicates permissions that are specific to [Prowler Cloud](https://cloud.prowler.com/sign-in).
|
||||
@@ -241,3 +242,5 @@ The following permissions are available exclusively in **Prowler Cloud**:
|
||||
**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-app-import-findings) for details.
|
||||
|
||||
**Manage Billing:** Access and manage billing settings, subscription plans, and payment methods.
|
||||
|
||||
**Manage Alerts:** Create, edit, and delete alert rules and recipients used to deliver scan-result digests via email.
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
title: 'Prowler Check Kreator'
|
||||
---
|
||||
|
||||
<Note>
|
||||
Currently, this tool is only available for creating checks for the AWS provider.
|
||||
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
If you are looking for a way to create new checks for all the supported providers, you can use [Prowler Studio](https://github.com/prowler-cloud/prowler-studio), it is an AI-powered toolkit for generating and managing security checks for Prowler (better version of the Check Kreator).
|
||||
|
||||
</Note>
|
||||
|
||||
## Introduction
|
||||
|
||||
**Prowler Check Kreator** is a utility designed to streamline the creation of new checks for Prowler. This tool generates all necessary files required to add a new check to the Prowler repository. Specifically, it creates:
|
||||
|
||||
- A dedicated folder for the check.
|
||||
- The main check script.
|
||||
- A metadata file with essential details.
|
||||
- A folder and file structure for testing the check.
|
||||
|
||||
## Usage
|
||||
|
||||
To use the tool, execute the main script with the following command:
|
||||
|
||||
```bash
|
||||
python util/prowler_check_kreator/prowler_check_kreator.py <prowler_provider> <check_name>
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `<prowler_provider>`: Currently only AWS is supported.
|
||||
- `<check_name>`: The name you wish to assign to the new check.
|
||||
|
||||
## AI integration
|
||||
|
||||
This tool optionally integrates AI to assist in generating the check code and metadata file content. When AI assistance is chosen, the tool uses [Gemini](https://gemini.google.com/) to produce preliminary code and metadata.
|
||||
|
||||
<Note>
|
||||
For this feature to work, you must have the library `google-generativeai` installed in your Python environment.
|
||||
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
AI-generated code and metadata might contain errors or require adjustments to align with specific Prowler requirements. Carefully review all AI-generated content before committing.
|
||||
|
||||
</Warning>
|
||||
|
||||
To enable AI assistance, simply confirm when prompted by the tool. Additionally, ensure that the `GEMINI_API_KEY` environment variable is set with a valid Gemini API key. For instructions on obtaining your API key, refer to the [Gemini documentation](https://ai.google.dev/gemini-api/docs/api-key).
|
||||
Generated
+9
-8
@@ -1934,6 +1934,7 @@ files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "contextlib2"
|
||||
@@ -3102,7 +3103,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
@@ -3182,7 +3183,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.05.14"
|
||||
certifi = ">=14.5.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -4701,14 +4702,14 @@ dev = ["black (==22.6.0)", "flake8", "mypy", "pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
|
||||
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
|
||||
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
|
||||
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4988,7 +4989,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
astroid = ">=3.3.8,<=3.4.0-dev0"
|
||||
astroid = ">=3.3.8,<=3.4.0.dev0"
|
||||
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||
dill = [
|
||||
{version = ">=0.2", markers = "python_version < \"3.11\""},
|
||||
@@ -5834,10 +5835,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "safety"
|
||||
|
||||
+65
-5
@@ -2,11 +2,73 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.25.0] (Prowler UNRELEASED)
|
||||
## [5.26.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
|
||||
- Universal compliance pipeline integrated into the CLI: `--list-compliance` and `--list-compliance-requirements` show universal frameworks, and CSV plus OCSF outputs are generated for any framework declaring a `TableConfig` [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
|
||||
- Update Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Azure Network Watcher flow log checks now require workspace-backed Traffic Analytics for `network_flow_log_captured_sent` and align metadata with VNet-compatible flow log guidance [(#10645)](https://github.com/prowler-cloud/prowler/pull/10645)
|
||||
- Azure compliance entries for legacy Network Watcher flow log controls now use retirement-aware guidance and point new deployments to VNet flow logs [(#10937)](https://github.com/prowler-cloud/prowler/pull/10937)
|
||||
- AWS CodeBuild service now batches `BatchGetProjects` and `BatchGetBuilds` calls per region (up to 100 items per call) to reduce API call volume and prevent throttling-induced false positives in `codebuild_project_not_publicly_accessible` [(#10639)](https://github.com/prowler-cloud/prowler/pull/10639)
|
||||
- `display_compliance_table` dispatch switched from substring `in` checks to `startswith` to prevent false matches between similarly named frameworks (e.g. `cisa` vs `cis`) [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- AWS SDK test isolation: autouse `mock_aws` fixture and leak detector in `conftest.py` to prevent tests from hitting real AWS endpoints, with idempotent organization setup for tests calling `set_mocked_aws_provider` multiple times [(#10605)](https://github.com/prowler-cloud/prowler/pull/10605)
|
||||
- AWS `boto` user agent extra is now applied to every client [(#10944)](https://github.com/prowler-cloud/prowler/pull/10944)
|
||||
- Image provider connection check no longer fails with a misleading `host='https'` resolution error when the registry URL includes an `http://` or `https://` scheme prefix [(#10950)](https://github.com/prowler-cloud/prowler/pull/10950)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Parser-mismatch SSRF in image provider registry auth where crafted bearer-token realms and pagination links could force requests to internal addresses and leak credentials cross-origin [(#10945)](https://github.com/prowler-cloud/prowler/pull/10945)
|
||||
|
||||
---
|
||||
|
||||
## [5.25.2] (Prowler v5.25.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `route53_dangling_ip_subdomain_takeover` now also flags `CNAME` records pointing to S3 website endpoints whose buckets are missing from the account [(#10920)](https://github.com/prowler-cloud/prowler/pull/10920)
|
||||
- Duplicate Kubernetes RBAC findings when the same User or Group subject appeared in multiple ClusterRoleBindings [(#10242)](https://github.com/prowler-cloud/prowler/pull/10242)
|
||||
- Match K8s RBAC rules by `apiGroup` [(#10969)](https://github.com/prowler-cloud/prowler/pull/10969)
|
||||
- Return a compact actor name from CloudTrail `userIdentity` events [(#10986)](https://github.com/prowler-cloud/prowler/pull/10986)
|
||||
|
||||
---
|
||||
|
||||
## [5.25.1] (Prowler v5.25.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `KeyError` when generating compliance outputs after the CLI scan [#10919](https://github.com/prowler-cloud/prowler/pull/10919)
|
||||
- Kubernetes OCSF `provider_uid` now uses the cluster name in in-cluster mode (so `--cluster-name` is correctly reflected in findings) and keeps the kubeconfig context in kubeconfig mode [(#10483)](https://github.com/prowler-cloud/prowler/pull/10483)
|
||||
|
||||
---
|
||||
|
||||
## [5.25.0] (Prowler v5.25.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `--repo-list-file` CLI flag for GitHub provider to load repositories from a file [(#10501)](https://github.com/prowler-cloud/prowler/pull/10501)
|
||||
- SARIF output format for the IaC provider, enabling GitHub Code Scanning integration via `--output-formats sarif` [(#10626)](https://github.com/prowler-cloud/prowler/pull/10626)
|
||||
- `repository_default_branch_dismisses_stale_reviews` check for GitHub provider to ensure stale pull request approvals are dismissed when new commits are pushed [(#10569)](https://github.com/prowler-cloud/prowler/pull/10569)
|
||||
- Official Prowler GitHub Action (`prowler-cloud/prowler@5.25`) for running scans in GitHub workflows with optional `--push-to-cloud` and SARIF upload to GitHub Code Scanning [(#10872)](https://github.com/prowler-cloud/prowler/pull/10872)
|
||||
- GitHub Actions service for scanning workflow security issues using zizmor [(#10607)](https://github.com/prowler-cloud/prowler/pull/10607)
|
||||
- `secretsmanager_has_restrictive_resource_policy` check for AWS provider [(#6985)](https://github.com/prowler-cloud/prowler/pull/6985)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Alibaba Cloud CS service SDK compatibility, harden other services and improve documentation [(#10871)](https://github.com/prowler-cloud/prowler/pull/10871)
|
||||
- AWS Organizations metadata retrieval for delegated administrator scans by using the assumed role session instead of the pre-assume credentials [(#10894)](https://github.com/prowler-cloud/prowler/pull/10894)
|
||||
- `admincenter_groups_not_public_visibility` check for M365 provider evaluating Security and Distribution groups, now restricted to Microsoft 365 (Unified) groups per CIS M365 Foundations 1.2.1 [(#10899)](https://github.com/prowler-cloud/prowler/pull/10899)
|
||||
- Google Workspace check reports now store the actual domain or account resource subject instead of `provider.identity` [(#10901)](https://github.com/prowler-cloud/prowler/pull/10901)
|
||||
- `entra_users_mfa_capable` evaluating disabled guest accounts; CIS 5.2.3.4 only targets enabled member users [(#10785)](https://github.com/prowler-cloud/prowler/pull/10785)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,15 +77,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
### 🐞 Fixed
|
||||
|
||||
- CloudTrail resource timeline uses resource name as fallback in `LookupEvents` [(#10828)](https://github.com/prowler-cloud/prowler/pull/10828)
|
||||
- Exclude `me-south-1` and `me-central-1` from default AWS scans to prevent hangs when the host can't reach those regional endpoints [(#10837)](https://github.com/prowler-cloud/prowler/pull/10837)
|
||||
|
||||
---
|
||||
|
||||
## [5.24.1] (Prowler v5.24.1)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `--repo-list-file` CLI flag for GitHub provider to load repositories from a file [(#10501)](https://github.com/prowler-cloud/prowler/pull/10501)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `msgraph-sdk` from 1.23.0 to 1.55.0 and `azure-mgmt-resource` from 23.3.0 to 24.0.0, removing `marshmallow` as is a transitively dev dependency [(#10733)](https://github.com/prowler-cloud/prowler/pull/10733)
|
||||
@@ -64,6 +123,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
- `prowler image --registry-list` crashes with `AttributeError` because `ImageProvider.__init__` returns early before registering the global provider [(#10691)](https://github.com/prowler-cloud/prowler/pull/10691)
|
||||
- Vercel firewall config handling for team-scoped projects and current API response shapes [(#10695)](https://github.com/prowler-cloud/prowler/pull/10695)
|
||||
- 9 Gmail checks for Google Workspace provider (`gmail_mail_delegation_disabled`, `gmail_shortener_scanning_enabled`, `gmail_external_image_scanning_enabled`, `gmail_untrusted_link_warnings_enabled`, `gmail_pop_imap_access_disabled`, `gmail_auto_forwarding_disabled`, `gmail_per_user_outbound_gateway_disabled`, `gmail_enhanced_pre_delivery_scanning_enabled`, `gmail_comprehensive_mail_storage_enabled`) using the Cloud Identity Policy API [(#10683)](https://github.com/prowler-cloud/prowler/pull/10683)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+62
-11
@@ -45,7 +45,10 @@ from prowler.lib.check.check import (
|
||||
)
|
||||
from prowler.lib.check.checks_loader import load_checks_to_execute
|
||||
from prowler.lib.check.compliance import update_checks_metadata_with_compliance
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.compliance_models import (
|
||||
Compliance,
|
||||
get_bulk_compliance_frameworks_universal,
|
||||
)
|
||||
from prowler.lib.check.custom_checks_metadata import (
|
||||
parse_custom_checks_metadata_file,
|
||||
update_checks_metadata,
|
||||
@@ -75,7 +78,10 @@ from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
|
||||
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
|
||||
GoogleWorkspaceCISASCuBA,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.compliance import display_compliance_table
|
||||
from prowler.lib.outputs.compliance.compliance import (
|
||||
display_compliance_table,
|
||||
process_universal_compliance_frameworks,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
|
||||
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
|
||||
from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA
|
||||
@@ -84,6 +90,9 @@ from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA
|
||||
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
|
||||
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
|
||||
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
|
||||
from prowler.lib.outputs.compliance.essential_eight.essential_eight_aws import (
|
||||
EssentialEightAWS,
|
||||
)
|
||||
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.compliance.iso27001.iso27001_aws import AWSISO27001
|
||||
from prowler.lib.outputs.compliance.iso27001.iso27001_azure import AzureISO27001
|
||||
@@ -199,7 +208,8 @@ def prowler():
|
||||
if compliance_framework:
|
||||
args.output_formats.extend(compliance_framework)
|
||||
# If no input compliance framework, set all, unless a specific service or check is input
|
||||
elif default_execution:
|
||||
# Skip for IAC and LLM providers that don't use compliance frameworks
|
||||
elif default_execution and provider not in ["iac", "llm"]:
|
||||
args.output_formats.extend(get_available_compliance_frameworks(provider))
|
||||
|
||||
# Set Logger configuration
|
||||
@@ -234,6 +244,8 @@ def prowler():
|
||||
# Load compliance frameworks
|
||||
logger.debug("Loading compliance frameworks from .json files")
|
||||
|
||||
universal_frameworks = {}
|
||||
|
||||
# Skip compliance frameworks for external-tool providers
|
||||
if provider not in EXTERNAL_TOOL_PROVIDERS:
|
||||
bulk_compliance_frameworks = Compliance.get_bulk(provider)
|
||||
@@ -241,6 +253,8 @@ def prowler():
|
||||
bulk_checks_metadata = update_checks_metadata_with_compliance(
|
||||
bulk_compliance_frameworks, bulk_checks_metadata
|
||||
)
|
||||
# Load universal compliance frameworks for new rendering pipeline
|
||||
universal_frameworks = get_bulk_compliance_frameworks_universal(provider)
|
||||
|
||||
# Update checks metadata if the --custom-checks-metadata-file is present
|
||||
custom_checks_metadata = None
|
||||
@@ -253,12 +267,12 @@ def prowler():
|
||||
)
|
||||
|
||||
if args.list_compliance:
|
||||
print_compliance_frameworks(bulk_compliance_frameworks)
|
||||
all_frameworks = {**bulk_compliance_frameworks, **universal_frameworks}
|
||||
print_compliance_frameworks(all_frameworks)
|
||||
sys.exit()
|
||||
if args.list_compliance_requirements:
|
||||
print_compliance_requirements(
|
||||
bulk_compliance_frameworks, args.list_compliance_requirements
|
||||
)
|
||||
all_frameworks = {**bulk_compliance_frameworks, **universal_frameworks}
|
||||
print_compliance_requirements(all_frameworks, args.list_compliance_requirements)
|
||||
sys.exit()
|
||||
|
||||
# Load checks to execute
|
||||
@@ -275,6 +289,7 @@ def prowler():
|
||||
provider=provider,
|
||||
list_checks=getattr(args, "list_checks", False)
|
||||
or getattr(args, "list_checks_json", False),
|
||||
universal_frameworks=universal_frameworks,
|
||||
)
|
||||
|
||||
# if --list-checks-json, dump a json file and exit
|
||||
@@ -430,14 +445,15 @@ def prowler():
|
||||
|
||||
findings = global_provider.run_scan(streaming_callback=streaming_callback)
|
||||
else:
|
||||
# Original behavior for IAC or non-verbose LLM
|
||||
# Original behavior for IAC and Image
|
||||
try:
|
||||
findings = global_provider.run()
|
||||
except ImageBaseException as error:
|
||||
logger.critical(f"{error}")
|
||||
sys.exit(1)
|
||||
# Note: IaC doesn't support granular progress tracking since Trivy runs as a black box
|
||||
# and returns all findings at once. Progress tracking would just be 0% → 100%.
|
||||
# Note: External tool providers don't support granular progress tracking since
|
||||
# they run external tools as a black box and return all findings at once.
|
||||
# Progress tracking would just be 0% → 100%.
|
||||
|
||||
# Filter findings by status if specified
|
||||
if hasattr(args, "status") and args.status:
|
||||
@@ -622,9 +638,29 @@ def prowler():
|
||||
)
|
||||
|
||||
# Compliance Frameworks
|
||||
# Source the framework listing from the union of `bulk_compliance_frameworks`
|
||||
# and `universal_frameworks` so universal-only frameworks (e.g.
|
||||
# `prowler/compliance/csa_ccm_4.0.json`) — which `Compliance.get_bulk(provider)`
|
||||
# does not load — still reach `process_universal_compliance_frameworks` below.
|
||||
# The provider-specific block subtracts the names handled by the universal
|
||||
# processor so the legacy per-provider handlers only see frameworks that the
|
||||
# bulk loader actually resolved.
|
||||
input_compliance_frameworks = set(output_options.output_modes).intersection(
|
||||
get_available_compliance_frameworks(provider)
|
||||
set(bulk_compliance_frameworks.keys()) | set(universal_frameworks.keys())
|
||||
)
|
||||
|
||||
# ── Universal compliance frameworks (provider-agnostic) ──
|
||||
universal_processed = process_universal_compliance_frameworks(
|
||||
input_compliance_frameworks=input_compliance_frameworks,
|
||||
universal_frameworks=universal_frameworks,
|
||||
finding_outputs=finding_outputs,
|
||||
output_directory=output_options.output_directory,
|
||||
output_filename=output_options.output_filename,
|
||||
provider=provider,
|
||||
generated_outputs=generated_outputs,
|
||||
)
|
||||
input_compliance_frameworks -= universal_processed
|
||||
|
||||
if provider == "aws":
|
||||
for compliance_name in input_compliance_frameworks:
|
||||
if compliance_name.startswith("cis_"):
|
||||
@@ -640,6 +676,18 @@ def prowler():
|
||||
)
|
||||
generated_outputs["compliance"].append(cis)
|
||||
cis.batch_write_data_to_file()
|
||||
elif compliance_name.startswith("essential_eight"):
|
||||
filename = (
|
||||
f"{output_options.output_directory}/compliance/"
|
||||
f"{output_options.output_filename}_{compliance_name}.csv"
|
||||
)
|
||||
essential_eight = EssentialEightAWS(
|
||||
findings=finding_outputs,
|
||||
compliance=bulk_compliance_frameworks[compliance_name],
|
||||
file_path=filename,
|
||||
)
|
||||
generated_outputs["compliance"].append(essential_eight)
|
||||
essential_eight.batch_write_data_to_file()
|
||||
elif compliance_name == "mitre_attack_aws":
|
||||
# Generate MITRE ATT&CK Finding Object
|
||||
filename = (
|
||||
@@ -1394,6 +1442,9 @@ def prowler():
|
||||
output_options.output_filename,
|
||||
output_options.output_directory,
|
||||
compliance_overview,
|
||||
universal_frameworks=universal_frameworks,
|
||||
provider=provider,
|
||||
output_formats=args.output_formats,
|
||||
)
|
||||
if compliance_overview:
|
||||
print(
|
||||
|
||||
@@ -6426,9 +6426,9 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_agent_guardrail_enabled",
|
||||
"bedrock_guardrail_prompt_attack_filter_enabled",
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled",
|
||||
"bedrock_agent_guardrail_enabled"
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -6485,9 +6485,9 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_agent_guardrail_enabled",
|
||||
"bedrock_guardrail_prompt_attack_filter_enabled",
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled",
|
||||
"bedrock_agent_guardrail_enabled"
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -6546,8 +6546,8 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled",
|
||||
"bedrock_agent_guardrail_enabled"
|
||||
"bedrock_agent_guardrail_enabled",
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -6606,8 +6606,8 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled",
|
||||
"bedrock_agent_guardrail_enabled"
|
||||
"bedrock_agent_guardrail_enabled",
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2894,8 +2894,10 @@
|
||||
"bedrock_agent_guardrail_enabled",
|
||||
"bedrock_guardrail_prompt_attack_filter_enabled",
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled",
|
||||
"bedrock_guardrails_configured",
|
||||
"bedrock_model_invocation_logging_enabled",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_management_exists",
|
||||
"cloudformation_stack_outputs_find_secrets",
|
||||
"cloudfront_distributions_custom_ssl_certificate",
|
||||
"cloudfront_distributions_default_root_object",
|
||||
|
||||
@@ -2898,8 +2898,10 @@
|
||||
"bedrock_agent_guardrail_enabled",
|
||||
"bedrock_guardrail_prompt_attack_filter_enabled",
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled",
|
||||
"bedrock_guardrails_configured",
|
||||
"bedrock_model_invocation_logging_enabled",
|
||||
"bedrock_model_invocation_logs_encryption_enabled",
|
||||
"bedrock_prompt_management_exists",
|
||||
"cloudformation_stack_outputs_find_secrets",
|
||||
"cloudfront_distributions_custom_ssl_certificate",
|
||||
"cloudfront_distributions_default_root_object",
|
||||
|
||||
@@ -2276,9 +2276,9 @@
|
||||
"Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace.",
|
||||
"RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting thegeneration of visual flow diagrams to aid with analyzing for lateral movement, etc.",
|
||||
"ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.",
|
||||
"RemediationProcedure": "From Azure Portal 1. Navigate to Network Watcher. 2. Select NSG flow logs. 3. Select + Create. 4. Select the desired Subscription. 5. Select + Select NSG. 6. Select a network security group. 7. Click Confirm selection. 8. Select or create a new Storage Account. 9. Input the retention in days to retain the log. 10. Click Next. 11. Under Configuration, select Version 2. 12. If rich analytics are required, select Enable Traffic Analytics, a processing interval, and a Log Analytics Workspace. 13. Select Next. 14. Optionally add Tags. 15. Select Review + create. 16. Select Create. Warning The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.",
|
||||
"AuditProcedure": "From Azure Portal 1. Navigate to Network Watcher. 2. Select NSG flow logs 3. For each log you wish to audit select it from this view.",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "From Azure Portal Existing NSG flow logs can still be reviewed under Network Watcher > Flow logs. If you already have NSG flow logs configured, ensure they remain enabled and that Traffic Analytics sends data to a Log Analytics Workspace until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create Virtual network flow logs instead: 1. Navigate to Network Watcher. 2. Select Flow logs. 3. Select + Create. 4. Select the desired Subscription. 5. For Flow log type, select Virtual network. 6. Select + Select target resource. 7. Select a virtual network. 8. Click Confirm selection. 9. Select or create a new Storage Account. 10. Input the retention in days to retain the log. 11. Click Next. 12. Under Analytics, select Version 2, enable Traffic Analytics, and select a Log Analytics Workspace. 13. Select Next. 14. Optionally add Tags. 15. Select Review + create. 16. Select Create.",
|
||||
"AuditProcedure": "From Azure Portal 1. Navigate to Network Watcher. 2. Select Flow logs. 3. Review existing Network security group flow logs, if any remain, to ensure they are enabled and configured to send logs to a Log Analytics Workspace. 4. Review Virtual network flow logs for new or migrated coverage.",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.",
|
||||
"DefaultValue": "By default Network Security Group logs are not sent to Log Analytics.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation"
|
||||
}
|
||||
@@ -2702,9 +2702,9 @@
|
||||
"Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days.",
|
||||
"RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.",
|
||||
"ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.",
|
||||
"RemediationProcedure": "From Azure Portal 1. Go to Network Watcher 2. Select NSG flow logs blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure Status is set to On 5. Ensure Retention (days) setting greater than 90 days 6. Select your storage account in the Storage account field 7. Select Save From Azure CLI Enable the NSG flow logs and set the Retention (days) to greater than or equal to 90 days. az network watcher flow-log configure --nsg <NameorID of the Network Security Group> --enabled true --resource-group <resourceGroupName> --retention 91 -- storage-account <NameorID of the storage account to save flow logs>",
|
||||
"AuditProcedure": "From Azure Portal 1. Go to Network Watcher 2. Select NSG flow logs blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure Status is set to On 5. Ensure Retention (days) setting greater than 90 days From Azure CLI az network watcher flow-log show --resource-group <resourceGroup> --nsg <NameorID of the NetworkSecurityGroup> --query 'retentionPolicy' Ensure that enabled is set to true and days is set to greater then or equal to 90.",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "From Azure Portal Existing NSG flow logs can still be reviewed under Network Watcher > Flow logs. If you already have NSG flow logs configured, ensure Status is set to On and Retention (days) is set to 0, 90, or a number greater than 90 until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure Virtual network flow logs instead and set Retention days to 0, 90, or a number greater than 90. From Azure CLI Update an existing flow log retention policy with az network watcher flow-log update --location <location> --name <flow-log> --retention <number-of-days>.",
|
||||
"AuditProcedure": "From Azure Portal 1. Go to Network Watcher. 2. Select Flow logs. 3. Review existing Network security group flow logs, if any remain, and ensure Status is set to On and Retention (days) is set to 0, 90, or a number greater than 90. 4. Review Virtual network flow logs for new or migrated coverage. From Azure CLI az network watcher flow-log list --location <location> --query [*].[name,retentionPolicy,targetResourceId] Ensure each relevant flow log has retention days set to 0, 90, or a number greater than 90.",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.",
|
||||
"DefaultValue": "By default, Network Security Group Flow Logs are disabled.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-6-configure-log-storage-retention"
|
||||
}
|
||||
|
||||
@@ -2241,9 +2241,9 @@
|
||||
"Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace.",
|
||||
"RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.",
|
||||
"ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.",
|
||||
"RemediationProcedure": "**From Azure Portal** 1. Navigate to `Network Watcher`. 1. Select `NSG flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. Select `+ Select NSG`. 1. Select a network security group. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. Input the retention in days to retain the log. 1. Click `Next`. 1. Under `Configuration`, select `Version 2`. 1. If rich analytics are required, select `Enable Traffic Analytics`, a processing interval, and a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`. ***Warning*** The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.",
|
||||
"AuditProcedure": "**From Azure Portal** 1. Navigate to `Network Watcher`. 1. Select `NSG flow logs` 1. For each log you wish to audit select it from this view. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "**From Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. Input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.",
|
||||
"AuditProcedure": "**From Azure Portal** 1. Navigate to `Network Watcher`. 1. Select `Flow logs`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.",
|
||||
"DefaultValue": "By default Network Security Group logs are not sent to Log Analytics.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation"
|
||||
}
|
||||
@@ -2627,9 +2627,9 @@
|
||||
"Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days.",
|
||||
"RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.",
|
||||
"ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.",
|
||||
"RemediationProcedure": "**From Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` 6. Select your storage account in the `Storage account` field 7. Select `Save` **From Azure CLI** Enable the `NSG flow logs` and set the Retention (days) to greater than or equal to 90 days. ``` az network watcher flow-log configure --nsg <NameorID of the Network Security Group> --enabled true --resource-group <resourceGroupName> --retention 91 --storage-account <NameorID of the storage account to save flow logs> ```",
|
||||
"AuditProcedure": "**From Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` **From Azure CLI** ``` az network watcher flow-log show --resource-group <resourceGroup> --nsg <NameorID of the NetworkSecurityGroup> --query 'retentionPolicy' ``` Ensure that `enabled` is set to `true` and `days` is set to `greater then or equal to 90`. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "**From Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure `Virtual network` flow logs instead and set `Retention days` to `0`, `90`, or a number greater than `90`. **From Azure CLI** Update an existing flow log retention policy with: ``` az network watcher flow-log update --location <location> --name <flow-log> --retention <number-of-days> ```",
|
||||
"AuditProcedure": "**From Azure Portal** 1. Go to `Network Watcher`. 1. Select `Flow logs`. 1. Review existing `Network security group` flow logs, if any remain, and ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90`. 1. Review `Virtual network` flow logs for new or migrated coverage. **From Azure CLI** ``` az network watcher flow-log list --location <location> --query [*].[name,retentionPolicy,targetResourceId] ``` Ensure each relevant flow log has retention days set to `0`, `90`, or a number greater than `90`. **From Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.",
|
||||
"DefaultValue": "By default, Network Security Group Flow Logs are `disabled`.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-6-configure-log-storage-retention"
|
||||
}
|
||||
|
||||
@@ -2548,9 +2548,9 @@
|
||||
"Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace.",
|
||||
"RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.",
|
||||
"ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace.This will increase the amount of data stored and used by Azure Monitor.",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal**1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Select `+ Create`.1. Select the desired Subscription.1. For `Flow log type`, select `Network security group`.1. Select `+ Select target resource`.1. Select `Network security group`.1. Select a network security group.1. Click `Confirm selection`.1. Select or create a new Storage Account.1. If using a v2 storage account, input the retention in days to retain the log.1. Click `Next`.1. Under `Analytics`, for `Flow log version`, select `Version 2`.1. Check the box next to `Enable traffic analytics`.1. Select a processing interval.1. Select a `Log Analytics Workspace`.1. Select `Next`.1. Optionally add Tags.1. Select `Review + create`.1. Select `Create`.***Warning***The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.",
|
||||
"AuditProcedure": "**Audit from Azure Portal**1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Click `Add filter`.1. From the `Filter` drop-down, select `Flow log type`.1. From the `Value` drop-down, check `Network security group` only.1. Click `Apply`.1. Ensure that at least one network security group flow log is listed and is configured to send logs to a `Log Analytics Workspace`.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state'- **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group'- **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Select `+ Create`.1. Select the desired Subscription.1. For `Flow log type`, select `Virtual network`.1. Select `+ Select target resource`.1. Select `Virtual network`.1. Select a virtual network.1. Click `Confirm selection`.1. Select or create a new Storage Account.1. If using a v2 storage account, input the retention in days to retain the log.1. Click `Next`.1. Under `Analytics`, for `Flow log version`, select `Version 2`.1. Check the box next to `Enable traffic analytics`.1. Select a processing interval.1. Select a `Log Analytics Workspace`.1. Select `Next`.1. Optionally add Tags.1. Select `Review + create`.1. Select `Create`.",
|
||||
"AuditProcedure": "**Audit from Azure Portal**1. Navigate to `Network Watcher`.1. Under `Logs`, select `Flow logs`.1. Click `Add filter`.1. From the `Filter` drop-down, select `Flow log type`.1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`.1. Review `Virtual network` flow logs for new or migrated coverage.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state'- **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group'- **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.",
|
||||
"DefaultValue": "By default Network Security Group logs are not sent to Log Analytics.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation"
|
||||
}
|
||||
@@ -2934,9 +2934,9 @@
|
||||
"Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days.",
|
||||
"RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.",
|
||||
"ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal**1. Go to `Network Watcher`2. Select `NSG flow logs` blade in the Logs section3. Select each Network Security Group from the list4. Ensure `Status` is set to `On`5. Ensure `Retention (days)` setting `greater than 90 days`6. Select your storage account in the `Storage account` field7. Select `Save`**Remediate from Azure CLI**Enable the `NSG flow logs` and set the Retention (days) to greater than or equal to 90 days.```az network watcher flow-log configure --nsg <NameorID of the Network Security Group> --enabled true --resource-group <resourceGroupName> --retention 91 --storage-account <NameorID of the storage account to save flow logs>```",
|
||||
"AuditProcedure": "**Audit from Azure Portal**1. Go to `Network Watcher`2. Select `NSG flow logs` blade in the Logs section3. Select each Network Security Group from the list4. Ensure `Status` is set to `On`5. Ensure `Retention (days)` setting `greater than 90 days`**Audit from Azure CLI**```az network watcher flow-log show --resource-group <resourceGroup> --nsg <NameorID of the NetworkSecurityGroup> --query 'retentionPolicy'```Ensure that `enabled` is set to `true` and `days` is set to `greater then or equal to 90`.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure `Virtual network` flow logs instead and set `Retention days` to `0`, `90`, or a number greater than `90`.**Remediate from Azure CLI**Update an existing flow log retention policy with:```az network watcher flow-log update --location <location> --name <flow-log> --retention <number-of-days>```",
|
||||
"AuditProcedure": "**Audit from Azure Portal**1. Go to `Network Watcher`.1. Select `Flow logs`.1. Review existing `Network security group` flow logs, if any remain, and ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90`.1. Review `Virtual network` flow logs for new or migrated coverage.**Audit from Azure CLI**```az network watcher flow-log list --location <location> --query [*].[name,retentionPolicy,targetResourceId]```Ensure each relevant flow log has retention days set to `0`, `90`, or a number greater than `90`.**Audit from Azure Policy**If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure.If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions- **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies.",
|
||||
"DefaultValue": "By default, Network Security Group Flow Logs are `disabled`.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-6-configure-log-storage-retention"
|
||||
}
|
||||
|
||||
@@ -1302,9 +1302,9 @@
|
||||
"Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace. **Retirement Notice** On September 30, 2027, network security group (NSG) flow logs will be retired. Starting June 30, 2025, it will no longer be possible to create new NSG flow logs. Azure recommends migrating to virtual network flow logs. Review https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement for more information. For virtual network flow logs, consider applying the recommendation `Ensure that virtual network flow logs are captured and sent to Log Analytics` in this section.",
|
||||
"RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.",
|
||||
"ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Network security group`. 1. Select `+ Select target resource`. 1. Select `Network security group`. 1. Select a network security group. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`. ***Warning*** The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.",
|
||||
"AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. From the `Value` drop-down, check `Network security group` only. 1. Click `Apply`. 1. Ensure that at least one network security group flow log is listed and is configured to send logs to a `Log Analytics Workspace`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.",
|
||||
"AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation",
|
||||
"DefaultValue": "By default Network Security Group logs are not sent to Log Analytics."
|
||||
}
|
||||
@@ -1789,9 +1789,9 @@
|
||||
"Description": "Network Security Group Flow Logs should be enabled and the retention period set to greater than or equal to 90 days. **Retirement Notice** On September 30, 2027, network security group (NSG) flow logs will be retired. Starting June 30, 2025, it will no longer be possible to create new NSG flow logs. Azure recommends migrating to virtual network flow logs. Review https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement for more information. For virtual network flow logs, consider applying the recommendation `Ensure that virtual network flow log retention days is set to greater than or equal to 90` in this section.",
|
||||
"RationaleStatement": "Flow logs enable capturing information about IP traffic flowing in and out of network security groups. Logs can be used to check for anomalies and give insight into suspected breaches.",
|
||||
"ImpactStatement": "This will keep IP traffic logs for longer than 90 days. As a level 2, first determine your need to retain data, then apply your selection here. As this is data stored for longer, your monthly storage costs will increase depending on your data use.",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` 6. Select your storage account in the `Storage account` field 7. Select `Save` **Remediate from Azure CLI** Enable the `NSG flow logs` and set the Retention (days) to greater than or equal to 90 days. ``` az network watcher flow-log configure --nsg <NameorID of the Network Security Group> --enabled true --resource-group <resourceGroupName> --retention 91 --storage-account <NameorID of the storage account to save flow logs> ```",
|
||||
"AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher` 2. Select `NSG flow logs` blade in the Logs section 3. Select each Network Security Group from the list 4. Ensure `Status` is set to `On` 5. Ensure `Retention (days)` setting `greater than 90 days` **Audit from Azure CLI** ``` az network watcher flow-log show --resource-group <resourceGroup> --nsg <NameorID of the NetworkSecurityGroup> --query 'retentionPolicy' ``` Ensure that `enabled` is set to `true` and `days` is set to `greater then or equal to 90`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, configure `Virtual network` flow logs instead and set `Retention days` to `0`, `90`, or a number greater than `90`. **Remediate from Azure CLI** Update an existing flow log retention policy with: ``` az network watcher flow-log update --location <location> --name <flow-log> --retention <number-of-days> ```",
|
||||
"AuditProcedure": "**Audit from Azure Portal** 1. Go to `Network Watcher`. 1. Select `Flow logs`. 1. Review existing `Network security group` flow logs, if any remain, and ensure `Status` is set to `On` and `Retention (days)` is set to `0`, `90`, or a number greater than `90`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure CLI** ``` az network watcher flow-log list --location <location> --query [*].[name,retentionPolicy,targetResourceId] ``` Ensure each relevant flow log has retention days set to `0`, `90`, or a number greater than `90`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [5e1cd26a-5090-4fdb-9d6a-84a90335e22d](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F5e1cd26a-5090-4fdb-9d6a-84a90335e22d) **- Name:** 'Configure network security groups to use specific workspace, storage account and flowlog retention policy for traffic analytics'",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-overview:https://docs.microsoft.com/en-us/cli/azure/network/watcher/flow-log?view=azure-cli-latest:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-6-configure-log-storage-retention",
|
||||
"DefaultValue": "By default, Network Security Group Flow Logs are `disabled`."
|
||||
}
|
||||
|
||||
@@ -1292,9 +1292,9 @@
|
||||
"Description": "Ensure that network flow logs are captured and fed into a central log analytics workspace. **Retirement Notice** On September 30, 2027, network security group (NSG) flow logs will be retired. Starting June 30, 2025, it will no longer be possible to create new NSG flow logs. Azure recommends migrating to virtual network flow logs. Review https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement for more information. For virtual network flow logs, consider applying the recommendation `Ensure that virtual network flow logs are captured and sent to Log Analytics` in this section.",
|
||||
"RationaleStatement": "Network Flow Logs provide valuable insight into the flow of traffic around your network and feed into both Azure Monitor and Azure Sentinel (if in use), permitting the generation of visual flow diagrams to aid with analyzing for lateral movement, etc.",
|
||||
"ImpactStatement": "The impact of configuring NSG Flow logs is primarily one of cost and configuration. If deployed, it will create storage accounts that hold minimal amounts of data on a 5-day lifecycle before feeding to Log Analytics Workspace. This will increase the amount of data stored and used by Azure Monitor.",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Network security group`. 1. Select `+ Select target resource`. 1. Select `Network security group`. 1. Select a network security group. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`. ***Warning*** The remediation policy creates remediation deployment and names them by concatenating the subscription name and the resource group name. The MAXIMUM permitted length of a deployment name is 64 characters. Exceeding this will cause the remediation task to fail.",
|
||||
"AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. From the `Value` drop-down, check `Network security group` only. 1. Click `Apply`. 1. Ensure that at least one network security group flow log is listed and is configured to send logs to a `Log Analytics Workspace`. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "",
|
||||
"RemediationProcedure": "**Remediate from Azure Portal** Existing NSG flow logs can still be reviewed under `Network Watcher` > `Flow logs`. If you already have NSG flow logs configured, ensure they remain enabled and that `Traffic Analytics` sends data to a `Log Analytics Workspace` until migration is complete. Azure no longer allows creation of new NSG flow logs after June 30, 2025. For new or migrated deployments, create `Virtual network` flow logs instead: 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Select `+ Create`. 1. Select the desired Subscription. 1. For `Flow log type`, select `Virtual network`. 1. Select `+ Select target resource`. 1. Select `Virtual network`. 1. Select a virtual network. 1. Click `Confirm selection`. 1. Select or create a new Storage Account. 1. If using a v2 storage account, input the retention in days to retain the log. 1. Click `Next`. 1. Under `Analytics`, for `Flow log version`, select `Version 2`. 1. Check the box next to `Enable traffic analytics`. 1. Select a processing interval. 1. Select a `Log Analytics Workspace`. 1. Select `Next`. 1. Optionally add Tags. 1. Select `Review + create`. 1. Select `Create`.",
|
||||
"AuditProcedure": "**Audit from Azure Portal** 1. Navigate to `Network Watcher`. 1. Under `Logs`, select `Flow logs`. 1. Click `Add filter`. 1. From the `Filter` drop-down, select `Flow log type`. 1. Review existing `Network security group` flow logs, if any remain, to ensure they are enabled and configured to send logs to a `Log Analytics Workspace`. 1. Review `Virtual network` flow logs for new or migrated coverage. **Audit from Azure Policy** If referencing a digital copy of this Benchmark, clicking a Policy ID will open a link to the associated Policy definition in Azure. If referencing a printed copy, you can search Policy IDs from this URL: https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Definitions - **Policy ID:** [27960feb-a23c-4577-8d36-ef8b5f35e0be](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F27960feb-a23c-4577-8d36-ef8b5f35e0be) **- Name:** 'All flow log resources should be in enabled state' - **Policy ID:** [c251913d-7d24-4958-af87-478ed3b9ba41](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2Fc251913d-7d24-4958-af87-478ed3b9ba41) **- Name:** 'Flow logs should be configured for every network security group' - **Policy ID:** [4c3c6c5f-0d47-4402-99b8-aa543dd8bcee](https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyDetailBlade/definitionId/%2Fproviders%2FMicrosoft.Authorization%2FpolicyDefinitions%2F4c3c6c5f-0d47-4402-99b8-aa543dd8bcee) **- Name:** 'Flow logs should be configured for every virtual network'",
|
||||
"AdditionalInformation": "On September 30, 2027, NSG flow logs will be retired, and creating new NSG flow logs has not been possible since June 30, 2025. Azure recommends migrating to virtual network flow logs, which address NSG flow log limitations. After retirement, traffic analytics using NSG flow logs will no longer be supported, and existing NSG flow log resources will be deleted. Previously collected NSG flow log records will remain available per their retention policies. For details, see the official announcement: https://azure.microsoft.com/en-gb/updates?id=Azure-NSG-flow-logs-Retirement.",
|
||||
"References": "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-nsg-flow-logging-portal:https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-logging-threat-detection#lt-4-enable-network-logging-for-security-investigation",
|
||||
"DefaultValue": "By default Network Security Group logs are not sent to Log Analytics."
|
||||
}
|
||||
|
||||
@@ -709,17 +709,17 @@
|
||||
},
|
||||
{
|
||||
"Id": "3.1.8",
|
||||
"Description": "Ensure that Network Security Group Flow logs are captured and sent to Log Analytics",
|
||||
"Description": "Ensure that Network Watcher flow logs are captured and sent to Log Analytics",
|
||||
"Checks": [
|
||||
"network_flow_log_captured_sent"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Title": "Network Security Group Flow logs are captured and sent to Log Analytics",
|
||||
"Title": "Network Watcher flow logs are captured and sent to Log Analytics",
|
||||
"Section": "3. Logging and Monitoring",
|
||||
"SubSection": "3.1 Logging",
|
||||
"AttributeDescription": "Ensure that network flow logs are collected and sent to a central Log Analytics workspace for monitoring and analysis.",
|
||||
"AdditionalInformation": "Capturing network flow logs provides visibility into traffic patterns across your network, helping detect anomalies, potential lateral movement, and security threats. These logs integrate with Azure Monitor and Azure Sentinel, enabling advanced analytics and visualization for improved network security and incident response.",
|
||||
"AttributeDescription": "Ensure that Network Watcher flow logs for supported targets, such as virtual networks and network security groups, are collected and sent to a central Log Analytics workspace for monitoring and analysis.",
|
||||
"AdditionalInformation": "Capturing Network Watcher flow logs provides visibility into traffic patterns across your network, helping detect anomalies, potential lateral movement, and security threats. These logs integrate with Azure Monitor and Azure Sentinel, enabling advanced analytics and visualization for improved network security and incident response. For new deployments, prefer virtual network flow logs because NSG flow logs are on the retirement path.",
|
||||
"LevelOfRisk": 4,
|
||||
"Weight": 100
|
||||
}
|
||||
@@ -763,17 +763,17 @@
|
||||
},
|
||||
{
|
||||
"Id": "3.2.1",
|
||||
"Description": "Ensure that Network Security Group Flow Log retention period is 'greater than 90 days'",
|
||||
"Description": "Ensure that Network Watcher flow log retention period is '0 or at least 90 days'",
|
||||
"Checks": [
|
||||
"network_flow_log_more_than_90_days"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Title": "Network Security Group Flow Log retention period is 'greater than 90 days'",
|
||||
"Title": "Network Watcher flow log retention period is '0 or at least 90 days'",
|
||||
"Section": "3. Logging and Monitoring",
|
||||
"SubSection": "3.2 Retention",
|
||||
"AttributeDescription": "Enable Network Security Group (NSG) Flow Logs and configure the retention period to at least 90 days to capture and store IP traffic data for security monitoring and analysis.",
|
||||
"AdditionalInformation": "NSG Flow Logs provide visibility into network traffic, helping detect anomalies, unauthorized access, and potential security breaches. Retaining logs for at least 90 days ensures that historical data is available for incident investigation, compliance, and forensic analysis, strengthening overall network security monitoring.",
|
||||
"AttributeDescription": "Enable Network Watcher flow logs for supported targets, such as virtual networks and network security groups, and configure the retention period to 0 for unlimited retention or at least 90 days to capture and store IP traffic data for security monitoring and analysis.",
|
||||
"AdditionalInformation": "Network Watcher flow logs provide visibility into network traffic, helping detect anomalies, unauthorized access, and potential security breaches. Retaining logs for 0 days (unlimited) or at least 90 days ensures that historical data is available for incident investigation, compliance, and forensic analysis, strengthening overall network security monitoring. For new deployments, prefer virtual network flow logs because NSG flow logs are on the retirement path.",
|
||||
"LevelOfRisk": 3,
|
||||
"Weight": 10
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,9 @@
|
||||
{
|
||||
"Id": "1.1.4",
|
||||
"Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"repository_default_branch_dismisses_stale_reviews"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "1 Source Code",
|
||||
|
||||
@@ -525,7 +525,9 @@
|
||||
{
|
||||
"Id": "3.1.3.1.1",
|
||||
"Description": "Ensure users cannot delegate access to their mailbox",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_mail_delegation_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -714,7 +716,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.2.1",
|
||||
"Description": "Ensure link identification behind shortened URLs is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_shortener_scanning_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -735,7 +739,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.2.2",
|
||||
"Description": "Ensure scan linked images for malicious content is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_external_image_scanning_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -756,7 +762,9 @@
|
||||
{
|
||||
"Id": "3.1.3.4.2.3",
|
||||
"Description": "Ensure warning prompt is shown for any click on links to untrusted domains",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_untrusted_link_warnings_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -882,7 +890,9 @@
|
||||
{
|
||||
"Id": "3.1.3.5.1",
|
||||
"Description": "Ensure POP and IMAP access is disabled for all users",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_pop_imap_access_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -903,7 +913,9 @@
|
||||
{
|
||||
"Id": "3.1.3.5.2",
|
||||
"Description": "Ensure automatic forwarding options are disabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_auto_forwarding_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -924,7 +936,9 @@
|
||||
{
|
||||
"Id": "3.1.3.5.3",
|
||||
"Description": "Ensure per-user outbound gateways is disabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_per_user_outbound_gateway_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -966,7 +980,9 @@
|
||||
{
|
||||
"Id": "3.1.3.6.1",
|
||||
"Description": "Ensure enhanced pre-delivery message scanning is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_enhanced_pre_delivery_scanning_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
@@ -1008,7 +1024,9 @@
|
||||
{
|
||||
"Id": "3.1.3.7.1",
|
||||
"Description": "Ensure comprehensive mail storage is enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_comprehensive_mail_storage_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "3 Apps",
|
||||
|
||||
@@ -556,7 +556,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.1.1",
|
||||
"Description": "Mail Delegation SHOULD be disabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_mail_delegation_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -725,7 +727,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.6.1",
|
||||
"Description": "Identify links behind shortened URLs SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_shortener_scanning_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -738,7 +742,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.6.2",
|
||||
"Description": "Scan linked images SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_external_image_scanning_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -751,7 +757,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.6.3",
|
||||
"Description": "Show warning prompt for any click on links to untrusted domains SHALL be enabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_untrusted_link_warnings_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -907,7 +915,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.9.1",
|
||||
"Description": "POP and IMAP access SHALL be disabled to protect sensitive agency or organization emails from being accessed through legacy applications or other third-party mail clients",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_pop_imap_access_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -933,7 +943,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.11.1",
|
||||
"Description": "Automatic forwarding SHOULD be disabled, especially to external domains",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_auto_forwarding_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -946,7 +958,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.12.1",
|
||||
"Description": "Using a per-user outbound gateway that is a mail server other than the Google Workspace (GWS) mail servers SHALL be disabled",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_per_user_outbound_gateway_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -985,7 +999,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.15.1",
|
||||
"Description": "Enhanced pre-delivery message scanning SHALL be enabled to prevent phishing",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_enhanced_pre_delivery_scanning_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
@@ -1037,7 +1053,9 @@
|
||||
{
|
||||
"Id": "GWS.GMAIL.17.1",
|
||||
"Description": "Comprehensive mail storage SHOULD be enabled to allow information traceability across applications",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"gmail_comprehensive_mail_storage_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "Gmail",
|
||||
|
||||
@@ -9,6 +9,16 @@ import requests
|
||||
import yaml
|
||||
from packaging import version
|
||||
|
||||
from prowler.lib.check.compliance_models import load_compliance_framework_universal
|
||||
|
||||
# Re-exported from a leaf module so prowler.lib.check.utils can import the
|
||||
# constant without participating in the config <-> compliance_models <-> utils
|
||||
# import cycle. Existing consumers continue to import from this module.
|
||||
# The `as EXTERNAL_TOOL_PROVIDERS` rename is the PEP 484 explicit re-export
|
||||
# form so static analyzers (CodeQL, mypy, ruff) treat the name as public.
|
||||
from prowler.lib.check.external_tool_providers import ( # noqa: F401
|
||||
EXTERNAL_TOOL_PROVIDERS as EXTERNAL_TOOL_PROVIDERS,
|
||||
)
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
|
||||
@@ -38,7 +48,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.25.0"
|
||||
prowler_version = "5.26.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
@@ -68,10 +78,6 @@ class Provider(str, Enum):
|
||||
VERCEL = "vercel"
|
||||
|
||||
|
||||
# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo)
|
||||
# and bypass standard check/service loading.
|
||||
EXTERNAL_TOOL_PROVIDERS = frozenset({"iac", "llm", "image"})
|
||||
|
||||
# Compliance
|
||||
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
@@ -81,8 +87,8 @@ def get_available_compliance_frameworks(provider=None):
|
||||
providers = [p.value for p in Provider]
|
||||
if provider:
|
||||
providers = [provider]
|
||||
for provider in providers:
|
||||
compliance_dir = f"{actual_directory}/../compliance/{provider}"
|
||||
for current_provider in providers:
|
||||
compliance_dir = f"{actual_directory}/../compliance/{current_provider}"
|
||||
if not os.path.isdir(compliance_dir):
|
||||
continue
|
||||
with os.scandir(compliance_dir) as files:
|
||||
@@ -91,6 +97,23 @@ def get_available_compliance_frameworks(provider=None):
|
||||
available_compliance_frameworks.append(
|
||||
file.name.removesuffix(".json")
|
||||
)
|
||||
# Also scan top-level compliance/ for multi-provider (universal) JSONs.
|
||||
# When a specific provider was requested, only include the framework if it
|
||||
# declares support for that provider; otherwise include all universal frameworks.
|
||||
compliance_root = f"{actual_directory}/../compliance"
|
||||
if os.path.isdir(compliance_root):
|
||||
with os.scandir(compliance_root) as files:
|
||||
for file in files:
|
||||
if file.is_file() and file.name.endswith(".json"):
|
||||
name = file.name.removesuffix(".json")
|
||||
if provider:
|
||||
framework = load_compliance_framework_universal(file.path)
|
||||
if framework is None or not framework.supports_provider(
|
||||
provider
|
||||
):
|
||||
continue
|
||||
if name not in available_compliance_frameworks:
|
||||
available_compliance_frameworks.append(name)
|
||||
return available_compliance_frameworks
|
||||
|
||||
|
||||
@@ -136,7 +159,7 @@ def set_output_timestamp(
|
||||
Override the global output timestamps so generated artifacts reflect a specific scan.
|
||||
Returns the previous values so callers can restore them afterwards.
|
||||
"""
|
||||
global timestamp, timestamp_utc, output_file_timestamp, timestamp_iso
|
||||
global output_file_timestamp, timestamp_iso
|
||||
|
||||
previous_values = (
|
||||
timestamp.value,
|
||||
|
||||
@@ -6,7 +6,9 @@ aws:
|
||||
# aws.disallowed_regions --> List of AWS regions to exclude from the scan.
|
||||
# Also settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable or
|
||||
# the --excluded-region CLI flag. Precedence: CLI > env var > config file.
|
||||
# disallowed_regions: []
|
||||
disallowed_regions:
|
||||
- me-south-1
|
||||
- me-central-1
|
||||
# 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`:
|
||||
# Mutelist:
|
||||
# Accounts:
|
||||
@@ -139,6 +141,7 @@ aws:
|
||||
# ]
|
||||
organizations_enabled_regions: []
|
||||
organizations_trusted_delegated_administrators: []
|
||||
organizations_trusted_ids: []
|
||||
|
||||
# AWS ECR
|
||||
# aws.ecr_repositories_scan_vulnerabilities_in_latest_image
|
||||
|
||||
@@ -299,12 +299,22 @@ def print_compliance_frameworks(
|
||||
def print_compliance_requirements(
|
||||
bulk_compliance_frameworks: dict, compliance_frameworks: list
|
||||
):
|
||||
from prowler.lib.check.compliance_models import ComplianceFramework
|
||||
|
||||
for compliance_framework in compliance_frameworks:
|
||||
for key in bulk_compliance_frameworks.keys():
|
||||
framework = bulk_compliance_frameworks[key].Framework
|
||||
provider = bulk_compliance_frameworks[key].Provider
|
||||
version = bulk_compliance_frameworks[key].Version
|
||||
requirements = bulk_compliance_frameworks[key].Requirements
|
||||
entry = bulk_compliance_frameworks[key]
|
||||
is_universal = isinstance(entry, ComplianceFramework)
|
||||
if is_universal:
|
||||
framework = entry.framework
|
||||
provider = entry.provider or "Multi-provider"
|
||||
version = entry.version
|
||||
requirements = entry.requirements
|
||||
else:
|
||||
framework = entry.Framework
|
||||
provider = entry.Provider or "Multi-provider"
|
||||
version = entry.Version
|
||||
requirements = entry.Requirements
|
||||
# We can list the compliance requirements for a given framework using the
|
||||
# bulk_compliance_frameworks keys since they are the compliance specification file name
|
||||
if compliance_framework == key:
|
||||
@@ -313,10 +323,23 @@ def print_compliance_requirements(
|
||||
)
|
||||
for requirement in requirements:
|
||||
checks = ""
|
||||
for check in requirement.Checks:
|
||||
checks += f" {Fore.YELLOW}\t\t{check}\n{Style.RESET_ALL}"
|
||||
if is_universal:
|
||||
req_checks = requirement.checks
|
||||
req_id = requirement.id
|
||||
req_description = requirement.description
|
||||
else:
|
||||
req_checks = requirement.Checks
|
||||
req_id = requirement.Id
|
||||
req_description = requirement.Description
|
||||
if isinstance(req_checks, dict):
|
||||
for prov, check_list in req_checks.items():
|
||||
for check in check_list:
|
||||
checks += f" {Fore.YELLOW}\t\t[{prov}] {check}\n{Style.RESET_ALL}"
|
||||
else:
|
||||
for check in req_checks:
|
||||
checks += f" {Fore.YELLOW}\t\t{check}\n{Style.RESET_ALL}"
|
||||
print(
|
||||
f"Requirement Id: {Fore.MAGENTA}{requirement.Id}{Style.RESET_ALL}\n\t- Description: {requirement.Description}\n\t- Checks:\n{checks}"
|
||||
f"Requirement Id: {Fore.MAGENTA}{req_id}{Style.RESET_ALL}\n\t- Description: {req_description}\n\t- Checks:\n{checks}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import sys
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
|
||||
from prowler.lib.check.check import parse_checks_from_file
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.models import CheckMetadata, Severity
|
||||
@@ -21,11 +22,12 @@ def load_checks_to_execute(
|
||||
categories: set = None,
|
||||
resource_groups: set = None,
|
||||
list_checks: bool = False,
|
||||
universal_frameworks: dict = None,
|
||||
) -> set:
|
||||
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
|
||||
try:
|
||||
# Bypass check loading for providers that use Trivy directly
|
||||
if provider in ("iac", "image"):
|
||||
# Bypass check loading for providers that use external tools directly
|
||||
if provider in EXTERNAL_TOOL_PROVIDERS:
|
||||
return set()
|
||||
|
||||
# Local subsets
|
||||
@@ -154,12 +156,21 @@ def load_checks_to_execute(
|
||||
if not bulk_compliance_frameworks:
|
||||
bulk_compliance_frameworks = Compliance.get_bulk(provider=provider)
|
||||
for compliance_framework in compliance_frameworks:
|
||||
checks_to_execute.update(
|
||||
CheckMetadata.list(
|
||||
bulk_compliance_frameworks=bulk_compliance_frameworks,
|
||||
compliance_framework=compliance_framework,
|
||||
# Try universal frameworks first (snake_case dict-keyed checks)
|
||||
if (
|
||||
universal_frameworks
|
||||
and compliance_framework in universal_frameworks
|
||||
):
|
||||
fw = universal_frameworks[compliance_framework]
|
||||
for req in fw.requirements:
|
||||
checks_to_execute.update(req.checks.get(provider.lower(), []))
|
||||
elif compliance_framework in bulk_compliance_frameworks:
|
||||
checks_to_execute.update(
|
||||
CheckMetadata.list(
|
||||
bulk_compliance_frameworks=bulk_compliance_frameworks,
|
||||
compliance_framework=compliance_framework,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Handle if there are categories passed using --categories
|
||||
elif categories:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user