Compare commits

..
Author SHA1 Message Date
Hugo P.Brito ef69a347b1 docs(ui): add changelog entry for drawer mobile scroll fix (#11199) 2026-05-18 13:59:45 +01:00
Hugo P.Brito e691176612 fix(ui): contain finding drawer horizontal scroll to the tab bar on mobile
On narrow screens the five section tabs (Finding Overview, Remediation,
Findings for this resource, Scans, Events) did not fit, and because they
had no dedicated scroll container the overflow leaked up to the resource
card (whose overflow-y-auto implicitly resolves overflow-x to auto),
scrolling the entire drawer body horizontally.

- TabsList is now its own horizontal scroll container: triggers keep
  their intrinsic size (shrink-0) and stay on one line (whitespace-nowrap)
  so a partially-visible tab plus a thin scrollbar make it obvious more
  tabs are reachable by scrolling.
- The resource card is pinned to overflow-x-hidden + min-w-0 so no child
  can force the drawer body to scroll sideways.
- minimal-scrollbar now sets a 6px height so the horizontal scrollbar is
  actually visible on WebKit (it previously only set width).
2026-05-18 13:57:26 +01:00
Pedro Martín 5ca6e31f45 fix(vercel): exclude API token from serialization and repr (#11198) 2026-05-18 14:30:44 +02:00
Alan Buscaglia 21df5c58b1 fix(ui): preserve scans provider wizard flow 2026-05-18 13:34:32 +02:00
lydiavilchez 964548a677 fix(googleworkspace): add API filter to Drive and Calendar services to prevent rate limiting (#11195) 2026-05-18 12:57:22 +02:00
Pedro Martín 5d34577b0b feat(reporting): bound PDF compliance report memory and CPU (#11160) 2026-05-18 11:46:26 +02:00
Pedro Martín 855e74add0 chore(deps): fix osv-scanner from API (#11192) 2026-05-18 10:20:43 +02:00
Adrián Peña 40b7cb3991 fix(api): skip scan tasks when provider was deleted (#11185) 2026-05-15 13:48:02 +02:00
Pedro Martín c1cfda530f docs(powerbi): add new documentation + update .pbit (#11177) 2026-05-15 09:58:46 +02:00
lydiavilchez 9894ac7bc3 feat(googleworkspace): implement Chat service with 6 CIS checks (#11126) 2026-05-14 17:19:11 +02:00
Pedro Martín 8ae076f22c fix(gcp): update duplicated CIS requirement ID (#11180) 2026-05-14 16:17:04 +02:00
Adrián Peña 27fb8518cd chore(sdk): pin root transitive deps to prevent silent drift (#11178) 2026-05-14 16:14:34 +02:00
Pepe Fagoaga 375de5dc87 docs: replace safety for osv-scanner (#11179) 2026-05-14 15:04:09 +02:00
Pepe Fagoaga 3410fc927a chore(security): replace safety with osv-scanner (#11167) 2026-05-14 14:35:09 +02:00
Alejandro Bailo dcf91ef252 feat(ui): add health check endpoint (#11145) 2026-05-14 13:47:48 +02:00
lydiavilchez bf4fd8fabd fix(googleworkspace): use per-service resources for Directory (#11176) 2026-05-14 13:07:06 +02:00
Alejandro Bailo 3d65208fd3 fix(ui): update vulnerable npm packages (#11173) 2026-05-14 12:55:29 +02:00
Adrián Peña 7d3ed62e90 chore(sdk): migrate from poetry to uv (#11162) 2026-05-14 12:51:57 +02:00
lydiavilchez 5f92989492 fix(googleworkspace): use per-service resources for Calendar and Drive (#11161) 2026-05-14 12:43:29 +02:00
Hugo Pereira Brito 6befa78978 fix(cloudflare): plan-aware WAF FAIL hints for zones (#9896) 2026-05-14 12:27:47 +02:00
lydiavilchez 78af0c24fe fix(googleworkspace): use per-service resources for Gmail (#11169) 2026-05-14 12:01:07 +02:00
Andoni Alonso 1bb547e5e1 docs(cloudflare): add pre-configured token creation links (#11156) 2026-05-14 11:58:00 +02:00
JuneandDaniel Barranquero 1f39b01fb2 feat(sagemaker): add sagemaker_domain_sso_configured check (#11094)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-05-14 11:42:30 +02:00
AOrpsandAdrián Jesús Peña Rodríguez fb0ef391f2 ci(api): replace poetry with uv (api) (#10775)
Signed-off-by: AOrps <aorbeandrews@gmail.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
2026-05-14 11:17:17 +02:00
f2e6a3264d chore(ui): scope prek pre-commit to staged files, drop legacy husky (#11118)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:12:25 +02:00
Hugo Pereira Brito 9bd4e4b65c fix(ui): enforce 100-char limit on mute rule name input (#11158) 2026-05-14 09:13:36 +01:00
Hugo Pereira Brito 68ffb2b219 docs(sdk): update Scanning Unused Services tutorial (#11151) 2026-05-14 08:26:29 +01:00
Hugo Pereira Brito 739be07077 chore(aws): skip unattached IAM policies unless --scan-unused-services (#11150) 2026-05-14 08:10:20 +01:00
Alejandro Bailo 0abbb7fc59 feat(mcp): add finding groups tools (#11140) 2026-05-13 18:11:32 +02:00
Alan Buscaglia 0b4393776c chore: harden npm supply chain controls (#11157) 2026-05-13 17:30:25 +02:00
280 changed files with 22257 additions and 22640 deletions
+5 -6
View File
@@ -2,20 +2,19 @@
# 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.
# 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).
# Block 2: install Python deps (uv manages the venv on `uv sync`).
[[pre-start]]
deps = "poetry install --with dev"
deps = "uv sync"
# Block 3: reminder last visible output before `wt switch` returns.
# 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)'"
reminder = "echo '>> Reminder: activate the venv in this shell with: source .venv/bin/activate'"
# Background: pnpm install runs while you start working.
# Tail logs via `wt config state logs`.
+169
View File
@@ -0,0 +1,169 @@
name: 'OSV-Scanner'
description: 'Install osv-scanner and scan a lockfile, failing on HIGH/CRITICAL/UNKNOWN severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).'
author: 'Prowler'
inputs:
lockfile:
description: 'Path to the lockfile to scan, relative to the repository root (e.g. uv.lock, api/uv.lock, ui/pnpm-lock.yaml).'
required: true
severity-levels:
description: 'Comma-separated severity levels that fail the scan. Default: HIGH,CRITICAL,UNKNOWN.'
required: false
default: 'HIGH,CRITICAL,UNKNOWN'
version:
description: 'osv-scanner release tag to install. When overriding, you MUST also override binary-sha256.'
required: false
default: 'v2.3.8'
binary-sha256:
description: 'Expected SHA256 of osv-scanner_linux_amd64 for the given version. Default tracks v2.3.8. See https://github.com/google/osv-scanner/releases/download/<version>/osv-scanner_SHA256SUMS.'
required: false
default: 'bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc'
post-pr-comment:
description: 'Post or update a PR comment with the scan report. Only effective on pull_request events. Requires pull-requests: write permission on the caller job.'
required: false
default: 'true'
runs:
using: 'composite'
steps:
- name: Install osv-scanner
shell: bash
env:
OSV_SCANNER_VERSION: ${{ inputs.version }}
# Download the binary AND the published SHA256SUMS file, then verify the
# binary checksum against the upstream-signed manifest. Aborts on mismatch.
run: |
set -euo pipefail
if command -v osv-scanner >/dev/null 2>&1; then
INSTALLED="$(osv-scanner --version 2>&1 | awk '/scanner version/ {print $NF; exit}')"
if [ "v${INSTALLED}" = "${OSV_SCANNER_VERSION}" ]; then
echo "osv-scanner ${OSV_SCANNER_VERSION} already installed."
exit 0
fi
fi
BASE="https://github.com/google/osv-scanner/releases/download/${OSV_SCANNER_VERSION}"
BIN_NAME="osv-scanner_linux_amd64"
curl -fSL --retry 3 "${BASE}/${BIN_NAME}" -o "${RUNNER_TEMP}/${BIN_NAME}"
curl -fSL --retry 3 "${BASE}/osv-scanner_SHA256SUMS" -o "${RUNNER_TEMP}/osv-scanner_SHA256SUMS"
(cd "${RUNNER_TEMP}" && sha256sum --check --ignore-missing osv-scanner_SHA256SUMS)
chmod +x "${RUNNER_TEMP}/${BIN_NAME}"
sudo mv "${RUNNER_TEMP}/${BIN_NAME}" /usr/local/bin/osv-scanner
rm -f "${RUNNER_TEMP}/osv-scanner_SHA256SUMS"
osv-scanner --version
- name: Run osv-scanner
id: scan
shell: bash
working-directory: ${{ github.workspace }}
env:
OSV_LOCKFILE: ${{ inputs.lockfile }}
OSV_SEVERITY_LEVELS: ${{ inputs.severity-levels }}
OSV_REPORT_FILE: ${{ runner.temp }}/osv-scanner-findings.json
# Per-vulnerability ignores (reason + expiry) live in osv-scanner.toml at the repo root, if present.
# Severity filter is enforced in the wrapper via OSV_SEVERITY_LEVELS.
# `continue-on-error: true` lets the PR-comment step run even when findings exist;
# the gate step below re-fails the job from the wrapper exit code.
continue-on-error: true
run: ./.github/scripts/osv-scan.sh --lockfile="${OSV_LOCKFILE}"
- name: Post osv-scanner report on PR
if: >-
always()
&& inputs.post-pr-comment == 'true'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OSV_REPORT_FILE: ${{ runner.temp }}/osv-scanner-findings.json
OSV_LOCKFILE: ${{ inputs.lockfile }}
OSV_SEVERITY_LEVELS: ${{ inputs.severity-levels }}
with:
script: |
const fs = require('fs');
const lockfile = process.env.OSV_LOCKFILE;
const severityLevels = process.env.OSV_SEVERITY_LEVELS;
const reportFile = process.env.OSV_REPORT_FILE;
const marker = `<!-- osv-scanner-report:${lockfile} -->`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
let findings = [];
if (fs.existsSync(reportFile)) {
try {
findings = JSON.parse(fs.readFileSync(reportFile, 'utf8'));
} catch (err) {
core.warning(`Could not parse ${reportFile}: ${err.message}`);
return;
}
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body?.includes(marker));
if (findings.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
core.info(`Deleted stale osv-scanner comment for ${lockfile}.`);
} else {
core.info(`No findings and no stale comment for ${lockfile}.`);
}
return;
}
const sevIcon = (s) => ({
CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🟢', UNKNOWN: '⚪',
}[s] || '⚪');
const escape = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
const rows = findings.map(f =>
`| ${sevIcon(f.severity)} ${f.severity}${f.score ? ` (${f.score})` : ''} | \`${escape(f.id)}\` | \`${escape(f.ecosystem)}/${escape(f.package)}\` | \`${escape(f.version)}\` | ${escape(f.summary || '(no summary)')} |`
);
const body = [
marker,
`## 🔒 osv-scanner: ${findings.length} finding(s) in \`${lockfile}\``,
'',
`Severity gate: \`${severityLevels}\``,
'',
'| Severity | ID | Package | Version | Summary |',
'|----------|----|---------|---------|---------|',
...rows,
'',
`To accept a finding, add an \`[[IgnoredVulns]]\` entry to \`osv-scanner.toml\` at the repo root with a reason and \`ignoreUntil\`.`,
'',
`<sub>[View run](${runUrl})</sub>`,
].join('\n');
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
core.info(`Updated osv-scanner comment for ${lockfile}.`);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
core.info(`Posted new osv-scanner comment for ${lockfile}.`);
}
- name: Enforce osv-scanner severity gate
shell: bash
env:
SCAN_OUTCOME: ${{ steps.scan.outcome }}
run: |
if [ "${SCAN_OUTCOME}" != "success" ]; then
echo "osv-scanner gate: scan reported findings (outcome=${SCAN_OUTCOME})" >&2
exit 1
fi
@@ -1,116 +0,0 @@
name: 'Setup Python with Poetry'
description: 'Setup Python environment with Poetry and install dependencies'
author: 'Prowler'
inputs:
python-version:
description: 'Python version to use'
required: true
working-directory:
description: 'Working directory for Poetry'
required: false
default: '.'
poetry-version:
description: 'Poetry version to install'
required: false
default: '2.3.4'
install-dependencies:
description: 'Install Python dependencies with Poetry'
required: false
default: 'true'
update-lock:
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'
extras:
description: 'Comma-separated list of project extras to install (e.g. "okta"). Pass "all" to install every extra.'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Replace @master with current branch in pyproject.toml (prowler repo only)
if: github.event_name == 'pull_request' && github.base_ref == 'master' && github.repository == 'prowler-cloud/prowler'
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
run: |
BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
UPSTREAM="prowler-cloud/prowler"
if [ "$HEAD_REPO" != "$UPSTREAM" ]; then
echo "Fork PR detected (${HEAD_REPO}), rewriting VCS URL to fork"
sed -i "s|git+https://github.com/prowler-cloud/prowler\([^@]*\)@master|git+https://github.com/${HEAD_REPO}\1@$BRANCH_NAME|g" pyproject.toml
else
echo "Same-repo PR, using branch: $BRANCH_NAME"
sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml
fi
- name: Install poetry
shell: bash
run: |
python -m pip install --upgrade pip
pipx install poetry==${INPUTS_POETRY_VERSION}
env:
INPUTS_POETRY_VERSION: ${{ inputs.poetry-version }}
- name: Update poetry.lock with latest Prowler commit
if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha')
echo "Latest commit hash: $LATEST_COMMIT"
sed -i '/url = "https:\/\/github\.com\/prowler-cloud\/prowler\.git"/,/resolved_reference = / {
s/resolved_reference = "[a-f0-9]\{40\}"/resolved_reference = "'"$LATEST_COMMIT"'"/
}' poetry.lock
echo "Updated resolved_reference:"
grep -A2 -B2 "resolved_reference" poetry.lock
- name: Update poetry.lock (prowler repo only)
if: github.repository == 'prowler-cloud/prowler' && inputs.update-lock == 'true'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: poetry lock
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ inputs.python-version }}
# 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'
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
INPUTS_EXTRAS: ${{ inputs.extras }}
run: |
if [ "$INPUTS_EXTRAS" = "all" ]; then
poetry install --no-root --all-extras
elif [ -n "$INPUTS_EXTRAS" ]; then
EXTRAS_ARGS=""
for extra in ${INPUTS_EXTRAS//,/ }; do
EXTRAS_ARGS="$EXTRAS_ARGS -E $extra"
done
poetry install --no-root $EXTRAS_ARGS
else
poetry install --no-root
fi
poetry run pip list
- name: Update Prowler Cloud API Client
if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
poetry remove prowler-cloud-api-client
poetry add ./prowler-cloud-api-client
@@ -0,0 +1,90 @@
name: 'Setup Python with uv'
description: 'Setup Python environment with uv and install dependencies'
author: 'Prowler'
inputs:
python-version:
description: 'Python version to use'
required: true
working-directory:
description: 'Working directory for uv'
required: false
default: '.'
uv-version:
description: 'uv version to install'
required: false
default: '0.11.14'
install-dependencies:
description: 'Install Python dependencies with uv'
required: false
default: 'true'
runs:
using: 'composite'
steps:
- name: Replace @master with current branch in pyproject.toml (prowler repo only)
if: github.event_name == 'pull_request' && github.base_ref == 'master' && github.repository == 'prowler-cloud/prowler'
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
run: |
BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
UPSTREAM="prowler-cloud/prowler"
if [ "$HEAD_REPO" != "$UPSTREAM" ]; then
echo "Fork PR detected (${HEAD_REPO}), rewriting VCS URL to fork"
sed -i "s|git+https://github.com/prowler-cloud/prowler\([^@]*\)@master|git+https://github.com/${HEAD_REPO}\1@$BRANCH_NAME|g" pyproject.toml
else
echo "Same-repo PR, using branch: $BRANCH_NAME"
sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml
fi
- name: Update uv.lock with latest Prowler commit
if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha')
echo "Latest commit hash: $LATEST_COMMIT"
sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock
echo "Updated uv.lock entry:"
grep "prowler-cloud/prowler" uv.lock
- name: Update uv.lock SDK commit (prowler repo on push)
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha')
echo "Latest commit hash: $LATEST_COMMIT"
sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock
echo "Updated uv.lock entry:"
grep "prowler-cloud/prowler" uv.lock
- name: Install uv
shell: bash
env:
UV_VERSION: ${{ inputs.uv-version }}
run: pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir "uv==${UV_VERSION}"
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
- name: Install Python dependencies
if: inputs.install-dependencies == 'true'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
uv sync --no-install-project
uv run pip list
- name: Update Prowler Cloud API Client
if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
uv remove prowler-cloud-api-client
uv add ./prowler-cloud-api-client
+2 -1
View File
@@ -36,6 +36,7 @@ Please add a detailed description of how to review this PR.
#### UI
- [ ] All issue/task requirements work as expected on the UI
- [ ] If this PR adds or updates npm dependencies, include package-health evidence (maintenance, popularity, known vulnerabilities, license, release age) and explain why existing/native alternatives are insufficient.
- [ ] Screenshots/Video of the functionality flow (if applicable) - Mobile (X < 640px)
- [ ] Screenshots/Video of the functionality flow (if applicable) - Table (640px > X < 1024px)
- [ ] Screenshots/Video of the functionality flow (if applicable) - Desktop (X > 1024px)
@@ -48,7 +49,7 @@ Please add a detailed description of how to review this PR.
- [ ] Performance test results (if applicable)
- [ ] Any other relevant evidence of the implementation (if applicable)
- [ ] Verify if API specs need to be regenerated.
- [ ] Check if version updates are required (e.g., specs, Poetry, etc.).
- [ ] Check if version updates are required (e.g., specs, uv, etc.).
- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/api/CHANGELOG.md), if applicable.
### License
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Run osv-scanner and fail when findings match the configured severity levels.
#
# Replaces `safety check --policy-file .safety-policy.yml`. Used by:
# - .github/actions/osv-scanner/action.yml (composite action)
# - .github/workflows/api-security.yml, sdk-security.yml, ui-security.yml
#
# Severity levels (comma-separated) are read from OSV_SEVERITY_LEVELS.
# Default: HIGH,CRITICAL,UNKNOWN — preserves prior .safety-policy.yml policy
# (ignore-cvss-severity-below: 7 + ignore-cvss-unknown-severity: False).
# osv-scanner has no native CVSS threshold (google/osv-scanner#1400, closed
# not-planned). Severity is derived from $group.max_severity (numeric CVSS
# score string) which osv-scanner emits per group.
#
# CVSS v3 score → categorical label:
# CRITICAL >= 9.0
# HIGH >= 7.0
# MEDIUM >= 4.0
# LOW > 0.0
# UNKNOWN no score available
#
# Per-vulnerability ignores (with reason + expiry) live in osv-scanner.toml at
# the repo root, if it exists. Without that file, osv-scanner uses defaults.
#
# Usage:
# osv-scan.sh [osv-scanner pass-through args...]
# Examples:
# osv-scan.sh --lockfile=uv.lock
# osv-scan.sh --recursive .
# OSV_SEVERITY_LEVELS=CRITICAL osv-scan.sh --lockfile=uv.lock
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
CONFIG="${ROOT}/osv-scanner.toml"
SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-HIGH,CRITICAL,UNKNOWN}"
for bin in osv-scanner jq; do
if ! command -v "${bin}" >/dev/null 2>&1; then
echo "error: ${bin} not found in PATH" >&2
exit 2
fi
done
SCAN_ARGS=()
if [ -f "${CONFIG}" ]; then
SCAN_ARGS+=(--config="${CONFIG}")
fi
# Exit codes: 0=clean, 1=findings, 127=no supported files, 128=internal error.
STDERR="$(mktemp)"
trap 'rm -f "${STDERR}"' EXIT
set +e
OUTPUT="$(osv-scanner scan source "${SCAN_ARGS[@]}" --format=json "$@" 2>"${STDERR}")"
RC=$?
set -e
case "${RC}" in
0|1) ;;
127) echo "osv-scanner: no supported lockfiles in scan target"; exit 0 ;;
*)
echo "osv-scanner: exited with code ${RC}" >&2
[ -s "${STDERR}" ] && cat "${STDERR}" >&2
exit "${RC}"
;;
esac
# Build a JSON array of normalized severity levels for jq.
SEVERITY_JSON="$(printf '%s' "${SEVERITY_LEVELS}" | jq -Rc '
split(",") | map(ascii_upcase | sub("^\\s+"; "") | sub("\\s+$"; ""))
')"
# Walk each vulnerability, look up its group's max_severity (numeric CVSS),
# map to a categorical label, then filter by OSV_SEVERITY_LEVELS.
FINDINGS="$(printf '%s' "${OUTPUT}" | jq --argjson sevs "${SEVERITY_JSON}" '
[ .results[]?.packages[]?
| . as $pkg
| ($pkg.groups // []) as $groups
| ($pkg.vulnerabilities // [])[]
| . as $vuln
| ([ $groups[] | select((.ids // []) | index($vuln.id)) ][0] // {}) as $group
| (($group.max_severity // "") | tonumber? // null) as $score
| (if $score == null then "UNKNOWN"
elif $score >= 9.0 then "CRITICAL"
elif $score >= 7.0 then "HIGH"
elif $score >= 4.0 then "MEDIUM"
elif $score > 0 then "LOW"
else "UNKNOWN"
end) as $label
| {
id: $vuln.id,
severity: $label,
score: $score,
summary: ($vuln.summary // null),
package: $pkg.package.name,
version: $pkg.package.version,
ecosystem: $pkg.package.ecosystem
}
| select(.severity as $s | $sevs | any(. == $s))
]
')"
COUNT="$(printf '%s' "${FINDINGS}" | jq 'length')"
# Write the findings JSON to OSV_REPORT_FILE so callers (e.g. the composite
# action's PR-comment step) can consume the same data the gate decision uses.
if [ -n "${OSV_REPORT_FILE:-}" ]; then
printf '%s' "${FINDINGS}" > "${OSV_REPORT_FILE}"
fi
if [ "${COUNT}" -gt 0 ]; then
echo "osv-scanner: ${COUNT} finding(s) at severity ${SEVERITY_LEVELS}"
printf '%s' "${FINDINGS}" | jq -r '
.[] | " [\(.severity)\(if .score then " \(.score)" else "" end)] \(.id) \(.ecosystem)/\(.package)@\(.version) — \(.summary // "(no summary)")"
'
echo
echo "To accept a finding, create osv-scanner.toml at the repo root with a reason and ignoreUntil."
exit 1
fi
echo "osv-scanner: no findings at severity levels: ${SEVERITY_LEVELS}"
+8 -8
View File
@@ -43,6 +43,7 @@ jobs:
pypi.org:443
files.pythonhosted.org:443
api.github.com:443
raw.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -63,26 +64,25 @@ jobs:
api/CHANGELOG.md
api/AGENTS.md
- name: Setup Python with Poetry
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-poetry
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
working-directory: ./api
update-lock: 'true'
- name: Poetry check
- name: uv lock check
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry check --lock
run: uv lock --check
- name: Ruff lint
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run ruff check . --exclude contrib
run: uv run ruff check . --exclude contrib
- name: Ruff format
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run ruff format --check . --exclude contrib
run: uv run ruff format --check . --exclude contrib
- name: Pylint
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run pylint --disable=W,C,R,E -j 0 -rn -sn src/
run: uv run pylint --disable=W,C,R,E -j 0 -rn -sn src/
+26 -15
View File
@@ -9,7 +9,9 @@ on:
- 'api/**'
- '.github/workflows/api-tests.yml'
- '.github/workflows/api-security.yml'
- '.github/actions/setup-python-poetry/**'
- '.github/actions/setup-python-uv/**'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
pull_request:
branches:
- "master"
@@ -18,7 +20,9 @@ on:
- 'api/**'
- '.github/workflows/api-tests.yml'
- '.github/workflows/api-security.yml'
- '.github/actions/setup-python-poetry/**'
- '.github/actions/setup-python-uv/**'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -35,6 +39,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: read
pull-requests: write # osv-scanner action posts/updates a PR comment with findings
strategy:
matrix:
python-version:
@@ -52,10 +57,12 @@ jobs:
pypi.org:443
files.pythonhosted.org:443
github.com:443
auth.safetycli.com:443
pyup.io:443
data.safetycli.com:443
api.github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
api.osv.dev:443
api.deps.dev:443
osv-vulnerabilities.storage.googleapis.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -70,30 +77,34 @@ jobs:
files: |
api/**
.github/workflows/api-security.yml
.safety-policy.yml
.github/actions/osv-scanner/**
.github/scripts/osv-scan.sh
files_ignore: |
api/docs/**
api/README.md
api/CHANGELOG.md
api/AGENTS.md
- name: Setup Python with Poetry
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-poetry
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
working-directory: ./api
update-lock: 'true'
- name: Bandit
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run bandit -q -lll -x '*_test.py,./contrib/' -r .
# Exclude .venv because uv places the project venv inside ./api; otherwise
# bandit would recurse into installed third-party packages.
run: uv run bandit -q -lll -x '*_test.py,./contrib/,./.venv/' -r .
- name: Safety
- name: Dependency vulnerability scan with osv-scanner
if: steps.check-changes.outputs.any_changed == 'true'
# Accepted CVEs, severity threshold, and ignore expirations live in ../.safety-policy.yml
run: poetry run safety check --policy-file ../.safety-policy.yml
uses: ./.github/actions/osv-scanner
with:
lockfile: api/uv.lock
- name: Vulture
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run vulture --exclude "contrib,tests,conftest.py" --min-confidence 100 .
# Run even when osv-scanner reports findings so dead-code signal isn't masked by SCA failures.
if: ${{ !cancelled() && steps.check-changes.outputs.any_changed == 'true' }}
run: uv run vulture --exclude "contrib,tests,conftest.py,.venv" --min-confidence 100 .
+4 -4
View File
@@ -87,6 +87,7 @@ jobs:
files.pythonhosted.org:443
cli.codecov.io:443
keybase.io:443
raw.githubusercontent.com:443
ingest.codecov.io:443
storage.googleapis.com:443
o26192.ingest.us.sentry.io:443
@@ -112,17 +113,16 @@ jobs:
api/CHANGELOG.md
api/AGENTS.md
- name: Setup Python with Poetry
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-poetry
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
working-directory: ./api
update-lock: 'true'
- name: Run tests with pytest
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run pytest --cov=./src/backend --cov-report=xml src/backend
run: uv run pytest --cov=./src/backend --cov-report=xml src/backend
- name: Upload coverage reports to Codecov
if: steps.check-changes.outputs.any_changed == 'true'
+3 -3
View File
@@ -59,7 +59,7 @@ jobs:
ui/**
prowler/**
mcp_server/**
poetry.lock
uv.lock
pyproject.toml
- name: Check for folder changes and changelog presence
@@ -84,9 +84,9 @@ jobs:
fi
done
# Check root-level dependency files (poetry.lock, pyproject.toml)
# Check root-level dependency files (uv.lock, pyproject.toml)
# These are associated with the prowler folder changelog
root_deps_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep -E "^(poetry\.lock|pyproject\.toml)$" || true)
root_deps_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep -E "^(uv\.lock|pyproject\.toml)$" || true)
if [ -n "$root_deps_changed" ]; then
echo "Detected changes in root dependency files: $root_deps_changed"
# Check if prowler/CHANGELOG.md was already updated (might have been caught above)
+8 -8
View File
@@ -40,12 +40,11 @@ jobs:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
persist-credentials: false
- name: Setup Python with Poetry
uses: ./.github/actions/setup-python-poetry
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: '3.12'
install-dependencies: 'false'
enable-cache: 'false'
- name: Configure Git
run: |
@@ -339,10 +338,11 @@ jobs:
exit 1
fi
# Update poetry lock file
echo "Updating poetry.lock file..."
# Update uv lock file
echo "Updating uv.lock file..."
pip install --no-cache-dir uv==0.11.14
cd api
poetry lock
uv lock
cd ..
echo "✓ Prepared prowler dependency update to: $UPDATED_PROWLER_REF"
@@ -357,7 +357,7 @@ jobs:
base: ${{ env.BRANCH_NAME }}
add-paths: |
api/pyproject.toml
api/poetry.lock
api/uv.lock
title: "chore(api): Update prowler dependency to ${{ env.BRANCH_NAME }} for release ${{ env.PROWLER_VERSION }}"
body: |
### Description
@@ -366,7 +366,7 @@ jobs:
**Changes:**
- Updates `api/pyproject.toml` prowler dependency from `@master` to `@${{ env.BRANCH_NAME }}`
- Updates `api/poetry.lock` file with resolved dependencies
- Updates `api/uv.lock` file with resolved dependencies
This PR should be merged into the `${{ env.BRANCH_NAME }}` release branch.
+9 -8
View File
@@ -71,25 +71,26 @@ jobs:
contrib/**
**/AGENTS.md
- name: Setup Python with Poetry
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-poetry
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
extras: all
- name: Check Poetry lock file
- name: Check uv lock file
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry check --lock
run: uv lock --check
- name: Lint with flake8
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude contrib,ui,api,skills
run: uv run flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude .venv,contrib,ui,api,skills,mcp_server
- name: Check format with black
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run black --exclude "api|ui|skills" --check .
# mcp_server has its own pyproject and uses ruff format, exclude it so SDK black
# does not fight ruff over rules it never formatted.
run: uv run black --exclude "\.venv|api|ui|skills|mcp_server" --check .
- name: Lint with pylint
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run pylint --disable=W,C,R,E -j 0 -rn -sn prowler/
run: uv run pylint --disable=W,C,R,E -j 0 -rn -sn prowler/
+1 -11
View File
@@ -73,20 +73,10 @@ jobs:
with:
persist-credentials: false
- name: Setup Python with Poetry
uses: ./.github/actions/setup-python-poetry
with:
python-version: ${{ env.PYTHON_VERSION }}
install-dependencies: 'false'
enable-cache: 'false'
- name: Inject poetry-bumpversion plugin
run: pipx inject poetry poetry-bumpversion
- name: Get Prowler version and set tags
id: get-prowler-version
run: |
PROWLER_VERSION="$(poetry version -s 2>/dev/null)"
PROWLER_VERSION="$(grep -E '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' | tr -d '[:space:]')"
echo "prowler_version=${PROWLER_VERSION}" >> "${GITHUB_OUTPUT}"
PROWLER_VERSION_MAJOR="${PROWLER_VERSION%%.*}"
+2 -2
View File
@@ -9,7 +9,7 @@ on:
- 'prowler/**'
- 'Dockerfile*'
- 'pyproject.toml'
- 'poetry.lock'
- 'uv.lock'
- '.github/workflows/sdk-container-checks.yml'
pull_request:
branches:
@@ -19,7 +19,7 @@ on:
- 'prowler/**'
- 'Dockerfile*'
- 'pyproject.toml'
- 'poetry.lock'
- 'uv.lock'
- '.github/workflows/sdk-container-checks.yml'
concurrency:
+6 -8
View File
@@ -75,15 +75,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Python with Poetry
uses: ./.github/actions/setup-python-poetry
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ env.PYTHON_VERSION }}
install-dependencies: 'false'
enable-cache: 'false'
- name: Build Prowler package
run: poetry build
run: uv build
- name: Publish Prowler package to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
@@ -112,12 +111,11 @@ jobs:
with:
persist-credentials: false
- name: Setup Python with Poetry
uses: ./.github/actions/setup-python-poetry
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ env.PYTHON_VERSION }}
install-dependencies: 'false'
enable-cache: 'false'
- name: Install toml package
run: pip install toml
@@ -128,7 +126,7 @@ jobs:
python util/replicate_pypi_package.py
- name: Build prowler-cloud package
run: poetry build
run: uv build
- name: Publish prowler-cloud package to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
+24 -16
View File
@@ -9,10 +9,12 @@ on:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- 'uv.lock'
- '.github/workflows/sdk-tests.yml'
- '.github/workflows/sdk-security.yml'
- '.github/actions/setup-python-poetry/**'
- '.github/actions/setup-python-uv/**'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
pull_request:
branches:
- 'master'
@@ -21,10 +23,12 @@ on:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'poetry.lock'
- 'uv.lock'
- '.github/workflows/sdk-tests.yml'
- '.github/workflows/sdk-security.yml'
- '.github/actions/setup-python-poetry/**'
- '.github/actions/setup-python-uv/**'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -39,6 +43,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: read
pull-requests: write # osv-scanner action posts/updates a PR comment with findings
steps:
- name: Harden Runner
@@ -49,10 +54,12 @@ jobs:
pypi.org:443
files.pythonhosted.org:443
github.com:443
auth.safetycli.com:443
pyup.io:443
data.safetycli.com:443
api.github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
api.osv.dev:443
api.deps.dev:443
osv-vulnerabilities.storage.googleapis.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -87,22 +94,23 @@ jobs:
contrib/**
**/AGENTS.md
- name: Setup Python with Poetry
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-poetry
uses: ./.github/actions/setup-python-uv
with:
python-version: '3.12'
extras: all
- name: Security scan with Bandit
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run bandit -q -lll -x '*_test.py,./contrib/,./api/,./ui' -r .
run: uv run bandit -q -lll -x '*_test.py,./.venv/,./contrib/,./api/,./ui' -r .
- name: Security scan with Safety
- name: Dependency vulnerability scan with osv-scanner
if: steps.check-changes.outputs.any_changed == 'true'
# 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
uses: ./.github/actions/osv-scanner
with:
lockfile: uv.lock
- name: Dead code detection with Vulture
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run vulture --exclude "contrib,api,ui" --min-confidence 100 .
# Run even when osv-scanner reports findings so dead-code signal isn't masked by SCA failures.
if: ${{ !cancelled() && steps.check-changes.outputs.any_changed == 'true' }}
run: uv run vulture --exclude ".venv,contrib,api,ui" --min-confidence 100 .
+35 -36
View File
@@ -92,12 +92,11 @@ jobs:
contrib/**
**/AGENTS.md
- name: Setup Python with Poetry
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-poetry
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
extras: all
# AWS Provider
- name: Check if AWS files changed
@@ -108,7 +107,7 @@ jobs:
files: |
./prowler/**/aws/**
./tests/**/aws/**
./poetry.lock
./uv.lock
- name: Resolve AWS services under test
if: steps.changed-aws.outputs.any_changed == 'true'
@@ -210,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 -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
uv 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 -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
uv 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 }}
@@ -238,11 +237,11 @@ jobs:
files: |
./prowler/**/azure/**
./tests/**/azure/**
./poetry.lock
./uv.lock
- name: Run Azure tests
if: steps.changed-azure.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/azure --cov-report=xml:azure_coverage.xml tests/providers/azure
run: uv run pytest -n auto --cov=./prowler/providers/azure --cov-report=xml:azure_coverage.xml tests/providers/azure
- name: Upload Azure coverage to Codecov
if: steps.changed-azure.outputs.any_changed == 'true'
@@ -262,11 +261,11 @@ jobs:
files: |
./prowler/**/gcp/**
./tests/**/gcp/**
./poetry.lock
./uv.lock
- name: Run GCP tests
if: steps.changed-gcp.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/gcp --cov-report=xml:gcp_coverage.xml tests/providers/gcp
run: uv run pytest -n auto --cov=./prowler/providers/gcp --cov-report=xml:gcp_coverage.xml tests/providers/gcp
- name: Upload GCP coverage to Codecov
if: steps.changed-gcp.outputs.any_changed == 'true'
@@ -286,11 +285,11 @@ jobs:
files: |
./prowler/**/kubernetes/**
./tests/**/kubernetes/**
./poetry.lock
./uv.lock
- name: Run Kubernetes tests
if: steps.changed-kubernetes.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/kubernetes --cov-report=xml:kubernetes_coverage.xml tests/providers/kubernetes
run: uv run pytest -n auto --cov=./prowler/providers/kubernetes --cov-report=xml:kubernetes_coverage.xml tests/providers/kubernetes
- name: Upload Kubernetes coverage to Codecov
if: steps.changed-kubernetes.outputs.any_changed == 'true'
@@ -310,11 +309,11 @@ jobs:
files: |
./prowler/**/github/**
./tests/**/github/**
./poetry.lock
./uv.lock
- name: Run GitHub tests
if: steps.changed-github.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/github --cov-report=xml:github_coverage.xml tests/providers/github
run: uv run pytest -n auto --cov=./prowler/providers/github --cov-report=xml:github_coverage.xml tests/providers/github
- name: Upload GitHub coverage to Codecov
if: steps.changed-github.outputs.any_changed == 'true'
@@ -334,11 +333,11 @@ jobs:
files: |
./prowler/**/okta/**
./tests/**/okta/**
./poetry.lock
./uv.lock
- name: Run Okta tests
if: steps.changed-okta.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/okta --cov-report=xml:okta_coverage.xml tests/providers/okta
run: uv run pytest -n auto --cov=./prowler/providers/okta --cov-report=xml:okta_coverage.xml tests/providers/okta
- name: Upload Okta coverage to Codecov
if: steps.changed-okta.outputs.any_changed == 'true'
@@ -358,11 +357,11 @@ jobs:
files: |
./prowler/**/nhn/**
./tests/**/nhn/**
./poetry.lock
./uv.lock
- name: Run NHN tests
if: steps.changed-nhn.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/nhn --cov-report=xml:nhn_coverage.xml tests/providers/nhn
run: uv run pytest -n auto --cov=./prowler/providers/nhn --cov-report=xml:nhn_coverage.xml tests/providers/nhn
- name: Upload NHN coverage to Codecov
if: steps.changed-nhn.outputs.any_changed == 'true'
@@ -382,11 +381,11 @@ jobs:
files: |
./prowler/**/m365/**
./tests/**/m365/**
./poetry.lock
./uv.lock
- name: Run M365 tests
if: steps.changed-m365.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/m365 --cov-report=xml:m365_coverage.xml tests/providers/m365
run: uv run pytest -n auto --cov=./prowler/providers/m365 --cov-report=xml:m365_coverage.xml tests/providers/m365
- name: Upload M365 coverage to Codecov
if: steps.changed-m365.outputs.any_changed == 'true'
@@ -406,11 +405,11 @@ jobs:
files: |
./prowler/**/iac/**
./tests/**/iac/**
./poetry.lock
./uv.lock
- name: Run IaC tests
if: steps.changed-iac.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/iac --cov-report=xml:iac_coverage.xml tests/providers/iac
run: uv run pytest -n auto --cov=./prowler/providers/iac --cov-report=xml:iac_coverage.xml tests/providers/iac
- name: Upload IaC coverage to Codecov
if: steps.changed-iac.outputs.any_changed == 'true'
@@ -430,11 +429,11 @@ jobs:
files: |
./prowler/**/mongodbatlas/**
./tests/**/mongodbatlas/**
./poetry.lock
./uv.lock
- name: Run MongoDB Atlas tests
if: steps.changed-mongodbatlas.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/mongodbatlas --cov-report=xml:mongodbatlas_coverage.xml tests/providers/mongodbatlas
run: uv run pytest -n auto --cov=./prowler/providers/mongodbatlas --cov-report=xml:mongodbatlas_coverage.xml tests/providers/mongodbatlas
- name: Upload MongoDB Atlas coverage to Codecov
if: steps.changed-mongodbatlas.outputs.any_changed == 'true'
@@ -454,11 +453,11 @@ jobs:
files: |
./prowler/**/oraclecloud/**
./tests/**/oraclecloud/**
./poetry.lock
./uv.lock
- name: Run OCI tests
if: steps.changed-oraclecloud.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/oraclecloud --cov-report=xml:oraclecloud_coverage.xml tests/providers/oraclecloud
run: uv run pytest -n auto --cov=./prowler/providers/oraclecloud --cov-report=xml:oraclecloud_coverage.xml tests/providers/oraclecloud
- name: Upload OCI coverage to Codecov
if: steps.changed-oraclecloud.outputs.any_changed == 'true'
@@ -478,11 +477,11 @@ jobs:
files: |
./prowler/**/openstack/**
./tests/**/openstack/**
./poetry.lock
./uv.lock
- name: Run OpenStack tests
if: steps.changed-openstack.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/openstack --cov-report=xml:openstack_coverage.xml tests/providers/openstack
run: uv run pytest -n auto --cov=./prowler/providers/openstack --cov-report=xml:openstack_coverage.xml tests/providers/openstack
- name: Upload OpenStack coverage to Codecov
if: steps.changed-openstack.outputs.any_changed == 'true'
@@ -502,11 +501,11 @@ jobs:
files: |
./prowler/**/googleworkspace/**
./tests/**/googleworkspace/**
./poetry.lock
./uv.lock
- name: Run Google Workspace tests
if: steps.changed-googleworkspace.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/googleworkspace --cov-report=xml:googleworkspace_coverage.xml tests/providers/googleworkspace
run: uv run pytest -n auto --cov=./prowler/providers/googleworkspace --cov-report=xml:googleworkspace_coverage.xml tests/providers/googleworkspace
- name: Upload Google Workspace coverage to Codecov
if: steps.changed-googleworkspace.outputs.any_changed == 'true'
@@ -526,11 +525,11 @@ jobs:
files: |
./prowler/**/vercel/**
./tests/**/vercel/**
./poetry.lock
./uv.lock
- name: Run Vercel tests
if: steps.changed-vercel.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/vercel --cov-report=xml:vercel_coverage.xml tests/providers/vercel
run: uv run pytest -n auto --cov=./prowler/providers/vercel --cov-report=xml:vercel_coverage.xml tests/providers/vercel
- name: Upload Vercel coverage to Codecov
if: steps.changed-vercel.outputs.any_changed == 'true'
@@ -550,11 +549,11 @@ jobs:
files: |
./prowler/lib/**
./tests/lib/**
./poetry.lock
./uv.lock
- name: Run Lib tests
if: steps.changed-lib.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/lib --cov-report=xml:lib_coverage.xml tests/lib
run: uv run pytest -n auto --cov=./prowler/lib --cov-report=xml:lib_coverage.xml tests/lib
- name: Upload Lib coverage to Codecov
if: steps.changed-lib.outputs.any_changed == 'true'
@@ -574,11 +573,11 @@ jobs:
files: |
./prowler/config/**
./tests/config/**
./poetry.lock
./uv.lock
- name: Run Config tests
if: steps.changed-config.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/config --cov-report=xml:config_coverage.xml tests/config
run: uv run pytest -n auto --cov=./prowler/config --cov-report=xml:config_coverage.xml tests/config
- name: Upload Config coverage to Codecov
if: steps.changed-config.outputs.any_changed == 'true'
+7 -1
View File
@@ -130,6 +130,12 @@ jobs:
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
- name: Build API image from current code
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
# which lags behind PR changes; build locally so E2E exercises the API image
# produced by this PR.
run: docker build -t prowlercloud/prowler-api:latest ./api
- name: Start API services
run: |
export PROWLER_API_VERSION=latest
@@ -158,7 +164,7 @@ jobs:
for fixture in api/fixtures/dev/*.json; do
if [ -f "$fixture" ]; then
echo "Loading $fixture"
poetry run python manage.py loaddata "$fixture" --database admin
uv run python manage.py loaddata "$fixture" --database admin
fi
done
'
+75
View File
@@ -0,0 +1,75 @@
name: 'UI: Security'
on:
push:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/package.json'
- 'ui/pnpm-lock.yaml'
- '.github/workflows/ui-security.yml'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
pull_request:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/package.json'
- 'ui/pnpm-lock.yaml'
- '.github/workflows/ui-security.yml'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
ui-security-scans:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write # osv-scanner action posts/updates a PR comment with findings
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
api.osv.dev:443
api.deps.dev:443
osv-vulnerabilities.storage.googleapis.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Check for UI dependency changes
id: check-changes
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with:
files: |
ui/package.json
ui/pnpm-lock.yaml
.github/workflows/ui-security.yml
.github/actions/osv-scanner/**
.github/scripts/osv-scan.sh
- name: Dependency vulnerability scan with osv-scanner
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/osv-scanner
with:
lockfile: ui/pnpm-lock.yaml
+4
View File
@@ -132,6 +132,10 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run healthcheck
- name: Run pnpm audit
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run audit
- name: Run unit tests (all - critical paths changed)
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed == 'true'
run: |
+11 -35
View File
@@ -107,35 +107,21 @@ repos:
files: { glob: ["{api,mcp_server}/**/*.py"] }
priority: 20
## PYTHON — Poetry
- repo: https://github.com/python-poetry/poetry
rev: 2.3.4
## PYTHON — uv (API + SDK)
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.11.14
hooks:
- id: poetry-check
name: API - poetry-check
args: ["--directory=./api"]
files: { glob: ["api/{pyproject.toml,poetry.lock}"] }
- id: uv-lock
name: API - uv-lock
args: ["--check", "--project=./api"]
files: { glob: ["api/{pyproject.toml,uv.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}"] }
- id: uv-lock
name: SDK - uv-lock
args: ["--check", "--project=./"]
files: { glob: ["{pyproject.toml,uv.lock}"] }
pass_filenames: false
priority: 50
@@ -179,16 +165,6 @@ repos:
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"
# 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."
+2 -6
View File
@@ -11,15 +11,11 @@ build:
python: "3.11"
jobs:
post_create_environment:
# Install poetry
# https://python-poetry.org/docs/#installing-manually
- python -m pip install poetry==2.3.4
- python -m pip install uv==0.11.14
post_install:
# Install dependencies with 'docs' dependency group
# https://python-poetry.org/docs/managing-dependencies/#dependency-groups
# VIRTUAL_ENV needs to be set manually for now.
# See https://github.com/readthedocs/readthedocs.org/pull/11152/
- VIRTUAL_ENV=${READTHEDOCS_VIRTUALENV_PATH} python -m poetry install --only=docs
- VIRTUAL_ENV=${READTHEDOCS_VIRTUALENV_PATH} uv sync --group docs --no-install-project
mkdocs:
configuration: mkdocs.yml
-58
View File
@@ -1,58 +0,0 @@
# 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.08.9) and CRITICAL (9.010.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'
+6 -6
View File
@@ -148,7 +148,7 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure,
| Component | Location | Tech Stack |
|-----------|----------|------------|
| SDK | `prowler/` | Python 3.10+, Poetry 2.3+ |
| SDK | `prowler/` | Python 3.10+, uv |
| API | `api/` | Django 5.1, DRF, Celery |
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
@@ -160,13 +160,13 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure,
```bash
# Setup
poetry install --with dev
poetry run prek install
uv sync
uv run prek install
# Code quality
poetry run make lint
poetry run make format
poetry run prek run --all-files
uv run make lint
uv run make format
uv run prek run --all-files
```
---
+6 -6
View File
@@ -78,7 +78,7 @@ WORKDIR /home/prowler
# Copy necessary files
COPY prowler/ /home/prowler/prowler/
COPY dashboard/ /home/prowler/dashboard/
COPY pyproject.toml /home/prowler
COPY pyproject.toml uv.lock /home/prowler/
COPY README.md /home/prowler/
COPY prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py
@@ -87,17 +87,17 @@ ENV HOME='/home/prowler'
ENV PATH="${HOME}/.local/bin:${PATH}"
#hadolint ignore=DL3013
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir poetry==2.3.4
pip install --no-cache-dir uv==0.11.14
RUN poetry install --compile --all-extras && \
rm -rf ~/.cache/pip
RUN uv sync --compile-bytecode && \
rm -rf ~/.cache/uv
# Install PowerShell modules
RUN poetry run python prowler/providers/m365/lib/powershell/m365_powershell.py
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py
# Remove deprecated dash dependencies
RUN pip uninstall dash-html-components -y && \
pip uninstall dash-core-components -y
USER prowler
ENTRYPOINT ["poetry", "run", "prowler"]
ENTRYPOINT [".venv/bin/prowler"]
+2 -3
View File
@@ -23,7 +23,7 @@ format: ## Format Code
lint: ## Lint Code
@echo "Running flake8..."
flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude contrib
flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude .venv,contrib
@echo "Running black... "
black --check .
@echo "Running pylint..."
@@ -35,7 +35,7 @@ pypi-clean: ## Delete the distribution files
pypi-build: ## Build package
$(MAKE) pypi-clean && \
poetry build
uv build
pypi-upload: ## Upload package
python3 -m twine upload --repository pypi dist/*
@@ -56,4 +56,3 @@ run-api-dev: ## Start development environment with API, PostgreSQL, Valkey, MCP,
##@ Development Environment
build-and-run-api-dev: build-no-cache-dev run-api-dev
+10 -21
View File
@@ -177,7 +177,7 @@ You can find more information in the [Troubleshooting](./docs/troubleshooting.md
**Requirements**
* `git` installed.
* `poetry` v2 installed: [poetry installation](https://python-poetry.org/docs/#installation).
* `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/).
* `pnpm` installed: [pnpm installation](https://pnpm.io/installation).
* `Docker Compose` installed: https://docs.docker.com/compose/install/.
@@ -186,8 +186,8 @@ You can find more information in the [Troubleshooting](./docs/troubleshooting.md
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler/api
poetry install
eval $(poetry env activate)
uv sync
source .venv/bin/activate
set -a
source .env
docker compose up postgres valkey -d
@@ -195,11 +195,6 @@ cd src/backend
python manage.py migrate --database admin
gunicorn -c config/guniconf.py config.wsgi:application
```
> [!IMPORTANT]
> As of Poetry v2.0.0, the `poetry shell` command has been deprecated. Use `poetry env activate` instead for environment activation.
>
> 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.
> After completing the setup, access the API documentation at http://localhost:8080/api/v1/docs.
@@ -208,8 +203,8 @@ gunicorn -c config/guniconf.py config.wsgi:application
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler/api
poetry install
eval $(poetry env activate)
uv sync
source .venv/bin/activate
set -a
source .env
cd src/backend
@@ -221,8 +216,8 @@ python -m celery -A config.celery worker -l info -E
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler/api
poetry install
eval $(poetry env activate)
uv sync
source .venv/bin/activate
set -a
source .env
cd src/backend
@@ -283,24 +278,18 @@ The container images are available here:
### From GitHub
Python >=3.10, <3.13 is required with pip and Poetry:
Python >=3.10, <3.13 is required with [uv](https://docs.astral.sh/uv/):
``` console
git clone https://github.com/prowler-cloud/prowler
cd prowler
eval $(poetry env activate)
poetry install
uv sync
source .venv/bin/activate
python prowler-cli.py -v
```
> [!IMPORTANT]
> To clone Prowler on Windows, configure Git to support long file paths by running the following command: `git config core.longpaths true`.
> [!IMPORTANT]
> As of Poetry v2.0.0, the `poetry shell` command has been deprecated. Use `poetry env activate` instead for environment activation.
>
> 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.
+1 -1
View File
@@ -167,7 +167,7 @@ runs:
- 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
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
sarif_file: ${{ steps.find-sarif.outputs.sarif_path }}
category: ${{ inputs.sarif-category }}
+8 -8
View File
@@ -124,24 +124,24 @@ api/src/backend/
```bash
# Development
poetry run python src/backend/manage.py runserver
poetry run celery -A config.celery worker -l INFO
uv run python src/backend/manage.py runserver
uv run celery -A config.celery worker -l INFO
# Database
poetry run python src/backend/manage.py makemigrations
poetry run python src/backend/manage.py migrate
uv run python src/backend/manage.py makemigrations
uv run python src/backend/manage.py migrate
# Testing & Linting
poetry run pytest -x --tb=short
poetry run make lint
uv run pytest -x --tb=short
uv run make lint
```
---
## QA CHECKLIST
- [ ] `poetry run pytest` passes
- [ ] `poetry run make lint` passes
- [ ] `uv run pytest` passes
- [ ] `uv run make lint` passes
- [ ] Migrations created if models changed
- [ ] New endpoints have `@extend_schema` decorators
- [ ] RLS properly applied for tenant data
+6
View File
@@ -10,7 +10,13 @@ All notable changes to the **Prowler API** are documented in this file.
### 🔄 Changed
- Replace `poetry` with `uv` (`0.11.14`) as the API package manager; migrate `pyproject.toml` to `[dependency-groups]` and regenerate as `uv.lock` [(#10775)](https://github.com/prowler-cloud/prowler/pull/10775)
- Remove orphaned `gin_resources_search_idx` declaration from `Resource.Meta.indexes` (DB index dropped in `0072_drop_unused_indexes`) [(#11001)](https://github.com/prowler-cloud/prowler/pull/11001)
- PDF compliance reports cap detail tables at 100 failed findings per check (configurable via `DJANGO_PDF_MAX_FINDINGS_PER_CHECK`) to bound worker memory on large scans [(#11160)](https://github.com/prowler-cloud/prowler/pull/11160)
### 🐞 Fixed
- `perform_scan_task` and `perform_scheduled_scan_task` now short-circuit with a warning and `return None` when the target provider no longer exists, instead of letting `handle_provider_deletion` raise `ProviderDeletedException`. `perform_scheduled_scan_task` also removes any orphan `PeriodicTask` it finds so beat stops re-firing scans for deleted providers. Prevents queued messages for deleted providers from being recorded as `FAILURE` and, in one-shot scan-worker deployments, from burning a fresh container per redelivery [(#11185)](https://github.com/prowler-cloud/prowler/pull/11185)
---
+7 -6
View File
@@ -14,6 +14,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
git \
libicu72 \
gcc \
g++ \
@@ -88,18 +89,18 @@ WORKDIR /home/prowler
# Ensure output directory exists
RUN mkdir -p /tmp/prowler_api_output
COPY pyproject.toml ./
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir poetry==2.3.4
pip install --no-cache-dir uv==0.11.14
ENV PATH="/home/prowler/.local/bin:$PATH"
# Add `--no-root` to avoid installing the current project as a package
RUN poetry install --no-root && \
rm -rf ~/.cache/pip
# Add `--no-install-project` to avoid installing the current project as a package
RUN uv sync --no-install-project && \
rm -rf ~/.cache/uv
RUN poetry run python "$(poetry env info --path)/src/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py"
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
COPY src/backend/ ./backend/
COPY docker-entrypoint.sh ./docker-entrypoint.sh
+12 -19
View File
@@ -25,12 +25,11 @@ If you dont set `DJANGO_TOKEN_SIGNING_KEY` or `DJANGO_TOKEN_VERIFYING_KEY`, t
**Important note**: Every Prowler version (or repository branches and tags) could have different variables set in its `.env` file. Please use the `.env` file that corresponds with each version.
## Local deployment
Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the Poetry interpreter, not before. Otherwise, variables will not be loaded properly.
Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the virtual environment, not before. Otherwise, variables will not be loaded properly.
To do this, you can run:
```console
poetry shell
set -a
source .env
```
@@ -78,7 +77,7 @@ docker logs -f $(docker ps --format "{{.Names}}" | grep 'api-')
## Local deployment
To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `poetry` and `docker compose` are installed.
To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `uv` and `docker compose` are installed.
### Clone the repository
@@ -90,11 +89,10 @@ git clone https://github.com/prowler-cloud/api.git
git clone git@github.com:prowler-cloud/api.git
```
### Install all dependencies with Poetry
### Install all dependencies with uv
```console
poetry install
poetry shell
uv sync
```
## Start the PostgreSQL Database and Valkey
@@ -139,7 +137,7 @@ gunicorn -c config/guniconf.py config.wsgi:application
## Local deployment
To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `poetry` and `docker compose` are installed.
To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `uv` and `docker compose` are installed.
### Clone the repository
@@ -165,11 +163,10 @@ docker compose up postgres valkey -d
### Install the Python dependencies
> You must have Poetry installed
> You must have uv installed
```console
poetry install
poetry shell
uv sync
```
### Apply migrations
@@ -246,9 +243,8 @@ docker logs -f $(docker ps --format "{{.Names}}" | grep 'api-')
For migrations, you need to force the `admin` database router. Assuming you have the correct environment variables and Python virtual environment, run:
```console
poetry shell
cd src/backend
python manage.py migrate --database admin
uv run python manage.py migrate --database admin
```
## Apply fixtures
@@ -256,9 +252,8 @@ python manage.py migrate --database admin
Fixtures are used to populate the database with initial development data.
```console
poetry shell
cd src/backend
python manage.py loaddata api/fixtures/0_dev_users.json --database admin
uv run python manage.py loaddata api/fixtures/0_dev_users.json --database admin
```
> The default credentials are `dev@prowler.com:Thisisapassword123@` or `dev2@prowler.com:Thisisapassword123@`
@@ -270,9 +265,8 @@ Note that the tests will fail if you use the same `.env` file as the development
For best results, run in a new shell with no environment variables set.
```console
poetry shell
cd src/backend
pytest
uv run pytest
```
# Custom commands
@@ -284,8 +278,7 @@ Django provides a way to create custom commands that can be run from the command
To run a custom command, you need to be in the `prowler/api/src/backend` directory and run:
```console
poetry shell
python manage.py <command_name>
uv run python manage.py <command_name>
```
## Generate dummy data
@@ -308,7 +301,7 @@ This command creates, for a given tenant, a provider, scan and a set of findings
### Example
```console
~/backend $ poetry run python manage.py findings --tenant
~/backend $ uv run python manage.py findings --tenant
fffb1893-3fc7-4623-a5d9-fae47da1c528 --findings 25000 --re
sources 1000 --batch 5000 --alias test-script
+8 -8
View File
@@ -5,9 +5,9 @@ apply_migrations() {
echo "Applying database migrations..."
# Fix Inconsistent migration history after adding sites app
poetry run python manage.py check_and_fix_socialaccount_sites_migration --database admin
uv run python manage.py check_and_fix_socialaccount_sites_migration --database admin
poetry run python manage.py migrate --database admin
uv run python manage.py migrate --database admin
}
apply_fixtures() {
@@ -15,19 +15,19 @@ apply_fixtures() {
for fixture in api/fixtures/dev/*.json; do
if [ -f "$fixture" ]; then
echo "Loading $fixture"
poetry run python manage.py loaddata "$fixture" --database admin
uv run python manage.py loaddata "$fixture" --database admin
fi
done
}
start_dev_server() {
echo "Starting the development server..."
poetry run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}"
uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}"
}
start_prod_server() {
echo "Starting the Gunicorn server..."
poetry run gunicorn -c config/guniconf.py config.wsgi:application
uv run gunicorn -c config/guniconf.py config.wsgi:application
}
resolve_worker_hostname() {
@@ -47,7 +47,7 @@ resolve_worker_hostname() {
start_worker() {
echo "Starting the worker..."
poetry run python -m celery -A config.celery worker \
uv run python -m celery -A config.celery worker \
-n "$(resolve_worker_hostname)" \
-l "${DJANGO_LOGGING_LEVEL:-info}" \
-Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \
@@ -56,7 +56,7 @@ start_worker() {
start_worker_beat() {
echo "Starting the worker-beat..."
poetry run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
}
manage_db_partitions() {
@@ -64,7 +64,7 @@ manage_db_partitions() {
echo "Managing DB partitions..."
# For now we skip the deletion of partitions until we define the data retention policy
# --yes auto approves the operation without the need of an interactive terminal
poetry run python manage.py pgpartition --using admin --skip-delete --yes
uv run python manage.py pgpartition --using admin --skip-delete --yes
fi
}
-9427
View File
File diff suppressed because it is too large Load Diff
+400 -28
View File
@@ -1,6 +1,24 @@
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core"]
[dependency-groups]
dev = [
"bandit==1.7.9",
"coverage==7.5.4",
"django-silk==5.3.2",
"docker==7.1.0",
"filelock==3.20.3",
"freezegun==1.5.1",
"mypy==1.10.1",
"pylint==3.2.5",
"pytest==9.0.3",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest-env==1.1.3",
"pytest-randomly==3.15.0",
"pytest-xdist==3.6.1",
"ruff==0.5.0",
"tqdm==4.67.1",
"vulture==2.14",
"prek==0.3.9"
]
[project]
authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
@@ -24,14 +42,14 @@ dependencies = [
"drf-spectacular-jsonapi==0.5.1",
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
"uuid6==2024.7.10",
"openai (==1.109.1)",
"xmlsec==1.3.14",
"xmlsec==1.3.17",
"h2 (==4.3.0)",
"markdown (==3.10.2)",
"drf-simple-apikey (==2.2.1)",
@@ -52,26 +70,380 @@ package-mode = false
requires-python = ">=3.11,<3.13"
version = "1.28.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
[tool.poetry.group.dev.dependencies]
bandit = "1.7.9"
coverage = "7.5.4"
django-silk = "5.3.2"
docker = "7.1.0"
filelock = "3.20.3"
freezegun = "1.5.1"
mypy = "1.10.1"
prek = "0.3.9"
pylint = "3.2.5"
pytest = "9.0.3"
pytest-cov = "5.0.0"
pytest-django = "4.8.0"
pytest-env = "1.1.3"
pytest-randomly = "3.15.0"
pytest-xdist = "3.6.1"
ruff = "0.5.0"
safety = "3.7.0"
tqdm = "4.67.1"
vulture = "2.14"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
constraint-dependencies = [
"about-time==4.2.1",
"adal==1.2.7",
"aioboto3==15.5.0",
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.13.5",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
"alibabacloud-credentials==1.0.3",
"alibabacloud-credentials-api==1.0.0",
"alibabacloud-cs20151215==6.1.0",
"alibabacloud-darabonba-array==0.1.0",
"alibabacloud-darabonba-encode-util==0.0.2",
"alibabacloud-darabonba-map==0.0.1",
"alibabacloud-darabonba-signature-util==0.0.4",
"alibabacloud-darabonba-string==0.0.4",
"alibabacloud-darabonba-time==0.0.1",
"alibabacloud-ecs20140526==7.2.5",
"alibabacloud-endpoint-util==0.0.4",
"alibabacloud-gateway-oss==0.0.17",
"alibabacloud-gateway-oss-util==0.0.3",
"alibabacloud-gateway-sls==0.4.0",
"alibabacloud-gateway-sls-util==0.4.0",
"alibabacloud-gateway-spi==0.0.3",
"alibabacloud-openapi-util==0.2.4",
"alibabacloud-oss-util==0.0.6",
"alibabacloud-oss20190517==1.0.6",
"alibabacloud-ram20150501==1.2.0",
"alibabacloud-rds20140815==12.0.0",
"alibabacloud-sas20181203==6.1.0",
"alibabacloud-sls20201230==5.9.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.4",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
"alive-progress==3.3.0",
"aliyun-log-fastpb==0.2.0",
"amqp==5.3.1",
"annotated-types==0.7.0",
"anyio==4.12.1",
"applicationinsights==0.11.10",
"apscheduler==3.11.2",
"argcomplete==3.5.3",
"asgiref==3.11.0",
"astroid==3.2.4",
"async-timeout==5.0.1",
"attrs==25.4.0",
"authlib==1.6.9",
"autopep8==2.3.2",
"awsipranges==0.3.3",
"azure-cli-core==2.83.0",
"azure-cli-telemetry==1.1.0",
"azure-common==1.1.28",
"azure-core==1.38.1",
"azure-identity==1.21.0",
"azure-keyvault-certificates==4.10.0",
"azure-keyvault-keys==4.10.0",
"azure-keyvault-secrets==4.10.0",
"azure-mgmt-apimanagement==5.0.0",
"azure-mgmt-applicationinsights==4.1.0",
"azure-mgmt-authorization==4.0.0",
"azure-mgmt-compute==34.0.0",
"azure-mgmt-containerinstance==10.1.0",
"azure-mgmt-containerregistry==12.0.0",
"azure-mgmt-containerservice==34.1.0",
"azure-mgmt-core==1.6.0",
"azure-mgmt-cosmosdb==9.7.0",
"azure-mgmt-databricks==2.0.0",
"azure-mgmt-datafactory==9.2.0",
"azure-mgmt-eventgrid==10.4.0",
"azure-mgmt-eventhub==11.2.0",
"azure-mgmt-keyvault==10.3.1",
"azure-mgmt-loganalytics==12.0.0",
"azure-mgmt-logic==10.0.0",
"azure-mgmt-monitor==6.0.2",
"azure-mgmt-network==28.1.0",
"azure-mgmt-postgresqlflexibleservers==1.1.0",
"azure-mgmt-rdbms==10.1.0",
"azure-mgmt-recoveryservices==3.1.0",
"azure-mgmt-recoveryservicesbackup==9.2.0",
"azure-mgmt-resource==24.0.0",
"azure-mgmt-search==9.1.0",
"azure-mgmt-security==7.0.0",
"azure-mgmt-sql==3.0.1",
"azure-mgmt-storage==22.1.1",
"azure-mgmt-subscription==3.1.1",
"azure-mgmt-synapse==2.0.0",
"azure-mgmt-web==8.0.0",
"azure-monitor-query==2.0.0",
"azure-storage-blob==12.24.1",
"azure-synapse-artifacts==0.21.0",
"backoff==2.2.1",
"bandit==1.7.9",
"billiard==4.2.4",
"blinker==1.9.0",
"boto3==1.40.61",
"botocore==1.40.61",
"cartography==0.135.0",
"celery==5.6.2",
"certifi==2026.1.4",
"cffi==2.0.0",
"charset-normalizer==3.4.4",
"circuitbreaker==2.1.3",
"click==8.3.1",
"click-didyoumean==0.3.1",
"click-plugins==1.1.1.2",
"click-repl==0.3.0",
"cloudflare==4.3.1",
"colorama==0.4.6",
"contextlib2==21.6.0",
"contourpy==1.3.3",
"coverage==7.5.4",
"cron-descriptor==1.4.5",
"crowdstrike-falconpy==1.6.0",
"cryptography==46.0.7",
"cycler==0.12.1",
"darabonba-core==1.0.5",
"dash==3.1.1",
"dash-bootstrap-components==2.0.3",
"debugpy==1.8.20",
"decorator==5.2.1",
"defusedxml==0.7.1",
"detect-secrets==1.5.0",
"dill==0.4.1",
"distro==1.9.0",
"dj-rest-auth==7.0.1",
"django==5.1.15",
"django-allauth==65.15.0",
"django-celery-beat==2.9.0",
"django-celery-results==2.6.0",
"django-cors-headers==4.4.0",
"django-environ==0.11.2",
"django-filter==24.3",
"django-guid==3.5.0",
"django-postgres-extra==2.0.9",
"django-silk==5.3.2",
"django-timezone-field==7.2.1",
"djangorestframework==3.15.2",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt==5.5.1",
"dnspython==2.8.0",
"docker==7.1.0",
"dogpile-cache==1.5.0",
"dparse==0.6.4",
"drf-extensions==0.8.0",
"drf-nested-routers==0.95.0",
"drf-simple-apikey==2.2.1",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"dulwich==0.23.0",
"duo-client==5.5.0",
"durationpy==0.10",
"email-validator==2.2.0",
"execnet==2.1.2",
"filelock==3.20.3",
"flask==3.1.3",
"fonttools==4.62.1",
"freezegun==1.5.1",
"frozenlist==1.8.0",
"gevent==25.9.1",
"google-api-core==2.29.0",
"google-api-python-client==2.163.0",
"google-auth==2.48.0",
"google-auth-httplib2==0.2.0",
"google-cloud-access-context-manager==0.3.0",
"google-cloud-asset==4.2.0",
"google-cloud-org-policy==1.16.0",
"google-cloud-os-config==1.23.0",
"google-cloud-resource-manager==1.16.0",
"googleapis-common-protos==1.72.0",
"gprof2dot==2025.4.14",
"graphemeu==0.7.2",
"greenlet==3.3.1",
"grpc-google-iam-v1==0.14.3",
"grpcio==1.76.0",
"grpcio-status==1.76.0",
"gunicorn==23.0.0",
"h11==0.16.0",
"h2==4.3.0",
"hpack==4.1.0",
"httpcore==1.0.9",
"httplib2==0.31.2",
"httpx==0.28.1",
"humanfriendly==10.0",
"hyperframe==6.1.0",
"iamdata==0.1.202602021",
"idna==3.11",
"importlib-metadata==8.7.1",
"inflection==0.5.1",
"iniconfig==2.3.0",
"iso8601==2.1.0",
"isodate==0.7.2",
"isort==5.13.2",
"itsdangerous==2.2.0",
"jinja2==3.1.6",
"jiter==0.13.0",
"jmespath==1.1.0",
"joblib==1.5.3",
"jsonpatch==1.33",
"jsonpickle==4.1.1",
"jsonpointer==3.0.0",
"jsonschema==4.23.0",
"jsonschema-specifications==2025.9.1",
"keystoneauth1==5.13.0",
"kiwisolver==1.4.9",
"knack==0.11.0",
"kombu==5.6.2",
"kubernetes==32.0.1",
"lxml==6.1.0",
"lz4==4.4.5",
"markdown==3.10.2",
"markdown-it-py==4.0.0",
"markupsafe==3.0.3",
"marshmallow==4.3.0",
"matplotlib==3.10.8",
"mccabe==0.7.0",
"mdurl==0.1.2",
"microsoft-kiota-abstractions==1.9.9",
"microsoft-kiota-authentication-azure==1.9.9",
"microsoft-kiota-http==1.9.9",
"microsoft-kiota-serialization-form==1.9.9",
"microsoft-kiota-serialization-json==1.9.9",
"microsoft-kiota-serialization-multipart==1.9.9",
"microsoft-kiota-serialization-text==1.9.9",
"microsoft-security-utilities-secret-masker==1.0.0b4",
"msal==1.35.0b1",
"msal-extensions==1.2.0",
"msgraph-core==1.3.8",
"msgraph-sdk==1.55.0",
"msrest==0.7.1",
"msrestazure==0.6.4.post1",
"multidict==6.7.1",
"mypy==1.10.1",
"mypy-extensions==1.1.0",
"narwhals==2.16.0",
"neo4j==6.1.0",
"nest-asyncio==1.6.0",
"nltk==3.9.4",
"numpy==2.0.2",
"oauthlib==3.3.1",
"oci==2.169.0",
"openai==1.109.1",
"openstacksdk==4.2.0",
"opentelemetry-api==1.39.1",
"opentelemetry-sdk==1.39.1",
"opentelemetry-semantic-conventions==0.60b1",
"os-service-types==1.8.2",
"packageurl-python==0.17.6",
"packaging==26.0",
"pagerduty==6.1.0",
"pandas==2.2.3",
"pbr==7.0.3",
"pillow==12.2.0",
"pkginfo==1.12.1.2",
"platformdirs==4.5.1",
"plotly==6.5.2",
"pluggy==1.6.0",
"policyuniverse==1.5.1.20231109",
"portalocker==2.10.1",
"prek==0.3.9",
"prompt-toolkit==3.0.52",
"propcache==0.4.1",
"proto-plus==1.27.0",
"protobuf==6.33.5",
"psutil==7.2.2",
"psycopg2-binary==2.9.9",
"py-deviceid==0.1.1",
"py-iam-expand==0.1.0",
"py-ocsf-models==0.8.1",
"pyasn1==0.6.3",
"pyasn1-modules==0.4.2",
"pycodestyle==2.14.0",
"pycparser==3.0",
"pydantic==2.12.5",
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
"pyjwt==2.12.1",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
"pyopenssl==26.0.0",
"pyparsing==3.3.2",
"pyreadline3==3.5.4",
"pysocks==1.7.1",
"pytest==9.0.3",
"pytest-celery==1.3.0",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest-docker-tools==3.1.9",
"pytest-env==1.1.3",
"pytest-randomly==3.15.0",
"pytest-xdist==3.6.1",
"python-crontab==3.3.0",
"python-dateutil==2.9.0.post0",
"python-digitalocean==1.17.0",
"python3-saml==1.16.0",
"pytz==2025.1",
"pywin32==311",
"pyyaml==6.0.3",
"redis==7.1.0",
"referencing==0.37.0",
"regex==2026.1.15",
"reportlab==4.4.10",
"requests==2.33.1",
"requests-file==3.0.1",
"requests-oauthlib==2.0.0",
"requestsexceptions==1.4.0",
"retrying==1.4.2",
"rich==14.3.2",
"rpds-py==0.30.0",
"rsa==4.9.1",
"ruamel-yaml==0.19.1",
"ruff==0.5.0",
"s3transfer==0.14.0",
"scaleway==2.10.3",
"scaleway-core==2.10.3",
"schema==0.7.5",
"sentry-sdk==2.56.0",
"setuptools==80.10.2",
"shellingham==1.5.4",
"shodan==1.31.0",
"six==1.17.0",
"slack-sdk==3.39.0",
"sniffio==1.3.1",
"sqlparse==0.5.5",
"statsd==4.0.1",
"std-uritemplate==2.0.8",
"stevedore==5.6.0",
"tabulate==0.9.0",
"tenacity==9.1.2",
"tldextract==5.3.1",
"tomlkit==0.14.0",
"tqdm==4.67.1",
"typer==0.21.1",
"types-aiobotocore-ecr==3.1.1",
"typing-extensions==4.15.0",
"typing-inspection==0.4.2",
"tzdata==2025.3",
"tzlocal==5.3.1",
"uritemplate==4.2.0",
"urllib3==2.7.0",
"uuid6==2024.7.10",
"vine==5.1.0",
"vulture==2.14",
"wcwidth==0.5.3",
"websocket-client==1.9.0",
"werkzeug==3.1.7",
"workos==6.0.4",
"wrapt==1.17.3",
"xlsxwriter==3.2.9",
"xmlsec==1.3.17",
"xmltodict==1.0.2",
"yarl==1.22.0",
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
"zstd==1.5.7.3"
]
# prowler@master needs okta==3.4.2; cartography 0.135.0 declares okta<1.0.0 for an
# integration prowler does not import.
#
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
override-dependencies = [
"okta==3.4.2",
"microsoft-kiota-abstractions==1.9.9"
]
+145 -10
View File
@@ -20,11 +20,15 @@ from tasks.jobs.reports import (
ThreatScoreReportGenerator,
)
from tasks.jobs.threatscore import compute_threatscore_metrics
from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_database
from tasks.jobs.threatscore_utils import (
_aggregate_requirement_statistics_from_database,
_get_compliance_check_ids,
)
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import rls_transaction
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from api.utils import initialize_prowler_provider
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.finding import Finding as FindingOutput
@@ -427,6 +431,7 @@ def generate_threatscore_report(
provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
prowler_provider=None,
) -> None:
"""
Generate a PDF compliance report based on Prowler ThreatScore framework.
@@ -455,6 +460,7 @@ def generate_threatscore_report(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
only_failed=only_failed,
)
@@ -469,6 +475,7 @@ def generate_ens_report(
provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
prowler_provider=None,
) -> None:
"""
Generate a PDF compliance report for ENS RD2022 framework.
@@ -495,6 +502,7 @@ def generate_ens_report(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
include_manual=include_manual,
)
@@ -510,6 +518,7 @@ def generate_nis2_report(
provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
prowler_provider=None,
) -> None:
"""
Generate a PDF compliance report for NIS2 Directive (EU) 2022/2555.
@@ -537,6 +546,7 @@ def generate_nis2_report(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
only_failed=only_failed,
include_manual=include_manual,
)
@@ -553,6 +563,7 @@ def generate_csa_report(
provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
prowler_provider=None,
) -> None:
"""
Generate a PDF compliance report for CSA Cloud Controls Matrix (CCM) v4.0.
@@ -580,6 +591,7 @@ def generate_csa_report(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
only_failed=only_failed,
include_manual=include_manual,
)
@@ -596,6 +608,7 @@ def generate_cis_report(
provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
prowler_provider=None,
) -> None:
"""
Generate a PDF compliance report for a specific CIS Benchmark variant.
@@ -627,6 +640,7 @@ def generate_cis_report(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
only_failed=only_failed,
include_manual=include_manual,
)
@@ -771,6 +785,17 @@ def generate_compliance_reports(
results["csa"] = {"upload": False, "path": ""}
generate_csa = False
# Load the framework definitions for this provider once. We use this map
# both to pick the latest CIS variant and to precompute the set of
# check_ids each framework consumes (for findings_cache eviction).
frameworks_bulk: dict = {}
try:
frameworks_bulk = Compliance.get_bulk(provider_type)
except Exception as e:
logger.error("Error loading compliance frameworks for %s: %s", provider_type, e)
# Fall through; individual frameworks will still try and fail
# gracefully if their compliance_id is missing.
# 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
@@ -778,7 +803,6 @@ def generate_compliance_reports(
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_")
)
@@ -815,10 +839,84 @@ def generate_compliance_reports(
tenant_id, scan_id
)
# Create shared findings cache
findings_cache = {}
# Initialize the Prowler provider once for the whole report batch. Each
# generator used to re-init this in _load_compliance_data, paying the
# boto3/Azure-SDK construction cost 5 times per scan. The instance is
# only used by FindingOutput.transform_api_finding to enrich findings,
# so a single shared instance is correct.
logger.info("Initializing prowler_provider once for all reports (scan %s)", scan_id)
try:
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
prowler_provider = initialize_prowler_provider(provider_obj)
except Exception as init_error:
# If init fails the generators will fall back to lazy init in
# _load_compliance_data; we just log and continue.
logger.warning(
"Could not pre-initialize prowler_provider for scan %s: %s",
scan_id,
init_error,
)
prowler_provider = None
# Create shared findings cache up front so the eviction closure below
# can reference it. Defined BEFORE the closure to avoid the UnboundLocalError
# trap if an early-return is later inserted between the closure and its
# first use.
findings_cache: dict[str, list[FindingOutput]] = {}
logger.info("Created shared findings cache for all reports")
# Precompute the set of check_ids each framework consumes. After a
# framework finishes, every check_id that no remaining framework still
# needs is evicted from findings_cache so the dict does not keep
# growing through the batch (PROWLER-1733).
pending_checks_by_framework: dict[str, set[str]] = {}
if generate_threatscore:
pending_checks_by_framework["threatscore"] = _get_compliance_check_ids(
frameworks_bulk.get(f"prowler_threatscore_{provider_type}")
)
if generate_ens:
pending_checks_by_framework["ens"] = _get_compliance_check_ids(
frameworks_bulk.get(f"ens_rd2022_{provider_type}")
)
if generate_nis2:
pending_checks_by_framework["nis2"] = _get_compliance_check_ids(
frameworks_bulk.get(f"nis2_{provider_type}")
)
if generate_csa:
pending_checks_by_framework["csa"] = _get_compliance_check_ids(
frameworks_bulk.get(f"csa_ccm_4.0_{provider_type}")
)
if generate_cis and latest_cis:
pending_checks_by_framework["cis"] = _get_compliance_check_ids(
frameworks_bulk.get(latest_cis)
)
def _evict_after_framework(done_key: str) -> int:
"""Drop from findings_cache every check_id no pending framework still needs."""
done = pending_checks_by_framework.pop(done_key, set())
still_needed: set[str] = (
set().union(*pending_checks_by_framework.values())
if pending_checks_by_framework
else set()
)
exclusive = done - still_needed
evicted = 0
for cid in exclusive:
if findings_cache.pop(cid, None) is not None:
evicted += 1
if evicted:
logger.info(
"Evicted %d exclusive check entries from findings_cache after %s "
"(remaining cache size: %d)",
evicted,
done_key,
len(findings_cache),
)
# Release the lists' memory now instead of waiting for the next
# gc cycle; FindingOutput instances retain quite a bit of state.
gc.collect()
return evicted
generated_report_keys: list[str] = []
output_paths: dict[str, str] = {}
out_dir: str | None = None
@@ -907,6 +1005,7 @@ def generate_compliance_reports(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
)
# Compute and store ThreatScore metrics snapshot
@@ -984,9 +1083,15 @@ def generate_compliance_reports(
logger.warning("ThreatScore report saved locally at %s", out_dir)
except Exception as e:
logger.error("Error generating ThreatScore report: %s", e)
logger.exception(
"compliance_report_failed framework=threatscore scan_id=%s tenant_id=%s",
scan_id,
tenant_id,
)
results["threatscore"] = {"upload": False, "path": "", "error": str(e)}
_evict_after_framework("threatscore")
# Generate ENS report
if generate_ens:
generated_report_keys.append("ens")
@@ -1006,6 +1111,7 @@ def generate_compliance_reports(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
)
upload_uri_ens = _upload_to_s3(
@@ -1020,9 +1126,15 @@ def generate_compliance_reports(
logger.warning("ENS report saved locally at %s", out_dir)
except Exception as e:
logger.error("Error generating ENS report: %s", e)
logger.exception(
"compliance_report_failed framework=ens scan_id=%s tenant_id=%s",
scan_id,
tenant_id,
)
results["ens"] = {"upload": False, "path": "", "error": str(e)}
_evict_after_framework("ens")
# Generate NIS2 report
if generate_nis2:
generated_report_keys.append("nis2")
@@ -1043,6 +1155,7 @@ def generate_compliance_reports(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
)
upload_uri_nis2 = _upload_to_s3(
@@ -1057,9 +1170,15 @@ def generate_compliance_reports(
logger.warning("NIS2 report saved locally at %s", out_dir)
except Exception as e:
logger.error("Error generating NIS2 report: %s", e)
logger.exception(
"compliance_report_failed framework=nis2 scan_id=%s tenant_id=%s",
scan_id,
tenant_id,
)
results["nis2"] = {"upload": False, "path": "", "error": str(e)}
_evict_after_framework("nis2")
# Generate CSA CCM report
if generate_csa:
generated_report_keys.append("csa")
@@ -1080,6 +1199,7 @@ def generate_compliance_reports(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
)
upload_uri_csa = _upload_to_s3(
@@ -1094,9 +1214,15 @@ def generate_compliance_reports(
logger.warning("CSA CCM report saved locally at %s", out_dir)
except Exception as e:
logger.error("Error generating CSA CCM report: %s", e)
logger.exception(
"compliance_report_failed framework=csa scan_id=%s tenant_id=%s",
scan_id,
tenant_id,
)
results["csa"] = {"upload": False, "path": "", "error": str(e)}
_evict_after_framework("csa")
# 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
@@ -1119,6 +1245,7 @@ def generate_compliance_reports(
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
)
upload_uri_cis = _upload_to_s3(
@@ -1147,14 +1274,22 @@ def generate_compliance_reports(
)
except Exception as e:
logger.error("Error generating CIS report %s: %s", latest_cis, e)
logger.exception(
"compliance_report_failed framework=cis variant=%s scan_id=%s tenant_id=%s",
latest_cis,
scan_id,
tenant_id,
)
results["cis"] = {
"upload": False,
"path": "",
"error": str(e),
}
finally:
# Free ReportLab/matplotlib memory before moving on.
# Free ReportLab/matplotlib memory before moving on. CIS is
# always the last framework, so evicting its entries clears the
# cache entirely (subject to its check_ids set).
_evict_after_framework("cis")
gc.collect()
# Clean up temporary files only if all generated reports were
+281 -75
View File
@@ -1,6 +1,9 @@
import gc
import os
import resource as _resource_module
import time
from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any
@@ -41,6 +44,7 @@ from .config import (
COLOR_LIGHT_BLUE,
COLOR_LIGHTER_BLUE,
COLOR_PROWLER_DARK_GREEN,
FINDINGS_TABLE_CHUNK_SIZE,
PADDING_LARGE,
PADDING_SMALL,
FrameworkConfig,
@@ -48,6 +52,46 @@ from .config import (
logger = get_task_logger(__name__)
@contextmanager
def _log_phase(phase: str, **tags: Any):
"""Log start/end timing and RSS deltas around a long-running task section.
Generic helper: callers pass arbitrary ``key=value`` tags
(e.g. ``scan_id``, ``framework``, ``provider_id``) and they are
emitted as part of the structured log line, so Grafana/Datadog/
CloudWatch queries can pivot by whichever dimension is relevant to
the task. ``getrusage`` returns KB on Linux and bytes on macOS;
the values are still useful in relative terms even though units
differ across platforms.
"""
tag_str = " ".join(f"{key}={value}" for key, value in tags.items())
suffix = f" {tag_str}" if tag_str else ""
start = time.perf_counter()
rss_before = _resource_module.getrusage(_resource_module.RUSAGE_SELF).ru_maxrss
logger.info("phase_start phase=%s%s rss_kb=%d", phase, suffix, rss_before)
try:
yield
except Exception:
elapsed = time.perf_counter() - start
logger.exception(
"phase_failed phase=%s%s elapsed_s=%.2f", phase, suffix, elapsed
)
raise
else:
elapsed = time.perf_counter() - start
rss_after = _resource_module.getrusage(_resource_module.RUSAGE_SELF).ru_maxrss
logger.info(
"phase_end phase=%s%s elapsed_s=%.2f rss_kb=%d delta_rss_kb=%d",
phase,
suffix,
elapsed,
rss_after,
rss_after - rss_before,
)
# Register fonts (done once at module load)
_fonts_registered: bool = False
@@ -335,6 +379,7 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
prowler_provider: Any | None = None,
**kwargs,
) -> None:
"""Generate the PDF compliance report.
@@ -351,23 +396,35 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider object
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings cache
prowler_provider: Optional pre-initialized Prowler provider. When
generating multiple reports for the same scan the master
function initializes this once and passes it in to avoid
re-running boto3/Azure-SDK setup per framework.
**kwargs: Additional framework-specific arguments
"""
framework = self.config.display_name
logger.info(
"Generating %s report for scan %s", self.config.display_name, scan_id
"report_generation_start framework=%s scan_id=%s compliance_id=%s",
framework,
scan_id,
compliance_id,
)
try:
# 1. Load compliance data
data = self._load_compliance_data(
tenant_id=tenant_id,
scan_id=scan_id,
compliance_id=compliance_id,
provider_id=provider_id,
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
)
with _log_phase(
"load_compliance_data", scan_id=scan_id, framework=framework
):
data = self._load_compliance_data(
tenant_id=tenant_id,
scan_id=scan_id,
compliance_id=compliance_id,
provider_id=provider_id,
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
prowler_provider=prowler_provider,
)
# 2. Create PDF document
doc = self._create_document(output_path, data)
@@ -377,37 +434,54 @@ class BaseComplianceReportGenerator(ABC):
elements = []
# Cover page (lightweight)
elements.extend(self.create_cover_page(data))
elements.append(PageBreak())
with _log_phase("cover_page", scan_id=scan_id, framework=framework):
elements.extend(self.create_cover_page(data))
elements.append(PageBreak())
# Executive summary (framework-specific)
elements.extend(self.create_executive_summary(data))
with _log_phase("executive_summary", scan_id=scan_id, framework=framework):
elements.extend(self.create_executive_summary(data))
# Body sections (charts + requirements index)
# Override _build_body_sections() in subclasses to change section order
elements.extend(self._build_body_sections(data))
with _log_phase("body_sections", scan_id=scan_id, framework=framework):
elements.extend(self._build_body_sections(data))
# Detailed findings - heaviest section, loads findings on-demand
logger.info("Building detailed findings section...")
elements.extend(self.create_detailed_findings(data, **kwargs))
gc.collect() # Free findings data after processing
with _log_phase("detailed_findings", scan_id=scan_id, framework=framework):
elements.extend(self.create_detailed_findings(data, **kwargs))
gc.collect() # Free findings data after processing
# 4. Build the PDF
logger.info("Building PDF document with %d elements...", len(elements))
self._build_pdf(doc, elements, data)
logger.info(
"doc_build_about_to_run framework=%s scan_id=%s elements=%d",
framework,
scan_id,
len(elements),
)
with _log_phase("doc_build", scan_id=scan_id, framework=framework):
self._build_pdf(doc, elements, data)
# Final cleanup
del elements
gc.collect()
logger.info("Successfully generated report at %s", output_path)
logger.info(
"report_generation_end framework=%s scan_id=%s output_path=%s",
framework,
scan_id,
output_path,
)
except Exception as e:
import traceback
tb_lineno = e.__traceback__.tb_lineno if e.__traceback__ else "unknown"
logger.error("Error generating report, line %s -- %s", tb_lineno, e)
logger.error("Full traceback:\n%s", traceback.format_exc())
except Exception:
# logger.exception captures the full traceback; the contextual
# keys keep production search-by-scan-id viable.
logger.exception(
"report_generation_failed framework=%s scan_id=%s compliance_id=%s",
framework,
scan_id,
compliance_id,
)
raise
def _build_body_sections(self, data: ComplianceData) -> list:
@@ -638,15 +712,25 @@ class BaseComplianceReportGenerator(ABC):
for req in requirements:
check_ids_to_load.extend(req.checks)
# Load findings on-demand only for the checks that will be displayed
# Uses the shared findings cache to avoid duplicate queries across reports
# Load findings on-demand only for the checks that will be displayed.
# When ``only_failed`` is active at requirement level, also push the
# FAIL filter down to the finding level: a requirement marked FAIL
# because 1/1000 findings failed must not render a table dominated by
# 999 PASS rows. That hides the actual failure under noise and
# makes the per-check cap truncate the wrong rows.
# ``total_counts`` is populated with the pre-cap total per check_id
# (FAIL-only when only_failed is active) so the "Showing first N of
# M" banner uses the same denominator the reader cares about.
logger.info("Loading findings on-demand for %d requirements", len(requirements))
total_counts: dict[str, int] = {}
findings_by_check_id = _load_findings_for_requirement_checks(
data.tenant_id,
data.scan_id,
check_ids_to_load,
data.prowler_provider,
data.findings_by_check_id, # Pass the cache to update it
total_counts_out=total_counts,
only_failed_findings=only_failed,
)
for req in requirements:
@@ -678,9 +762,31 @@ class BaseComplianceReportGenerator(ABC):
)
)
else:
# Create findings table
findings_table = self._create_findings_table(findings)
elements.append(findings_table)
# Surface truncation BEFORE the tables so readers see it
# at the same scroll position as the data itself, not
# after thousands of rendered rows.
loaded = len(findings)
total = total_counts.get(check_id, loaded)
if total > loaded:
kind = "failed findings" if only_failed else "findings"
elements.append(
Paragraph(
f"<b>&#9888; Showing first {loaded:,} of "
f"{total:,} {kind} for this check.</b> "
f"Use the CSV or JSON-OCSF export for the full "
f"list. The PDF caps detail rows to keep "
f"the report readable and bounded in size.",
self.styles["normal"],
)
)
elements.append(Spacer(1, 0.05 * inch))
# Create chunked findings tables to prevent OOM when a
# single check has thousands of findings (ReportLab
# resolves layout per Flowable, so many small tables
# render contiguously with a bounded memory peak).
findings_tables = self._create_findings_tables(findings)
elements.extend(findings_tables)
elements.append(Spacer(1, 0.1 * inch))
@@ -735,6 +841,7 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Provider | None,
requirement_statistics: dict | None,
findings_cache: dict | None,
prowler_provider: Any | None = None,
) -> ComplianceData:
"""Load and aggregate compliance data from the database.
@@ -746,6 +853,9 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings
prowler_provider: Optional pre-initialized Prowler provider. When
the master function initializes it once and passes it in,
we skip the per-report ``initialize_prowler_provider`` call.
Returns:
Aggregated ComplianceData object
@@ -755,7 +865,8 @@ class BaseComplianceReportGenerator(ABC):
if provider_obj is None:
provider_obj = Provider.objects.get(id=provider_id)
prowler_provider = initialize_prowler_provider(provider_obj)
if prowler_provider is None:
prowler_provider = initialize_prowler_provider(provider_obj)
provider_type = provider_obj.provider
# Load compliance framework
@@ -823,13 +934,32 @@ class BaseComplianceReportGenerator(ABC):
) -> SimpleDocTemplate:
"""Create the PDF document template.
Validates that ``output_path`` is a filesystem path string with an
existing parent directory. SimpleDocTemplate technically accepts a
BytesIO too, but we want every report to land on disk so the
Celery worker doesn't hold the full PDF in memory while uploading
to S3.
Args:
output_path: Path for the output PDF
data: Compliance data for metadata
Returns:
Configured SimpleDocTemplate
Raises:
TypeError: ``output_path`` is not a string.
FileNotFoundError: The parent directory does not exist.
"""
if not isinstance(output_path, str):
raise TypeError(
"output_path must be a filesystem path string; "
f"got {type(output_path).__name__}"
)
parent_dir = os.path.dirname(output_path)
if parent_dir and not os.path.isdir(parent_dir):
raise FileNotFoundError(f"Output directory does not exist: {parent_dir}")
return SimpleDocTemplate(
output_path,
pagesize=letter,
@@ -876,47 +1006,10 @@ class BaseComplianceReportGenerator(ABC):
onLaterPages=add_footer,
)
def _create_findings_table(self, findings: list[FindingOutput]) -> Any:
"""Create a findings table.
Args:
findings: List of finding objects
Returns:
ReportLab Table element
"""
def get_finding_title(f):
metadata = getattr(f, "metadata", None)
if metadata:
return getattr(metadata, "CheckTitle", getattr(f, "check_id", ""))
return getattr(f, "check_id", "")
def get_resource_name(f):
name = getattr(f, "resource_name", "")
if not name:
name = getattr(f, "resource_uid", "")
return name
def get_severity(f):
metadata = getattr(f, "metadata", None)
if metadata:
return getattr(metadata, "Severity", "").capitalize()
return ""
# Convert findings to dicts for the table
data = []
for f in findings:
item = {
"title": get_finding_title(f),
"resource_name": get_resource_name(f),
"severity": get_severity(f),
"status": getattr(f, "status", "").upper(),
"region": getattr(f, "region", "global"),
}
data.append(item)
columns = [
# Column layout shared by all findings sub-tables. Defined as a method so
# subclasses can override it without re-implementing the chunking logic.
def _findings_table_columns(self) -> list[ColumnConfig]:
return [
ColumnConfig("Finding", 2.5 * inch, "title"),
ColumnConfig("Resource", 3 * inch, "resource_name"),
ColumnConfig("Severity", 0.9 * inch, "severity"),
@@ -924,9 +1017,122 @@ class BaseComplianceReportGenerator(ABC):
ColumnConfig("Region", 0.9 * inch, "region"),
]
@staticmethod
def _finding_to_row(f: FindingOutput) -> dict[str, str]:
"""Project a FindingOutput onto the row dict the table expects.
Kept defensive: missing metadata or attributes return empty strings
rather than raising, so a single malformed finding never breaks the
whole report.
"""
metadata = getattr(f, "metadata", None)
title = (
getattr(metadata, "CheckTitle", getattr(f, "check_id", ""))
if metadata
else getattr(f, "check_id", "")
)
resource_name = getattr(f, "resource_name", "") or getattr(
f, "resource_uid", ""
)
severity = getattr(metadata, "Severity", "").capitalize() if metadata else ""
return {
"title": title,
"resource_name": resource_name,
"severity": severity,
"status": getattr(f, "status", "").upper(),
"region": getattr(f, "region", "global"),
}
def _create_findings_tables(
self,
findings: list[FindingOutput],
chunk_size: int | None = None,
) -> list[Any]:
"""Build a list of small findings tables to keep ``doc.build()`` memory bounded.
ReportLab resolves layout (column widths, row heights, page-breaks)
per Flowable. A single ``LongTable`` of 15k rows forces all of that
to be computed at once and reliably OOMs the worker on large scans.
Splitting into chunks of ``chunk_size`` rows produces an equivalent-
looking PDF (LongTable repeats headers; chunks render contiguously)
with a bounded memory peak per chunk.
Args:
findings: List of finding objects for a single check.
chunk_size: Rows per sub-table. ``None`` uses
``FINDINGS_TABLE_CHUNK_SIZE`` from config.
Returns:
List of ReportLab flowables (interleaved ``Table``/``LongTable``
and small ``Spacer`` between chunks). Empty list when there are
no findings.
"""
if not findings:
return []
chunk_size = chunk_size or FINDINGS_TABLE_CHUNK_SIZE
# Build all rows first so we can chunk without re-walking the
# FindingOutput list. Malformed findings are skipped with a logged
# exception, never enough to abort the entire report.
rows: list[dict[str, str]] = []
for f in findings:
try:
rows.append(self._finding_to_row(f))
except Exception:
logger.exception(
"Skipping malformed finding while building table for check %s",
getattr(f, "check_id", "unknown"),
)
if not rows:
return []
columns = self._findings_table_columns()
flowables: list = []
total = len(rows)
for start in range(0, total, chunk_size):
chunk = rows[start : start + chunk_size]
flowables.append(
create_data_table(
data=chunk,
columns=columns,
header_color=self.config.primary_color,
normal_style=self.styles["normal_center"],
)
)
# A tiny spacer between chunks keeps them visually contiguous
# without forcing a page-break (KeepTogether would negate the
# memory benefit of chunking).
if start + chunk_size < total:
flowables.append(Spacer(1, 0.05 * inch))
if total > chunk_size:
logger.debug(
"Built %d findings sub-tables (chunk_size=%d, total_findings=%d)",
(total + chunk_size - 1) // chunk_size,
chunk_size,
total,
)
return flowables
def _create_findings_table(self, findings: list[FindingOutput]) -> Any:
"""Deprecated alias kept for backwards compatibility.
Returns the first chunk produced by ``_create_findings_tables``.
New callers MUST use ``_create_findings_tables``, which returns a
list of flowables and is what ``create_detailed_findings`` invokes.
"""
flowables = self._create_findings_tables(findings)
if flowables:
return flowables[0]
# Empty input → return an empty (header-only) table so callers that
# used to receive a Table never get None.
return create_data_table(
data=data,
columns=columns,
data=[],
columns=self._findings_table_columns(),
header_color=self.config.primary_color,
normal_style=self.styles["normal_center"],
)
@@ -1,9 +1,11 @@
import gc
import io
import math
import time
from typing import Callable
import matplotlib
from celery.utils.log import get_task_logger
# Use non-interactive Agg backend for memory efficiency in server environments
# This MUST be set before importing pyplot
@@ -20,6 +22,26 @@ from .config import ( # noqa: E402
CHART_DPI_DEFAULT,
)
logger = get_task_logger(__name__)
def _log_chart_built(name: str, dpi: int, buffer: io.BytesIO, started: float) -> None:
"""Emit a structured DEBUG line summarising a chart render.
Centralised so the formatting stays consistent across all chart helpers
and so we never accidentally pay for buffer.getbuffer().nbytes when
debug logging is disabled.
"""
if logger.isEnabledFor(10): # logging.DEBUG
logger.debug(
"chart_built name=%s dpi=%d bytes=%d elapsed_s=%.2f",
name,
dpi,
buffer.getbuffer().nbytes,
time.perf_counter() - started,
)
# Use centralized DPI setting from config
DEFAULT_CHART_DPI = CHART_DPI_DEFAULT
@@ -77,6 +99,7 @@ def create_vertical_bar_chart(
Returns:
BytesIO buffer containing the PNG image
"""
_started = time.perf_counter()
if color_func is None:
color_func = get_chart_color_for_percentage
@@ -122,6 +145,7 @@ def create_vertical_bar_chart(
plt.close(fig)
gc.collect() # Force garbage collection after heavy matplotlib operation
_log_chart_built("vertical_bar", dpi, buffer, _started)
return buffer
@@ -156,6 +180,7 @@ def create_horizontal_bar_chart(
Returns:
BytesIO buffer containing the PNG image
"""
_started = time.perf_counter()
if color_func is None:
color_func = get_chart_color_for_percentage
@@ -207,6 +232,7 @@ def create_horizontal_bar_chart(
plt.close(fig)
gc.collect() # Force garbage collection after heavy matplotlib operation
_log_chart_built("horizontal_bar", dpi, buffer, _started)
return buffer
@@ -239,6 +265,7 @@ def create_radar_chart(
Returns:
BytesIO buffer containing the PNG image
"""
_started = time.perf_counter()
num_vars = len(labels)
angles = [n / float(num_vars) * 2 * math.pi for n in range(num_vars)]
@@ -275,6 +302,7 @@ def create_radar_chart(
plt.close(fig)
gc.collect() # Force garbage collection after heavy matplotlib operation
_log_chart_built("radar", dpi, buffer, _started)
return buffer
@@ -303,6 +331,7 @@ def create_pie_chart(
Returns:
BytesIO buffer containing the PNG image
"""
_started = time.perf_counter()
fig, ax = plt.subplots(figsize=figsize)
_, _, autotexts = ax.pie(
@@ -330,6 +359,7 @@ def create_pie_chart(
plt.close(fig)
gc.collect() # Force garbage collection after heavy matplotlib operation
_log_chart_built("pie", dpi, buffer, _started)
return buffer
@@ -362,6 +392,7 @@ def create_stacked_bar_chart(
Returns:
BytesIO buffer containing the PNG image
"""
_started = time.perf_counter()
fig, ax = plt.subplots(figsize=figsize)
# Default colors if not provided
@@ -401,4 +432,5 @@ def create_stacked_bar_chart(
plt.close(fig)
gc.collect() # Force garbage collection after heavy matplotlib operation
_log_chart_built("stacked_bar", dpi, buffer, _started)
return buffer
@@ -475,8 +475,15 @@ def create_data_table(
else:
value = item.get(col.field, "")
# Wrap every string cell in Paragraph so the data rows keep the
# caller-supplied font/colour/alignment. Skipping Paragraph for
# short cells (a tempting micro-optimisation) breaks visual
# consistency: ReportLab Table falls back to Helvetica/black for
# raw strings, mixing fonts within the same table.
# ``escape_html`` keeps ``<``/``>``/``&`` in resource names from
# breaking Paragraph's mini-HTML parser.
if normal_style and isinstance(value, str):
value = Paragraph(value, normal_style)
value = Paragraph(escape_html(value), normal_style)
row.append(value)
table_data.append(row)
@@ -508,17 +515,26 @@ def create_data_table(
for idx, col in enumerate(columns):
styles.append(("ALIGN", (idx, 0), (idx, -1), col.align))
# Alternate row backgrounds - skip for very large tables as it adds memory overhead
# Alternate row backgrounds: single O(1) ROWBACKGROUNDS style entry.
# The previous implementation appended N per-row BACKGROUND commands,
# which scaled the TableStyle list linearly with row count. ReportLab
# cycles through the colour list row-by-row so the visual is identical.
# The ALTERNATE_ROWS_MAX_SIZE cap is preserved to mirror legacy
# behaviour (very large tables stay plain), but the memory cost of the
# styles list is now constant regardless of row count.
if (
alternate_rows
and len(table_data) > 1
and len(table_data) <= ALTERNATE_ROWS_MAX_SIZE
):
for i in range(1, len(table_data)):
if i % 2 == 0:
styles.append(
("BACKGROUND", (0, i), (-1, i), colors.Color(0.98, 0.98, 0.98))
)
styles.append(
(
"ROWBACKGROUNDS",
(0, 1),
(-1, -1),
[colors.white, colors.Color(0.98, 0.98, 0.98)],
)
)
table.setStyle(TableStyle(styles))
return table
@@ -1,3 +1,4 @@
import os
from dataclasses import dataclass, field
from reportlab.lib import colors
@@ -23,6 +24,47 @@ ALTERNATE_ROWS_MAX_SIZE = 200
# Larger = fewer queries but more memory per batch
FINDINGS_BATCH_SIZE = 2000
# Maximum rows per findings sub-table. ReportLab resolves layout per Flowable;
# splitting a huge findings list into multiple smaller tables keeps the peak
# memory of doc.build() bounded. A single 15k-row LongTable would force
# ReportLab to compute all column widths/row heights/page-breaks at once and
# OOM the worker; 300-row chunks are rendered contiguously with negligible
# visual impact.
FINDINGS_TABLE_CHUNK_SIZE = 300
# Maximum findings rendered per check in the detailed-findings section.
#
# Product behaviour: compliance PDFs render at most ``MAX_FINDINGS_PER_CHECK``
# **failed** findings per check (PASS rows are excluded at SQL level by the
# ``only_failed`` flag that all four list-rendering frameworks default to:
# ThreatScore, NIS2, CSA, CIS; ENS does not render finding tables). Above
# this cap each affected check renders an in-PDF banner
# ("Showing first 100 of N failed findings for this check. Use the CSV
# or JSON export for the full list") so the reader knows the table is
# truncated and where to find the full data.
#
# Why a cap exists at all:
# * ``FindingOutput.transform_api_finding`` is O(N) per finding (Pydantic
# v1 validation + nested model construction).
# * ReportLab resolves layout per Flowable; thousands of sub-tables make
# ``doc.build()`` very slow and grow the PDF unboundedly.
# * A human-readable executive/auditor PDF does not need 12,000 rows for
# one check; that is forensic data and lives in the CSV/JSON exports.
#
# Why 100 specifically:
# * Covers ~99% of real scans without truncation (most checks emit far
# fewer than 100 findings even in enterprise estates).
# * Worst-case rendered rows = 100 × ~500 checks = 50k rows across all
# frameworks, which keeps RSS bounded and a 5-framework run completes
# in minutes instead of hours.
#
# Override at runtime via ``DJANGO_PDF_MAX_FINDINGS_PER_CHECK``:
# * Set to ``0`` to disable the cap entirely (load every finding; only
# advisable for small scans).
# * Set to a larger value (e.g. ``500``) for forensic detail in big runs;
# watch RSS in the Celery worker.
MAX_FINDINGS_PER_CHECK = int(os.environ.get("DJANGO_PDF_MAX_FINDINGS_PER_CHECK", "100"))
# =============================================================================
# Base colors
+145 -12
View File
@@ -1,6 +1,8 @@
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from django.db.models import Count, Q
from django.db.models import Count, F, Q, Window
from django.db.models.functions import RowNumber
from tasks.jobs.reports.config import MAX_FINDINGS_PER_CHECK
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
@@ -154,6 +156,8 @@ def _load_findings_for_requirement_checks(
check_ids: list[str],
prowler_provider,
findings_cache: dict[str, list[FindingOutput]] | None = None,
total_counts_out: dict[str, int] | None = None,
only_failed_findings: bool = False,
) -> dict[str, list[FindingOutput]]:
"""
Load findings for specific check IDs on-demand with optional caching.
@@ -178,6 +182,23 @@ def _load_findings_for_requirement_checks(
prowler_provider: The initialized Prowler provider instance.
findings_cache (dict, optional): Cache of already loaded findings.
If provided, checks are first looked up in cache before querying database.
total_counts_out (dict, optional): If provided, populated with
``{check_id: total_findings_in_db}`` BEFORE any per-check cap is
applied. Lets callers render a "Showing first N of M" banner for
truncated checks. Only populated for ``check_ids`` actually
queried (cache hits keep whatever value the caller already had).
When ``only_failed_findings=True`` the total is FAIL-only.
only_failed_findings (bool): When True, push the ``status=FAIL``
filter down into the SQL query so PASS rows are never loaded
from the DB nor pydantic-transformed. This matches the
``only_failed`` requirement-level filter applied at PDF render
time: a requirement marked FAIL because 1/1000 findings failed
shouldn't render a table of 999 PASS rows. That hides the
actual failure under noise and wastes the per-check cap on
irrelevant data. NOTE: the findings cache stores whatever the
first caller asked for, so all callers in a single
``generate_compliance_reports`` run MUST pass the same flag
(which they do: it threads from ``only_failed`` defaults).
Returns:
dict[str, list[FindingOutput]]: Dictionary mapping check_id to list of FindingOutput objects.
@@ -222,17 +243,88 @@ def _load_findings_for_requirement_checks(
)
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
# Use iterator with chunk_size for memory-efficient streaming
# chunk_size controls how many rows Django fetches from DB at once
findings_queryset = (
Finding.all_objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
check_id__in=check_ids_to_load,
)
.order_by("check_id", "uid")
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
base_qs = Finding.all_objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
check_id__in=check_ids_to_load,
)
if only_failed_findings:
# Push the FAIL filter down into SQL: DB returns ~N×FAIL
# rows instead of N×ALL, and we never spend pydantic CPU on
# PASS findings the PDF would never render.
base_qs = base_qs.filter(status=StatusChoices.FAIL)
# Aggregate totals once so we (a) know which checks need capping
# and (b) can surface "Showing first N of M" in the PDF banner.
# Cheap: a single COUNT grouped by check_id.
totals: dict[str, int] = {
row["check_id"]: row["total"]
for row in base_qs.values("check_id").annotate(total=Count("id"))
}
if total_counts_out is not None:
total_counts_out.update(totals)
cap = MAX_FINDINGS_PER_CHECK
checks_over_cap = (
{cid for cid, n in totals.items() if n > cap} if cap > 0 else set()
)
# Use iterator with chunk_size for memory-efficient streaming.
# FindingOutput.transform_api_finding (prowler/lib/outputs/finding.py)
# reads finding.resources.first() and resource.tags.all() per
# finding, which without prefetch generates 2N queries per chunk.
# prefetch_related runs once per iterator chunk (Django >=4.1) and
# collapses that into a constant 2 extra queries per chunk.
if checks_over_cap:
# Two-step query so we can both cap rows per check AND attach
# prefetch_related on the streamed results:
#
# 1) ``ranked`` annotates every matching finding with a
# per-check row number via a window function. The
# partition keeps numbering independent per check, and
# ordering by ``uid`` makes the "first N" selection
# deterministic across runs (same scan → same rows).
#
# 2) The outer ``Finding.all_objects.filter(id__in=...)``
# keeps only IDs whose row number is within the cap and
# re-opens a plain queryset on it. Django cannot combine
# ``Window`` annotations with ``prefetch_related`` on the
# same queryset (the window is evaluated post-aggregation
# and the prefetch loader fights with it), so the inner
# SELECT becomes a subquery and the outer queryset is
# free to prefetch resources/tags as usual.
#
# PostgreSQL only materialises
# ``cap * |checks_over_cap| + sum(uncapped)`` rows for the
# window step, vs the full table scan the previous path did.
ranked = base_qs.annotate(
rn=Window(
expression=RowNumber(),
partition_by=[F("check_id")],
order_by=F("uid").asc(),
)
)
findings_queryset = (
Finding.all_objects.filter(
id__in=ranked.filter(rn__lte=cap).values("id")
)
.prefetch_related("resources", "resources__tags")
.order_by("check_id", "uid")
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
)
logger.info(
"Per-check cap=%d active for %d checks (max %d each); "
"skipping transform for surplus rows",
cap,
len(checks_over_cap),
cap,
)
else:
findings_queryset = (
base_qs.prefetch_related("resources", "resources__tags")
.order_by("check_id", "uid")
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
)
# Pre-initialize empty lists for all check_ids to load
# This avoids repeated dict lookups and 'if not in' checks
@@ -248,7 +340,11 @@ def _load_findings_for_requirement_checks(
findings_count += 1
logger.info(
f"Loaded {findings_count} findings for {len(check_ids_to_load)} checks"
"Loaded %d findings for %d checks (truncated %d checks total=%d)",
findings_count,
len(check_ids_to_load),
len(checks_over_cap),
sum(totals.values()),
)
# Build result dict using cache references (no data duplication)
@@ -258,3 +354,40 @@ def _load_findings_for_requirement_checks(
}
return result
def _get_compliance_check_ids(compliance_obj) -> set[str]:
"""Return the union of all check_ids referenced by a compliance framework.
Used by the master report orchestrator to know which checks each
framework consumes from the shared ``findings_cache``, so that once a
framework finishes the entries no other pending framework needs can be
evicted from the cache (PROWLER-1733).
Args:
compliance_obj: A loaded Compliance framework object exposing a
``Requirements`` iterable, each requirement carrying ``Checks``.
``None`` is treated as "no checks" rather than raising, so the
caller can pass ``frameworks_bulk.get(...)`` directly without
an extra existence check.
Returns:
Set of check_id strings (empty if ``compliance_obj`` is ``None``).
"""
if compliance_obj is None:
return set()
checks: set[str] = set()
requirements = getattr(compliance_obj, "Requirements", None) or []
try:
# Defensive: Mock objects (used in unit tests) return another Mock
# for any attribute access, which is truthy but not iterable. Treat
# any non-iterable Requirements value as "no checks".
for req in requirements:
req_checks = getattr(req, "Checks", None) or []
try:
checks.update(req_checks)
except TypeError:
continue
except TypeError:
return set()
return checks
+22 -1
View File
@@ -69,7 +69,7 @@ from tasks.utils import (
from api.compliance import get_compliance_frameworks
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.db_utils import delete_related_daily_task, rls_transaction
from api.decorators import handle_provider_deletion, set_tenant
from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices
from api.utils import initialize_prowler_provider
@@ -274,6 +274,17 @@ def perform_scan_task(
Returns:
dict: The result of the scan execution, typically including the status and results of the performed checks.
"""
with rls_transaction(tenant_id):
if not Provider.objects.filter(pk=provider_id).exists():
logger.warning(
"scan-perform skipped: provider %s no longer exists "
"(tenant=%s, scan=%s)",
provider_id,
tenant_id,
scan_id,
)
return None
result = perform_prowler_scan(
tenant_id=tenant_id,
scan_id=scan_id,
@@ -310,6 +321,16 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
task_id = self.request.id
with rls_transaction(tenant_id):
if not Provider.objects.filter(pk=provider_id).exists():
logger.warning(
"scheduled scan-perform skipped: provider %s no longer exists "
"(tenant=%s)",
provider_id,
tenant_id,
)
delete_related_daily_task(provider_id)
return None
periodic_task_instance = PeriodicTask.objects.get(
name=f"scan-perform-scheduled-{provider_id}"
)
+488
View File
@@ -44,6 +44,8 @@ from api.models import (
Finding,
Resource,
ResourceFindingMapping,
ResourceTag,
ResourceTagMapping,
StateChoices,
StatusChoices,
)
@@ -367,6 +369,317 @@ class TestLoadFindingsForChecks:
assert result == {}
def test_prefetch_avoids_n_plus_one(self, tenants_fixture, scans_fixture):
"""Loading N findings must NOT execute O(N) extra queries for resources/tags.
Regression test for PROWLER-1733. ``FindingOutput.transform_api_finding``
reads ``finding.resources.first()`` and ``resource.tags.all()`` per
finding. Without ``prefetch_related`` that's 2N additional queries;
with prefetch it collapses to a small constant per iterator chunk.
"""
from django.test.utils import CaptureQueriesContext
from django.db import connections
tenant = tenants_fixture[0]
scan = scans_fixture[0]
# Build N findings, each linked to one resource that owns 2 tags.
N = 20
for i in range(N):
finding = Finding.objects.create(
tenant_id=tenant.id,
scan=scan,
uid=f"f-prefetch-{i}",
check_id="aws_check_prefetch",
status=StatusChoices.FAIL,
severity=Severity.high,
impact=Severity.high,
check_metadata={
"provider": "aws",
"checkid": "aws_check_prefetch",
"checktitle": "t",
"checktype": [],
"servicename": "s",
"subservicename": "",
"severity": "high",
"resourcetype": "r",
"description": "",
"risk": "",
"relatedurl": "",
"remediation": {
"recommendation": {"text": "", "url": ""},
"code": {
"nativeiac": "",
"terraform": "",
"cli": "",
"other": "",
},
},
"resourceidtemplate": "",
"categories": [],
"dependson": [],
"relatedto": [],
"notes": "",
},
raw_result={},
)
resource = Resource.objects.create(
tenant_id=tenant.id,
provider=scan.provider,
uid=f"r-prefetch-{i}",
name=f"r-prefetch-{i}",
metadata="{}",
details="",
region="us-east-1",
service="s",
type="t::r",
)
ResourceFindingMapping.objects.create(
tenant_id=tenant.id, finding=finding, resource=resource
)
for k in ("env", "owner"):
tag, _ = ResourceTag.objects.get_or_create(
tenant_id=tenant.id, key=k, value=f"v-{i}-{k}"
)
ResourceTagMapping.objects.create(
tenant_id=tenant.id, resource=resource, tag=tag
)
mock_provider = Mock()
mock_provider.type = "aws"
mock_provider.identity.account = "test"
# Patch transform_api_finding to a no-op so the test isolates queries
# to the queryset/prefetch path (transform itself is exercised by
# the integration tests above and not by this regression check).
with patch(
"tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding",
side_effect=lambda model, provider: Mock(check_id=model.check_id),
):
with CaptureQueriesContext(
connections["default_read_replica"]
if "default_read_replica" in connections.databases
else connections["default"]
) as ctx:
_load_findings_for_requirement_checks(
str(tenant.id),
str(scan.id),
["aws_check_prefetch"],
mock_provider,
)
# Expected: a small constant number of queries irrespective of N.
# Pre-fix this would be ~1 + 2*N. We give some slack for RLS SET
# LOCAL statements that the rls_transaction emits.
assert len(ctx.captured_queries) < N, (
f"Expected O(1) queries with prefetch_related; got "
f"{len(ctx.captured_queries)} for N={N} (N+1 regression?)"
)
def test_max_findings_per_check_cap(self, tenants_fixture, scans_fixture):
"""When a check exceeds ``MAX_FINDINGS_PER_CHECK``, only ``cap`` rows
are loaded AND ``total_counts_out`` reports the pre-cap total.
Guards the PROWLER-1733 truncation knob: prevents both runaway memory
and silent data loss in the PDF (the banner relies on knowing the
real total).
"""
from unittest.mock import patch as _patch
tenant = tenants_fixture[0]
scan = scans_fixture[0]
# Create 12 findings for a single check; cap to 5.
check_id = "aws_check_cap_test"
for i in range(12):
finding = Finding.objects.create(
tenant_id=tenant.id,
scan=scan,
uid=f"f-cap-{i:02d}",
check_id=check_id,
status=StatusChoices.FAIL,
severity=Severity.high,
impact=Severity.high,
check_metadata={},
raw_result={},
)
resource = Resource.objects.create(
tenant_id=tenant.id,
provider=scan.provider,
uid=f"r-cap-{i:02d}",
name=f"r-cap-{i:02d}",
metadata="{}",
details="",
region="us-east-1",
service="s",
type="t::r",
)
ResourceFindingMapping.objects.create(
tenant_id=tenant.id, finding=finding, resource=resource
)
mock_provider = Mock(type="aws")
mock_provider.identity.account = "test"
totals: dict = {}
# Patch the cap to a small value AND skip the heavy transform so we
# only assert on row counts and totals.
with (
_patch("tasks.jobs.threatscore_utils.MAX_FINDINGS_PER_CHECK", 5),
_patch(
"tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding",
side_effect=lambda model, provider: Mock(check_id=model.check_id),
),
):
result = _load_findings_for_requirement_checks(
str(tenant.id),
str(scan.id),
[check_id],
mock_provider,
total_counts_out=totals,
)
assert (
len(result[check_id]) == 5
), f"cap=5 should yield exactly 5 loaded findings, got {len(result[check_id])}"
assert (
totals[check_id] == 12
), f"total_counts_out should report the pre-cap total (12), got {totals[check_id]}"
def test_only_failed_findings_pushes_down_to_sql(
self, tenants_fixture, scans_fixture
):
"""When ``only_failed_findings=True``, PASS rows are excluded by the
DB filter, not just visually hidden afterwards.
Regression for the consistency fix: previously the requirement-level
``only_failed`` flag filtered which requirements appeared, but inside
each rendered requirement the table still showed PASS rows mixed
with FAIL, which combined with ``MAX_FINDINGS_PER_CHECK`` could
truncate to 1000 PASS findings and hide the actual failure.
"""
from unittest.mock import patch as _patch
tenant = tenants_fixture[0]
scan = scans_fixture[0]
check_id = "aws_check_only_failed_test"
# Mix PASS and FAIL so the filter has something to drop.
for i in range(6):
status = StatusChoices.FAIL if i % 2 == 0 else StatusChoices.PASS
finding = Finding.objects.create(
tenant_id=tenant.id,
scan=scan,
uid=f"f-of-{i:02d}",
check_id=check_id,
status=status,
severity=Severity.high,
impact=Severity.high,
check_metadata={},
raw_result={},
)
resource = Resource.objects.create(
tenant_id=tenant.id,
provider=scan.provider,
uid=f"r-of-{i:02d}",
name=f"r-of-{i:02d}",
metadata="{}",
details="",
region="us-east-1",
service="s",
type="t::r",
)
ResourceFindingMapping.objects.create(
tenant_id=tenant.id, finding=finding, resource=resource
)
mock_provider = Mock(type="aws")
mock_provider.identity.account = "test"
totals: dict = {}
with _patch(
"tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding",
side_effect=lambda model, provider: Mock(
check_id=model.check_id, status=model.status
),
):
result = _load_findings_for_requirement_checks(
str(tenant.id),
str(scan.id),
[check_id],
mock_provider,
total_counts_out=totals,
only_failed_findings=True,
)
# 3 FAIL + 3 PASS in DB; FAIL-only filter should load just 3.
loaded = result[check_id]
assert len(loaded) == 3, f"expected 3 FAIL findings, got {len(loaded)}"
statuses = {getattr(f, "status", None) for f in loaded}
assert statuses == {
StatusChoices.FAIL
}, f"expected all loaded findings to be FAIL; got statuses {statuses}"
# total_counts must reflect the FAIL-only total, not the global total.
assert (
totals[check_id] == 3
), f"total_counts should be FAIL-only (3), got {totals[check_id]}"
def test_max_findings_per_check_disabled(self, tenants_fixture, scans_fixture):
"""``MAX_FINDINGS_PER_CHECK=0`` disables the cap; load all rows."""
from unittest.mock import patch as _patch
tenant = tenants_fixture[0]
scan = scans_fixture[0]
check_id = "aws_check_uncapped"
for i in range(8):
f = Finding.objects.create(
tenant_id=tenant.id,
scan=scan,
uid=f"f-unc-{i:02d}",
check_id=check_id,
status=StatusChoices.FAIL,
severity=Severity.high,
impact=Severity.high,
check_metadata={},
raw_result={},
)
r = Resource.objects.create(
tenant_id=tenant.id,
provider=scan.provider,
uid=f"r-unc-{i:02d}",
name=f"r-unc-{i:02d}",
metadata="{}",
details="",
region="us-east-1",
service="s",
type="t::r",
)
ResourceFindingMapping.objects.create(
tenant_id=tenant.id, finding=f, resource=r
)
mock_provider = Mock(type="aws")
mock_provider.identity.account = "test"
totals: dict = {}
with (
_patch("tasks.jobs.threatscore_utils.MAX_FINDINGS_PER_CHECK", 0),
_patch(
"tasks.jobs.threatscore_utils.FindingOutput.transform_api_finding",
side_effect=lambda model, provider: Mock(check_id=model.check_id),
),
):
result = _load_findings_for_requirement_checks(
str(tenant.id),
str(scan.id),
[check_id],
mock_provider,
total_counts_out=totals,
)
assert len(result[check_id]) == 8
assert totals[check_id] == 8
class TestCleanupStaleTmpOutputDirectories:
"""Unit tests for opportunistic stale cleanup under tmp output root."""
@@ -855,6 +1168,181 @@ class TestGenerateComplianceReportsOptimized:
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
@patch("api.utils.initialize_prowler_provider")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@patch("tasks.jobs.report.generate_csa_report")
@patch("tasks.jobs.report.generate_nis2_report")
@patch("tasks.jobs.report.generate_ens_report")
@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_findings_cache_eviction_after_framework(
self,
mock_scan_summary_filter,
mock_provider_get,
mock_get_bulk,
mock_aggregate_stats,
mock_generate_output_dir,
mock_threatscore,
mock_ens,
mock_nis2,
mock_csa,
mock_cis,
mock_upload_to_s3,
mock_rmtree,
mock_init_provider,
):
"""After each framework finishes, exclusive entries are evicted.
Threat scenario for PROWLER-1733: the shared ``findings_cache`` used
to grow monotonically through all 5 frameworks. With the new
eviction logic, check_ids only used by ThreatScore are dropped when
ThreatScore finishes, before ENS runs.
"""
from types import SimpleNamespace
from tasks.jobs import report as report_mod
mock_scan_summary_filter.return_value.exists.return_value = True
mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws")
# ThreatScore consumes {tsc_only, shared}; ENS consumes {ens_only,
# shared}. After ThreatScore evicts, tsc_only must be gone but
# shared and ens_only must remain.
mock_get_bulk.return_value = {
"prowler_threatscore_aws": SimpleNamespace(
Requirements=[SimpleNamespace(Checks=["tsc_only", "shared"])]
),
"ens_rd2022_aws": SimpleNamespace(
Requirements=[SimpleNamespace(Checks=["ens_only", "shared"])]
),
}
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
mock_init_provider.return_value = Mock(name="prowler_provider")
# Seed the cache as if both frameworks had already loaded their
# findings. We mutate it indirectly: each generator wrapper is a
# Mock: make ThreatScore populate the cache, and have ENS observe
# the state at call time so we can introspect post-eviction.
observed_state: dict = {}
def _threatscore_side_effect(**kwargs):
cache = kwargs["findings_cache"]
cache["tsc_only"] = ["tsc-finding"]
cache["shared"] = ["shared-finding"]
def _ens_side_effect(**kwargs):
# ENS runs AFTER threatscore's _evict_after_framework("threatscore").
observed_state["cache_keys_when_ens_runs"] = set(
kwargs["findings_cache"].keys()
)
kwargs["findings_cache"]["ens_only"] = ["ens-finding"]
mock_threatscore.side_effect = _threatscore_side_effect
mock_ens.side_effect = _ens_side_effect
report_mod.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=False,
generate_csa=False,
generate_cis=False,
)
# ``tsc_only`` was exclusive to ThreatScore → evicted before ENS ran.
# ``shared`` is still pending for ENS → must remain.
assert (
"tsc_only" not in observed_state["cache_keys_when_ens_runs"]
), "tsc_only should have been evicted before ENS ran"
assert (
"shared" in observed_state["cache_keys_when_ens_runs"]
), "shared must remain in cache because ENS still needs it"
@patch("tasks.jobs.report.initialize_prowler_provider")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@patch("tasks.jobs.report.generate_csa_report")
@patch("tasks.jobs.report.generate_nis2_report")
@patch("tasks.jobs.report.generate_ens_report")
@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_prowler_provider_initialized_once(
self,
mock_scan_summary_filter,
mock_provider_get,
mock_get_bulk,
mock_aggregate_stats,
mock_generate_output_dir,
mock_threatscore,
mock_ens,
mock_nis2,
mock_csa,
mock_cis,
mock_upload_to_s3,
mock_rmtree,
mock_init_provider,
):
"""``initialize_prowler_provider`` must be called exactly once for
the whole batch (PROWLER-1733). Previously each generator re-init'd
the SDK provider in ``_load_compliance_data`` 5 inits per scan.
"""
mock_scan_summary_filter.return_value.exists.return_value = True
mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws")
# CIS variant discovery needs at least one cis_* key.
mock_get_bulk.return_value = {"cis_6.0_aws": Mock()}
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
mock_init_provider.return_value = Mock(name="prowler_provider")
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,
)
# All 5 wrappers were invoked once each…
mock_threatscore.assert_called_once()
mock_ens.assert_called_once()
mock_nis2.assert_called_once()
mock_csa.assert_called_once()
mock_cis.assert_called_once()
# …but the SDK provider was initialized only once.
assert mock_init_provider.call_count == 1, (
f"expected 1 init, got {mock_init_provider.call_count} "
f"(prowler_provider must be shared across reports)"
)
# The shared instance must reach every wrapper as kwargs.
shared = mock_init_provider.return_value
for mock_wrapper in (
mock_threatscore,
mock_ens,
mock_nis2,
mock_csa,
mock_cis,
):
_, call_kwargs = mock_wrapper.call_args
assert call_kwargs.get("prowler_provider") is shared
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_threatscore_report")
@@ -1269,6 +1269,48 @@ class TestComponentEdgeCases:
# Should be a LongTable for large datasets
assert isinstance(table, LongTable)
def test_zebra_uses_rowbackgrounds_not_per_row_background(self, monkeypatch):
"""The styles list must contain exactly one ROWBACKGROUNDS entry
regardless of row count, never N per-row BACKGROUND entries.
"""
captured: dict = {}
# Capture the list passed to TableStyle. create_data_table builds a
# list of style tuples and wraps it in a TableStyle exactly once;
# by patching TableStyle we intercept that list.
import tasks.jobs.reports.components as comp_mod
original_table_style = comp_mod.TableStyle
def _capture_table_style(style_list):
captured["styles"] = list(style_list)
return original_table_style(style_list)
monkeypatch.setattr(comp_mod, "TableStyle", _capture_table_style)
data = [{"name": f"Item {i}"} for i in range(60)]
columns = [ColumnConfig("Name", 2 * inch, "name")]
comp_mod.create_data_table(data, columns, alternate_rows=True)
styles = captured["styles"]
# Count by command name.
names = [s[0] for s in styles if isinstance(s, tuple) and s]
# Exactly one ROWBACKGROUNDS entry.
assert names.count("ROWBACKGROUNDS") == 1
# Zero per-row BACKGROUND entries on data rows. (The header row
# BACKGROUND command is intentional and lives at coords (0,0)/(-1,0).)
data_row_bg = [
s
for s in styles
if isinstance(s, tuple)
and s[0] == "BACKGROUND"
and not (s[1] == (0, 0) and s[2] == (-1, 0))
]
assert data_row_bg == [], (
f"expected no per-row BACKGROUND entries on data rows; "
f"got {len(data_row_bg)}"
)
def test_create_risk_component_zero_values(self):
"""Test risk component with zero values."""
component = create_risk_component(risk_level=0, weight=0, score=0)
@@ -1344,3 +1386,194 @@ class TestFrameworkConfigEdgeCases:
assert get_framework_config("my_custom_threatscore_compliance") is not None
assert get_framework_config("ens_something_else") is not None
assert get_framework_config("nis2_gcp") is not None
# =============================================================================
# Findings Table Chunking Tests (PROWLER-1733)
# =============================================================================
#
# These tests guard the OOM-prevention behaviour added in PROWLER-1733:
# ``_create_findings_tables`` must split a list of findings into multiple
# small sub-tables instead of producing one giant Table, which would force
# ReportLab to resolve layout for all rows at once and OOM the worker on
# scans with thousands of findings per check.
class _DummyMetadata:
"""Lightweight stand-in for FindingOutput.metadata used in chunking tests."""
def __init__(self, check_title: str = "Title", severity: str = "high"):
self.CheckTitle = check_title
self.Severity = severity
class _DummyFinding:
"""Lightweight stand-in for FindingOutput used in chunking tests.
The chunking code only reads a small set of attributes via ``getattr``,
so a duck-typed object is enough and lets the tests run without touching
the DB or pydantic deserialisation.
"""
def __init__(
self,
check_id: str = "aws_check",
resource_name: str = "res-1",
resource_uid: str = "",
status: str = "FAIL",
region: str = "us-east-1",
with_metadata: bool = True,
):
self.check_id = check_id
self.resource_name = resource_name
self.resource_uid = resource_uid
self.status = status
self.region = region
if with_metadata:
self.metadata = _DummyMetadata()
else:
self.metadata = None
def _make_concrete_generator():
"""Return a minimal concrete subclass of BaseComplianceReportGenerator."""
class _Concrete(BaseComplianceReportGenerator):
def create_executive_summary(self, data):
return []
def create_charts_section(self, data):
return []
def create_requirements_index(self, data):
return []
return _Concrete(FrameworkConfig(name="test", display_name="Test"))
class TestFindingsTableChunking:
"""Tests for ``_create_findings_tables`` (PROWLER-1733)."""
def test_chunking_produces_expected_number_of_subtables(self):
"""5000 findings @ chunk_size=300 → 17 sub-tables + 16 spacers."""
generator = _make_concrete_generator()
findings = [_DummyFinding(check_id="c1") for _ in range(5000)]
flowables = generator._create_findings_tables(findings, chunk_size=300)
tables = [f for f in flowables if isinstance(f, (Table, LongTable))]
spacers = [f for f in flowables if isinstance(f, Spacer)]
# ceil(5000 / 300) == 17
assert len(tables) == 17
# Spacer between every pair of contiguous tables, not after the last
assert len(spacers) == 16
def test_chunk_size_param_overrides_default(self):
"""250 findings @ chunk_size=100 → 3 sub-tables."""
generator = _make_concrete_generator()
findings = [_DummyFinding(check_id="c2") for _ in range(250)]
flowables = generator._create_findings_tables(findings, chunk_size=100)
tables = [f for f in flowables if isinstance(f, (Table, LongTable))]
assert len(tables) == 3
def test_empty_findings_returns_empty_list(self):
"""No findings → no flowables. Callers can extend(...) safely."""
generator = _make_concrete_generator()
assert generator._create_findings_tables([]) == []
def test_single_chunk_has_no_spacer(self):
"""A single sub-table must not emit a trailing spacer."""
generator = _make_concrete_generator()
findings = [_DummyFinding(check_id="c3") for _ in range(10)]
flowables = generator._create_findings_tables(findings, chunk_size=300)
assert len(flowables) == 1
assert isinstance(flowables[0], (Table, LongTable))
def test_malformed_finding_is_skipped(self):
"""A broken finding must not abort the report; it is logged and skipped."""
generator = _make_concrete_generator()
class _Broken:
# No attributes at all; getattr() defaults will mostly cope, but
# we force an explicit error by making the metadata attribute
# itself raise on access.
@property
def metadata(self):
raise RuntimeError("boom")
check_id = "broken"
findings = [
_DummyFinding(check_id="c4"),
_Broken(),
_DummyFinding(check_id="c4"),
]
flowables = generator._create_findings_tables(findings, chunk_size=300)
# Two good rows → one sub-table containing them; the broken one is
# logged and dropped, not propagated.
tables = [f for f in flowables if isinstance(f, (Table, LongTable))]
assert len(tables) == 1
def test_create_findings_table_alias_returns_first_chunk(self):
"""The deprecated alias must keep returning a single Table flowable."""
generator = _make_concrete_generator()
findings = [_DummyFinding(check_id="c5") for _ in range(700)]
first = generator._create_findings_table(findings)
assert isinstance(first, (Table, LongTable))
def test_create_findings_table_alias_empty(self):
"""Alias on empty input returns an empty (header-only) Table, not None."""
generator = _make_concrete_generator()
result = generator._create_findings_table([])
# The legacy alias never returned None; an empty header-only table
# is a strict superset of that contract.
assert isinstance(result, (Table, LongTable))
# =============================================================================
# Logging Context Manager Tests (PROWLER-1733)
# =============================================================================
class TestLogPhaseContextManager:
"""Tests for ``_log_phase`` (PROWLER-1733).
The context manager emits structured ``phase_start`` / ``phase_end``
logs with ``scan_id``, ``framework`` and ``elapsed_s``, so Datadog/
CloudWatch queries can pivot by scan and find the slow section.
"""
def test_emits_start_and_end_with_elapsed_and_rss(self, caplog):
from tasks.jobs.reports.base import _log_phase
caplog.set_level("INFO", logger="tasks.jobs.reports.base")
with _log_phase("unit_test_phase", scan_id="s-1", framework="Test FW"):
pass
messages = [r.getMessage() for r in caplog.records]
starts = [m for m in messages if "phase_start" in m]
ends = [m for m in messages if "phase_end" in m]
assert len(starts) == 1 and len(ends) == 1
assert "phase=unit_test_phase" in starts[0]
assert "scan_id=s-1" in starts[0]
assert "framework=Test FW" in starts[0]
assert "elapsed_s=" in ends[0]
assert "rss_kb=" in ends[0]
assert "delta_rss_kb=" in ends[0]
def test_failure_logs_phase_failed_and_reraises(self, caplog):
from tasks.jobs.reports.base import _log_phase
caplog.set_level("INFO", logger="tasks.jobs.reports.base")
with pytest.raises(RuntimeError, match="boom"):
with _log_phase("failing_phase", scan_id="s-2", framework="FW"):
raise RuntimeError("boom")
messages = [r.getMessage() for r in caplog.records]
assert any("phase_failed" in m and "failing_phase" in m for m in messages)
# No phase_end on the failure path.
assert not any("phase_end" in m for m in messages)
+52
View File
@@ -21,6 +21,7 @@ from tasks.tasks import (
check_lighthouse_provider_connection_task,
generate_outputs_task,
perform_attack_paths_scan_task,
perform_scan_task,
perform_scheduled_scan_task,
reaggregate_all_finding_group_summaries_task,
refresh_lighthouse_provider_models_task,
@@ -2454,6 +2455,57 @@ class TestPerformScheduledScanTask:
== 1
)
def test_no_op_when_provider_does_not_exist(self, tenants_fixture):
"""Return None without raising when the provider was already deleted."""
tenant = tenants_fixture[0]
missing_provider_id = str(uuid.uuid4())
task_id = str(uuid.uuid4())
self._create_task_result(tenant.id, task_id)
# Orphan PeriodicTask left behind from a previous lifecycle.
self._create_periodic_task(missing_provider_id, tenant.id)
orphan_name = f"scan-perform-scheduled-{missing_provider_id}"
assert PeriodicTask.objects.filter(name=orphan_name).exists()
with (
patch("tasks.tasks.perform_prowler_scan") as mock_scan,
patch("tasks.tasks._perform_scan_complete_tasks") as mock_complete_tasks,
self._override_task_request(perform_scheduled_scan_task, id=task_id),
):
result = perform_scheduled_scan_task.run(
tenant_id=str(tenant.id), provider_id=missing_provider_id
)
assert result is None
mock_scan.assert_not_called()
mock_complete_tasks.assert_not_called()
# Orphan PeriodicTask is cleaned up so beat stops re-firing it.
assert not PeriodicTask.objects.filter(name=orphan_name).exists()
@pytest.mark.django_db
class TestPerformScanTask:
"""Unit tests for perform_scan_task."""
def test_no_op_when_provider_does_not_exist(self, tenants_fixture):
"""Return None without raising when the provider was already deleted."""
tenant = tenants_fixture[0]
missing_provider_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
with (
patch("tasks.tasks.perform_prowler_scan") as mock_scan,
patch("tasks.tasks._perform_scan_complete_tasks") as mock_complete_tasks,
):
result = perform_scan_task.run(
tenant_id=str(tenant.id),
scan_id=scan_id,
provider_id=missing_provider_id,
)
assert result is None
mock_scan.assert_not_called()
mock_complete_tasks.assert_not_called()
@pytest.mark.django_db
class TestReaggregateAllFindingGroupSummaries:
Generated
+5992
View File
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,9 @@
#!/bin/bash
# Run Prowler against All AWS Accounts in an AWS Organization
# Activate Poetry Environment
eval "$(poetry env activate)"
# Activate uv-managed virtualenv
# shellcheck disable=SC1091
source .venv/bin/activate
# Show Prowler Version
prowler -v
+1 -1
View File
@@ -73,7 +73,7 @@ secrets:
DJANGO_SECRETS_ENCRYPTION_KEY:
DJANGO_BROKER_VISIBILITY_TIMEOUT: 86400
releaseConfigRoot: /home/prowler/.cache/pypoetry/virtualenvs/prowler-api-NnJNioq7-py3.12/lib/python3.12/site-packages/
releaseConfigRoot: /home/prowler/.venv/lib/python3.12/site-packages/
releaseConfigPath: prowler/config/config.yaml
mainConfig:
+7 -1
View File
@@ -48,10 +48,16 @@ services:
- path: .env
required: false
ports:
- ${UI_PORT:-3000}:${UI_PORT:-3000}
- ${UI_PORT:-3000}:3000
depends_on:
mcp-server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 60s
postgres:
image: postgres:16.3-alpine3.20
+1 -1
View File
@@ -467,7 +467,7 @@ Effective headers and section titles enhance document readability and structure,
* **Example:**
* How to Clone and Install Prowler from GitHub (header: Title case)
* How to install poetry dependencies (subheading: Sentence case)
* How to install uv dependencies (subheading: Sentence case)
5. **Using Keywords in Headers**
Headers should include relevant keywords to improve document searchability:
* **Good:** Scanning AWS Accounts in Parallel
+2 -2
View File
@@ -10,10 +10,10 @@ This repository contains the Prowler Open Source documentation powered by [Mintl
## Local Development
Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview documentation changes locally:
Install a reviewed version of the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview documentation changes locally:
```bash
npm i -g mint
npm install --global mint@4.2.560
```
Run the following command at the root of your documentation (where `mint.json` is located):
+2 -2
View File
@@ -20,8 +20,8 @@ The most common high level steps to create a new check are:
3. Create a check-specific folder. The path should follow this pattern: `prowler/providers/<provider>/services/<service>/<check_name_want_to_implement>`. Adhere to the [Naming Format for Checks](#naming-format-for-checks).
4. Populate the folder with files as specified in [File Creation](#file-creation).
5. Run the check locally to ensure it works as expected. For checking you can use the CLI in the next way:
- To ensure the check has been detected by Prowler: `poetry run python prowler-cli.py <provider> --list-checks | grep <check_name>`.
- To run the check, to find possible issues: `poetry run python prowler-cli.py <provider> --log-level ERROR --verbose --check <check_name>`.
- To ensure the check has been detected by Prowler: `uv run python prowler-cli.py <provider> --list-checks | grep <check_name>`.
- To run the check, to find possible issues: `uv run python prowler-cli.py <provider> --log-level ERROR --verbose --check <check_name>`.
6. Create comprehensive tests for the check that cover multiple scenarios including both PASS (compliant) and FAIL (non-compliant) cases. For detailed information about test structure and implementation guidelines, refer to the [Testing](/developer-guide/unit-testing) documentation.
7. If the check and its corresponding tests are working as expected, you can submit a PR to Prowler.
+1 -1
View File
@@ -28,7 +28,7 @@ This includes the [AGENTS.md](https://github.com/prowler-cloud/prowler/blob/mast
<Steps>
<Step title="Install Mintlify CLI">
```bash
npm i -g mint
npm install --global mint@4.2.560
```
For detailed instructions, check the [Mintlify documentation](https://www.mintlify.com/docs/installation).
</Step>
+10 -17
View File
@@ -80,7 +80,7 @@ Before proceeding, ensure the following:
- Git is installed.
- Python 3.10 or higher is installed.
- `poetry` is installed to manage dependencies.
- `uv` is installed to manage dependencies.
### Forking the Prowler Repository
@@ -97,28 +97,21 @@ cd prowler
### Dependency Management and Environment Isolation
To prevent conflicts between environments, we recommend using `poetry`, a Python dependency management solution. Install it by following the [instructions](https://python-poetry.org/docs/#installation).
To prevent conflicts between environments, we recommend using [`uv`](https://docs.astral.sh/uv/), a fast Python package and project manager. Install it by following the [official instructions](https://docs.astral.sh/uv/getting-started/installation/).
### Installing Dependencies
To install all required dependencies, including those needed for development, run:
```
poetry install --with dev
eval $(poetry env activate)
uv sync
source .venv/bin/activate
```
<Warning>
Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`.
If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment.
In case you have any doubts, consult the [Poetry environment activation guide](https://python-poetry.org/docs/managing-environments/#activating-the-environment).
</Warning>
### Pre-Commit Hooks
This repository uses Git pre-commit hooks managed by the [prek](https://prek.j178.dev/) tool, it is installed with `poetry install --with dev`. Next, run the following command in the root of this repository:
This repository uses Git pre-commit hooks managed by the [prek](https://prek.j178.dev/) tool, it is installed with `uv sync`. Next, run the following command in the root of this repository:
```shell
prek install
@@ -155,11 +148,11 @@ Once installed, TruffleHog runs before each push and blocks the operation when v
Before merging pull requests, several automated checks and utilities ensure code security and updated dependencies:
<Note>
These should have been already installed if `poetry install --with dev` was already run.
These should have been already installed if `uv sync` was already run.
</Note>
- [`bandit`](https://pypi.org/project/bandit/) for code security review.
- [`safety`](https://pypi.org/project/safety/) and [`dependabot`](https://github.com/features/security) for dependencies.
- [`osv-scanner`](https://github.com/google/osv-scanner) and [`dependabot`](https://github.com/features/security) for dependencies.
- [`hadolint`](https://github.com/hadolint/hadolint) and [`dockle`](https://github.com/goodwithtech/dockle) for container security.
- [`Snyk`](https://docs.snyk.io/integrations/snyk-container-integrations/container-security-with-docker-hub-integration) for container security in Docker Hub.
- [`clair`](https://github.com/quay/clair) for container security in Amazon ECR.
@@ -183,7 +176,7 @@ These resources help ensure that AI-assisted contributions maintain consistency
All dependencies are listed in the `pyproject.toml` file.
The SDK keeps direct dependencies pinned to exact versions, while `poetry.lock` records the full resolved dependency tree and the artifact hashes for every package. Use `poetry install` from the lock file instead of ad-hoc `pip` installs when you need a reproducible environment.
The SDK keeps direct dependencies pinned to exact versions, while `uv.lock` records the full resolved dependency tree and the artifact hashes for every package. Use `uv sync` from the lock file instead of ad-hoc `pip` installs when you need a reproducible environment.
For proper code documentation, refer to the following and follow the code documentation practices presented there: [Google Python Style Guide - Comments and Docstrings](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings).
@@ -209,8 +202,8 @@ prowler/
├── contrib/ # Community-contributed scripts or modules
├── kubernetes/ # Kubernetes deployment files
├── .github/ # GitHub-related files (workflows, issue templates, etc.)
├── pyproject.toml # Python project configuration (Poetry)
├── poetry.lock # Poetry lock file
├── pyproject.toml # Python project configuration (uv)
├── uv.lock # uv lock file
├── README.md # Project overview and getting started
├── Makefile # Common development commands
├── Dockerfile # SDK Docker container
+6 -4
View File
@@ -1277,10 +1277,12 @@ Dependencies ensure that your provider's required libraries are available when P
**File:** `pyproject.toml`
```toml
[tool.poetry.dependencies]
python = ">=3.10,<3.13"
# ... other dependencies
your-sdk-library = "^1.0.0" # Add your SDK dependency
[project]
requires-python = ">=3.10,<3.13"
dependencies = [
# ... other dependencies
"your-sdk-library>=1.0.0,<2.0.0", # Add your SDK dependency
]
```
#### Step 18: Create Tests
@@ -228,7 +228,7 @@ Each requirement links to the Prowler checks that, together, produce a PASS or F
To discover available checks, run:
```bash
poetry run python prowler-cli.py <provider> --list-checks
uv run python prowler-cli.py <provider> --list-checks
```
## Supporting Multiple Providers
@@ -295,7 +295,7 @@ Follow the steps below before opening a pull request.
### 1. Run the Compliance Model Validator
```bash
poetry run python prowler-cli.py <provider> --list-compliance
uv run python prowler-cli.py <provider> --list-compliance
```
The framework must appear in the output. A validation error indicates a schema mismatch between the JSON file and the attribute model.
@@ -303,7 +303,7 @@ The framework must appear in the output. A validation error indicates a schema m
### 2. Run a Scan Filtered by the Framework
```bash
poetry run python prowler-cli.py <provider> \
uv run python prowler-cli.py <provider> \
--compliance <framework>_<version>_<provider> \
--log-level ERROR
```
@@ -336,7 +336,7 @@ Compliance contributions require two layers of tests.
Run the suite with:
```bash
poetry run pytest -n auto tests/lib/check/universal_compliance_models_test.py \
uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \
tests/lib/outputs/compliance/
```
@@ -348,8 +348,8 @@ Before opening the pull request:
1. Run the complete QA pipeline:
```bash
poetry run pre-commit run --all-files
poetry run pytest -n auto
uv run pre-commit run --all-files
uv run pytest -n auto
```
2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers.
3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, for example `feat(compliance): add My Framework 1.0 for AWS`.
+1 -1
View File
@@ -23,7 +23,7 @@ Within this folder the following files are also to be created:
- `<new_service_name>_service.py` Contains all the logic and API calls of the service.
- `<new_service_name>_client_.py` Contains the initialization of the freshly created service's class so that the checks can use it.
Once the files are create, you can check that the service has been created by running the following command: `poetry run python prowler-cli.py <provider> --list-services | grep <new_service_name>`.
Once the files are create, you can check that the service has been created by running the following command: `uv run python prowler-cli.py <provider> --list-services | grep <new_service_name>`.
## Service Structure and Initialisation
+2 -1
View File
@@ -353,7 +353,8 @@
"group": "Cookbooks",
"pages": [
"user-guide/cookbooks/kubernetes-in-cluster",
"user-guide/cookbooks/cicd-pipeline"
"user-guide/cookbooks/cicd-pipeline",
"user-guide/cookbooks/powerbi-cis-benchmarks"
]
}
]
@@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|----------|------------|------------------------|
| Prowler Hub | 10 tools | No |
| Prowler Documentation | 2 tools | No |
| Prowler Cloud/App | 29 tools | Yes |
| Prowler Cloud/App | 32 tools | Yes |
## Tool Naming Convention
@@ -36,6 +36,14 @@ Tools for searching, viewing, and analyzing security findings across all cloud p
- **`prowler_app_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
- **`prowler_app_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
### Finding Groups Management
Tools for listing finding groups aggregated by check ID, viewing complete group counters, and drilling down into affected resources.
- **`prowler_app_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
- **`prowler_app_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
- **`prowler_app_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
### Provider Management
Tools for managing cloud provider connections in Prowler.
@@ -44,13 +44,21 @@ Choose the configuration based on your deployment:
<Tab title="Generic without Native HTTP Support">
**Configuration:**
<Warning>
Avoid configuring MCP clients to run `npx mcp-remote` directly. `npx` can download and execute a new package version on each run. Install a reviewed version of `mcp-remote` in a dedicated local workspace, then point the MCP client to the installed binary.
</Warning>
```bash
mkdir -p ~/.local/share/prowler-mcp-bridge
cd ~/.local/share/prowler-mcp-bridge
npm init -y
npm install --save-exact mcp-remote@0.1.38
```
```json
{
"mcpServers": {
"prowler": {
"command": "npx",
"command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
"args": [
"mcp-remote",
"https://mcp.prowler.com/mcp", // or your self-hosted Prowler MCP Server URL
"--header",
"Authorization: Bearer ${PROWLER_APP_API_KEY}"
@@ -72,14 +80,20 @@ Choose the configuration based on your deployment:
2. Go to "Developer" tab
3. Click in "Edit Config" button
4. Edit the `claude_desktop_config.json` file with your favorite editor
5. Add the following configuration:
5. Install a reviewed version of `mcp-remote` in a dedicated local workspace:
```bash
mkdir -p ~/.local/share/prowler-mcp-bridge
cd ~/.local/share/prowler-mcp-bridge
npm init -y
npm install --save-exact mcp-remote@0.1.38
```
6. Add the following configuration:
```json
{
"mcpServers": {
"prowler": {
"command": "npx",
"command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
"args": [
"mcp-remote",
"https://mcp.prowler.com/mcp",
"--header",
"Authorization: Bearer ${PROWLER_APP_API_KEY}"
@@ -37,8 +37,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
_Requirements_:
- `git` installed.
- `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation).
- `npm` installed: [npm installation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).
- `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/).
- `pnpm` installed through [Corepack](https://pnpm.io/installation#using-corepack) or the standalone [pnpm installation](https://pnpm.io/installation).
- `Docker Compose` installed: https://docs.docker.com/compose/install/.
<Warning>
@@ -49,8 +49,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
```bash
git clone https://github.com/prowler-cloud/prowler \
cd prowler/api \
poetry install \
eval $(poetry env activate) \
uv sync \
source .venv/bin/activate \
set -a \
source .env \
docker compose up postgres valkey -d \
@@ -59,11 +59,6 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
gunicorn -c config/guniconf.py config.wsgi:application
```
<Warning>
Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`.
If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment. In case you have any doubts, consult the Poetry environment activation guide: https://python-poetry.org/docs/managing-environments/#activating-the-environment
</Warning>
> Now, you can access the API documentation at http://localhost:8080/api/v1/docs.
_Commands to run the API Worker_:
@@ -71,8 +66,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
```bash
git clone https://github.com/prowler-cloud/prowler \
cd prowler/api \
poetry install \
eval $(poetry env activate) \
uv sync \
source .venv/bin/activate \
set -a \
source .env \
cd src/backend \
@@ -84,8 +79,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
```bash
git clone https://github.com/prowler-cloud/prowler \
cd prowler/api \
poetry install \
eval $(poetry env activate) \
uv sync \
source .venv/bin/activate \
set -a \
source .env \
cd src/backend \
@@ -97,9 +92,11 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
```bash
git clone https://github.com/prowler-cloud/prowler \
cd prowler/ui \
npm install \
npm run build \
npm start
corepack enable \
corepack install \
pnpm install --frozen-lockfile \
pnpm run build \
pnpm start
```
> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
@@ -68,7 +68,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
_Requirements for Developers_:
* `git`
* `poetry` installed: [poetry installation](https://python-poetry.org/docs/#installation).
* `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/).
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
@@ -76,8 +76,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
```bash
git clone https://github.com/prowler-cloud/prowler
cd prowler
poetry install
poetry run python prowler-cli.py -v
uv sync
uv run python prowler-cli.py -v
```
<Note>
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

+5 -4
View File
@@ -39,10 +39,11 @@ Dependencies are continuously monitored for known vulnerabilities with timely up
### Dependency Vulnerability Scanning
- **Safety:** Scans Python dependencies against known vulnerability databases
- Runs on every commit via pre-commit hooks
- Integrated into CI/CD for SDK and API
- Configured with selective ignores for tracked exceptions
- **osv-scanner:** Scans lockfiles against the [OSV.dev](https://osv.dev) vulnerability database
- Runs in CI on every pull request and push for SDK, API, and UI
- Fails the build on `HIGH`, `CRITICAL`, and `UNKNOWN` severity findings
- Posts a per-lockfile report as a PR comment
- Per-vulnerability ignores (with reason and expiry) live in `osv-scanner.toml` at the repo root
- **Trivy:** Multi-purpose scanner for containers and dependencies
- Scans all container images (UI, API, SDK, MCP Server)
- Checks for vulnerabilities in OS packages and application dependencies
@@ -18,9 +18,11 @@ prowler <provider> --scan-unused-services
#### ACM (AWS Certificate Manager)
Certificates stored in ACM without active usage in AWS resources are excluded. By default, Prowler only scans actively used certificates. Unused certificates will not be checked if they are expired, if their expiring date is near or if they are good.
Certificates stored in ACM without active usage in AWS resources are excluded. By default, Prowler only scans actively used certificates. Unused certificates are not evaluated for expiration, transparency logging, or weak key algorithms.
- `acm_certificates_expiration_check`
- `acm_certificates_transparency_logs_enabled`
- `acm_certificates_with_secure_key_algorithms`
#### Athena
@@ -28,6 +30,13 @@ Upon AWS account creation, Athena provisions a default primary workgroup for the
- `athena_workgroup_encryption`
- `athena_workgroup_enforce_configuration`
- `athena_workgroup_logging_enabled`
#### Amazon Bedrock
Generative AI workloads benefit from private VPC endpoint connectivity to keep prompt and model traffic off the public internet. Prowler only evaluates this configuration for VPCs in use (with active ENIs).
- `bedrock_vpc_endpoints_configured`
#### AWS CloudTrail
@@ -38,15 +47,23 @@ AWS CloudTrail should have at least one trail with a data event to record all S3
#### AWS Elastic Compute Cloud (EC2)
If Amazon Elastic Block Store (EBS) default encyption is not enabled, sensitive data at rest will remain unprotected in EC2. However, Prowler will only generate a finding if EBS volumes exist where default encryption could be enforced.
If Amazon Elastic Block Store (EBS) default encryption is not enabled, sensitive data at rest remains unprotected in EC2. Prowler only generates a finding if EBS volumes exist where default encryption could be enforced.
- `ec2_ebs_default_encryption`
**EBS Snapshot Public Access**: Public EBS snapshots can leak data. Prowler only evaluates the account-level block setting if EBS snapshots exist in the account.
- `ec2_ebs_snapshot_account_block_public_access`
**EC2 Instance Metadata Service (IMDS)**: Enforcing IMDSv2 at the account level mitigates SSRF-based credential theft. Prowler only evaluates the account-level setting if EC2 instances exist in the account.
- `ec2_instance_account_imdsv2_enabled`
**Security Groups**: Misconfigured security groups increase the attack surface.
Prowler scans only attached security groups to report vulnerabilities in actively used configurations. Applies to:
- 15 security group-related checks, including open ports and ingress/egress traffic rules.
- 20 security group-related checks, including open ports and ingress/egress traffic rules.
- `ec2_securitygroup_allow_ingress_from_internet_to_port_X`
- `ec2_securitygroup_default_restrict_traffic`
@@ -56,6 +73,18 @@ Prowler scans only attached security groups to report vulnerabilities in activel
- `ec2_networkacl_allow_ingress_X_port`
#### AWS Identity and Access Management (IAM)
Customer-managed IAM policies that are not attached to any user, group, or role grant no effective permissions until a principal is bound to them. Prowler treats such policies as dormant by default and skips the content-evaluation checks below when `--scan-unused-services` is not set. Enable the flag to surface findings on unattached policies as well.
- `iam_policy_allows_privilege_escalation`
- `iam_policy_no_full_access_to_cloudtrail`
- `iam_policy_no_full_access_to_kms`
- `iam_policy_no_wildcard_marketplace_subscribe`
- `iam_no_custom_policy_permissive_role_assumption`
The dedicated `iam_customer_unattached_policy_no_administrative_privileges` check still inspects unattached policies regardless of the flag, since its purpose is to highlight dormant administrator privileges.
#### AWS Glue
AWS Glue best practices recommend encrypting metadata and connection passwords in Data Catalogs.
@@ -71,6 +100,12 @@ Amazon Inspector is a vulnerability discovery service that automates continuous
- `inspector2_is_enabled`
#### AWS Key Management Service (KMS)
Customer managed Customer Master Keys (CMKs) in the `Disabled` state cannot be used for cryptographic operations, so Prowler skips the unintentional-deletion check on them by default. Enable the flag to evaluate disabled CMKs as well.
- `kms_cmk_not_deleted_unintentionally`
#### Amazon Macie
Amazon Macie leverages machine learning to automatically discover, classify, and protect sensitive data in S3 buckets. Prowler only generates findings if Macie is disabled and there are S3 buckets in the AWS account.
@@ -83,6 +118,15 @@ A network firewall is essential for monitoring and controlling traffic within a
- `networkfirewall_in_all_vpc`
#### Amazon Relational Database Service (RDS)
RDS event subscriptions notify operators of critical database events. Prowler only evaluates these subscription checks when RDS clusters or instances exist in the account.
- `rds_cluster_critical_event_subscription`
- `rds_instance_critical_event_subscription`
- `rds_instance_event_subscription_parameter_groups`
- `rds_instance_event_subscription_security_groups`
#### Amazon S3
To prevent unintended data exposure:
@@ -99,6 +143,10 @@ VPC settings directly impact network security and availability.
- `vpc_flow_logs_enabled`
- VPC Endpoint for EC2: Routes EC2 API calls through a private VPC endpoint to keep traffic off the public internet. Prowler only evaluates this configuration for VPCs in use, i.e., those with active ENIs.
- `vpc_endpoint_for_ec2_enabled`
- VPC Subnet Public IP Restrictions: Prevent unintended exposure of resources to the internet. Prowler only checks this configuration for VPCs in use, i.e., those with active ENIs.
- `vpc_subnet_no_public_ip_by_default`
@@ -149,6 +149,14 @@ Prowler Cloud and App expose two formats:
* **CSV report:** Every requirement, every check, and every finding for the selected scan and filters. Available for all supported frameworks.
* **PDF report:** Curated executive-style report. Currently supported for Prowler ThreatScore, ENS RD2022, NIS2, and CSA CCM. Additional PDF reports are added in subsequent Prowler releases.
<Note>
**PDF detail section is capped at the first 100 failed findings per check.** The PDF is intended as an executive/auditor document, not a raw data dump: when a check produces more than 100 failed findings the report renders the first 100 and shows a banner pointing the reader to the CSV or JSON-OCSF export for the complete list. The compliance CSV and the scan outputs are never truncated.
The cap is configurable per deployment via the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` environment variable on the Prowler API workers; set it to `0` to disable truncation entirely. The default value of `100` keeps the PDF readable and bounded in size on enterprise-scale scans (hundreds of thousands of findings) without affecting smaller scans, where the cap is rarely reached.
Only **failed** findings are rendered in the detail section. PASS findings for the same check are excluded at query time. The PDF surfaces what needs attention, and the CSV/JSON exports surface everything for forensic review.
</Note>
#### Downloading From the Detail Page
Inside any framework detail page, the **CSV** and **PDF** buttons in the header trigger the same downloads as the overview dropdown. The PDF button only appears for frameworks that support it.
@@ -0,0 +1,168 @@
---
title: "Visualize Multi-Cloud CIS Benchmarks With Power BI"
description: "Ingest Prowler compliance CSV exports into a ready-made Microsoft Power BI template that surfaces CIS Benchmark posture across AWS, Azure, Google Cloud, and Kubernetes."
---
The Multi-Cloud CIS Benchmarks Power BI template turns Prowler compliance CSV exports into an interactive dashboard. The template ingests scan results from Prowler CLI or Prowler Cloud and renders cross-provider CIS Benchmark coverage, profile-level breakdowns, regional drill-downs, and time-series trends. Center for Internet Security (CIS) Benchmarks are industry-standard configuration baselines maintained by CIS.
The template and its source files live in the Prowler repository under [`contrib/PowerBI/Multicloud CIS Benchmarks`](https://github.com/prowler-cloud/prowler/tree/master/contrib/PowerBI/Multicloud%20CIS%20Benchmarks).
<img src="/images/powerbi/report-cover.png" alt="Multi-Cloud CIS Benchmarks Power BI report cover showing aggregated compliance posture across providers" width="900" />
## Prerequisites
The setup requires the following components:
* **Microsoft Power BI Desktop:** free download from Microsoft.
* **Prowler compliance CSV exports:** produced by Prowler CLI or downloaded from Prowler Cloud or Prowler App.
* **Local directory:** holds the CSV exports that the template ingests at load time.
## Supported CIS Benchmarks
The template ships with predefined mappings for the following CIS Benchmark versions. Exports must match these versions for the dashboard to populate correctly:
| Compliance Framework | Version |
| ---------------------------------------------- | -------- |
| CIS Amazon Web Services Foundations Benchmark | v6.0 |
| CIS Microsoft Azure Foundations Benchmark | v5.0 |
| CIS Google Cloud Platform Foundation Benchmark | v4.0 |
| CIS Kubernetes Benchmark | v1.12.0 |
<Warning>
Other CIS Benchmark versions are not recognized by the template. Confirm the framework version before running the scan or downloading the export.
</Warning>
## Setup
### Step 1: Install Microsoft Power BI Desktop
Download and install Microsoft Power BI Desktop from the official Microsoft site. The template is opened with this application.
### Step 2: Generate Compliance CSV Exports
Compliance CSV exports can be generated through Prowler CLI or downloaded from Prowler Cloud and Prowler App.
#### Option A: Prowler CLI
Run a scan with the `--compliance` flag pointing to the appropriate CIS framework, for example:
```sh
prowler aws --compliance cis_6.0_aws
prowler azure --compliance cis_5.0_azure
prowler gcp --compliance cis_4.0_gcp
prowler kubernetes --compliance cis_1.12_kubernetes
```
The compliance CSV exports are written to `output/compliance/` by default.
#### Option B: Prowler Cloud or Prowler App
Open the Compliance section, select the desired CIS Benchmark, and download the CSV export.
<img src="/images/powerbi/download-compliance-scan.png" alt="Compliance section in Prowler Cloud showing the CSV download option for a CIS Benchmark scan" width="900" />
### Step 3: Create a Local Directory for the Exports
Place every CSV export in a single local directory. The template parses filenames to detect the provider, so filenames must keep the provider keyword (`aws`, `azure`, `gcp`, or `kubernetes`).
<Note>
Time-series visualizations such as "Compliance Percent Over Time" require multiple scans from different dates in the same directory.
</Note>
### Step 4: Open the Power BI Template
Download the template file [`Prowler Multicloud CIS Benchmarks.pbit`](https://github.com/prowler-cloud/prowler/raw/master/contrib/PowerBI/Multicloud%20CIS%20Benchmarks/Prowler%20Multicloud%20CIS%20Benchmarks.pbit) and open it. Power BI Desktop prompts for the full filepath to the directory created in step 3.
### Step 5: Provide the Directory Filepath
Enter the absolute filepath without quotation marks. The Windows "copy as path" feature wraps the path in quotation marks automatically; remove them before submitting.
### Step 6: Save the Report as a `.pbix` File
Once the filepath is submitted, the template ingests the CSV exports and renders the report. Save the populated report as a `.pbix` file for future use. Re-running the `.pbit` template generates a fresh report against an updated directory.
## Validation
To confirm the CSV exports were ingested correctly, open the "Configuration" tab inside the report.
<img src="/images/powerbi/validation.png" alt="Configuration tab in the Power BI report displaying loaded CIS Benchmarks, the Prowler CSV folder path, and the list of ingested exports" width="900" />
The "Configuration" tab exposes three tables:
* **Loaded CIS Benchmarks:** lists the benchmarks and versions supported by the template. This table is defined by the template itself and is not editable. All benchmarks remain listed regardless of which provider exports were supplied.
* **Prowler CSV Folder:** displays the absolute path provided during template load.
* **Loaded Prowler Exports:** lists every CSV file detected in the directory. A green checkmark identifies the file used as the latest assessment for each provider and benchmark combination.
## Report Sections
The report is organized into three navigable pages:
| Report Page | Purpose |
| ----------- | ------------------------------------------------------------------------------------ |
| Overview | Aggregates CIS Benchmark posture across AWS, Azure, Google Cloud, and Kubernetes. |
| Benchmark | Focuses on a single CIS Benchmark with profile-level and regional filters. |
| Requirement | Drill-through page that surfaces details for a single benchmark requirement. |
### Overview Page
The Overview page summarizes CIS Benchmark posture across every supported provider.
<img src="/images/powerbi/overview-page.png" alt="Overview page in the Power BI report aggregating CIS Benchmark posture across AWS, Azure, Google Cloud, and Kubernetes" width="900" />
The Overview page contains the following components:
| Component | Description |
| ---------------------------------------- | ---------------------------------------------------------------------------- |
| CIS Benchmark Overview | Table listing benchmark name, version, and overall compliance percentage. |
| Provider by Requirement Status | Bar chart breaking down requirements by status and provider. |
| Compliance Percent Heatmap | Heatmap of compliance percentage by benchmark and profile level. |
| Profile Level by Requirement Status | Bar chart breaking down requirements by status and profile level. |
| Compliance Percent Over Time by Provider | Line chart tracking overall compliance percentage over time by provider. |
### Benchmark Page
The Benchmark page focuses on a single CIS Benchmark. The benchmark, profile level, and region can be selected through dropdown filters.
<img src="/images/powerbi/benchmark-page.png" alt="Benchmark page in the Power BI report showing region heatmap, section breakdown, time-series trend, and the requirements table" width="900" />
The Benchmark page contains the following components:
| Component | Description |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Compliance Percent Heatmap | Heatmap of compliance percentage by region and profile level. |
| Benchmark Section by Requirement Status | Bar chart of requirements grouped by benchmark section and status. |
| Compliance Percent Over Time by Region | Line chart tracking compliance percentage over time by region. |
| Benchmark Requirements | Table listing requirement section, requirement number, requirement title, number of resources tested, status, and failing checks. |
### Requirement Page
The Requirement page is a drill-through view that exposes the full context of a single requirement. To populate the page, right-click a row in the "Benchmark Requirements" table on the Benchmark page and select "Drill through" > "Requirement".
<img src="/images/powerbi/requirement-page.png" alt="Requirement drill-through page in the Power BI report showing rationale, remediation, regional breakdown, and the resource-level check results" width="900" />
The Requirement page contains the following components:
| Component | Description |
| ------------------------------------------ | -------------------------------------------------------------------------------------------- |
| Title | Requirement title. |
| Rationale | Rationale for the requirement. |
| Remediation | Remediation guidance for the requirement. |
| Region by Check Status | Bar chart of Prowler check results grouped by region and status. |
| Resource Checks for Benchmark Requirements | Table listing resource ID, resource name, status, description, and the underlying Prowler check. |
## Walkthrough Video
A full walkthrough is available on YouTube:
[![Multi-Cloud CIS Benchmarks Power BI walkthrough video thumbnail](/images/powerbi/walkthrough-video-thumb.png)](https://www.youtube.com/watch?v=lfKFkTqBxjU)
## Related Resources
<CardGroup cols={2}>
<Card title="Compliance Frameworks" icon="shield-check" href="/user-guide/compliance/tutorials/compliance">
Review the Compliance workflow across Prowler Cloud, Prowler App, and Prowler CLI.
</Card>
<Card title="Prowler Dashboard" icon="chart-line" href="/user-guide/cli/tutorials/dashboard">
Explore the built-in local dashboard for Prowler CSV exports.
</Card>
</CardGroup>
+5 -13
View File
@@ -27,7 +27,7 @@ To download results from AWS CloudShell:
## Cloning Prowler from GitHub
Due to the limited storage in AWS CloudShell's home directory, installing Poetry dependencies for running Prowler from GitHub can be problematic.
Due to the limited storage in AWS CloudShell's home directory, installing uv dependencies for running Prowler from GitHub can be problematic.
The following workaround ensures successful installation:
@@ -37,17 +37,9 @@ adduser prowler
su prowler
git clone https://github.com/prowler-cloud/prowler.git
cd prowler
pip install poetry
mkdir /tmp/poetry
poetry config cache-dir /tmp/poetry
eval $(poetry env activate)
poetry install
pip install uv
mkdir /tmp/uv-cache
UV_CACHE_DIR=/tmp/uv-cache uv sync
source .venv/bin/activate
python prowler-cli.py -v
```
<Warning>
Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`.
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.
</Warning>
@@ -44,6 +44,15 @@ User API Tokens are the recommended authentication method because they:
Create a **User API Token**, not an Account API Token. User API Tokens are created from the profile settings and offer finer permission control.
</Note>
**Quick Setup:** Use these pre-configured links to open the Cloudflare Dashboard with the required permissions already selected:
- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a **User API Token** (recommended). Opens the **Create Custom Token** form prefilled with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner`. Adjust **Account Resources** and **Zone Resources** to match the accounts and zones you want to scan, then click **Create Token**.
- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/) instead. Use this for automation or CI/CD where the token should not depend on a specific user account remaining active. Requires the **Super Administrator** or **Administrator** role on the account.
<Note>
Template URLs only pre-fill the token creation form. Review the permissions, configure resources, and click **Create Token** to complete the process.
</Note>
### Step 1: Create a User API Token
1. Log into the [Cloudflare Dashboard](https://dash.cloudflare.com).
@@ -14,6 +14,15 @@ Set up authentication for Cloudflare with the [Cloudflare Authentication](/user-
- Grant the required read-only permissions (`Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`)
- Identify the Cloudflare Account ID to use as the provider identifier
<Note>
**Quick Setup:** Use these pre-configured links to create a token with the required permissions already selected:
- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a User API Token (recommended).
- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/), better suited for automation and CI/CD.
Both links open the Cloudflare Dashboard with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner` prefilled. See [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication#api-token-recommended) for the equivalent manual steps.
</Note>
<CardGroup cols={2}>
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
Onboard Cloudflare using Prowler Cloud
@@ -153,8 +153,8 @@ Before running Prowler CLI for GitHub, ensure you have:
# Install via pip
pip install prowler
# Or via poetry
poetry install
# Or via uv (from the cloned repo)
uv sync
```
2. **Authentication Credentials**
@@ -18,7 +18,7 @@ Prowler requests the following read-only OAuth 2.0 scopes:
| `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/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar, Gmail, Chat, and Drive service checks) |
| `https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly` | Read access to admin roles and role assignments |
<Warning>
@@ -40,7 +40,7 @@ In the [Google Cloud Console](https://console.cloud.google.com), select the targ
| API | Required For |
|-----|--------------|
| **Admin SDK API** | Directory service checks (users, roles, domains) |
| **Cloud Identity API** | Calendar service checks (domain-level sharing and invitation policies) |
| **Cloud Identity API** | Calendar, Gmail, Chat, and Drive service checks (domain-level application policies) |
For each API:
@@ -49,7 +49,7 @@ For each API:
3. Click **Enable**
<Note>
Both APIs must be enabled in the same GCP project that hosts the Service Account. Calendar checks will return no findings if the Cloud Identity API is not enabled.
Both APIs must be enabled in the same GCP project that hosts the Service Account. Calendar, Gmail, Chat, and Drive checks will return no findings if the Cloud Identity API is not enabled.
</Note>
### Step 3: Create a Service Account
@@ -176,9 +176,9 @@ If Prowler connects but returns empty results or permission errors for specific
- Verify all scopes are authorized in the Admin Console
- Ensure the delegated user is an active super administrator
### Calendar Checks Return No Findings
### Policy API Checks Return No Findings
If the Directory checks run successfully but the Calendar checks (e.g., `calendar_external_sharing_primary_calendar`) return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify:
If the Directory checks run successfully but the Calendar, Gmail, Chat, or Drive checks return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify:
- The **Cloud Identity API** is enabled in the GCP project hosting the Service Account (Step 2)
- The scope `https://www.googleapis.com/auth/cloud-identity.policies.readonly` is included in the Domain-Wide Delegation OAuth scopes list in the Admin Console (Step 5)
@@ -22,7 +22,7 @@ Install promptfoo using one of the following methods:
**Using npm:**
```bash
npm install -g promptfoo
npm install --global promptfoo@0.121.11
```
**Using Homebrew (macOS):**
@@ -46,7 +46,7 @@ Before you begin, ensure you have:
```bash
pip install prowler
# or for development:
poetry install
uv sync
```
2. **OCI Python SDK** (automatically installed with Prowler):
@@ -133,7 +133,7 @@ export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
# Optional — defaults to "okta.policies.read"
export OKTA_SCOPES="okta.policies.read"
poetry run python prowler-cli.py okta
uv run python prowler-cli.py okta
```
### Non-Secret CLI Flags
@@ -149,7 +149,7 @@ Non-secret values are also available as CLI flags for ergonomic overrides:
Run a single check directly:
```bash
poetry run python prowler-cli.py okta --check signon_global_session_idle_timeout_15min
uv run python prowler-cli.py okta --check signon_global_session_idle_timeout_15min
```
## Troubleshooting
-115
View File
@@ -1,115 +0,0 @@
# Prowler Multicloud CIS Benchmarks PowerBI Template
![Prowler Report](https://github.com/user-attachments/assets/560f7f83-1616-4836-811a-16963223c72f)
## Getting Started
1. Install Microsoft PowerBI Desktop
This report requires the Microsoft PowerBI Desktop software which can be downloaded for free from Microsoft.
2. Run compliance scans in Prowler
The report uses compliance csv outputs from Prowler. Compliance scans be run using either [Prowler CLI](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli) or [Prowler Cloud/App](https://cloud.prowler.com/sign-in)
1. Prowler CLI -&gt; Run a Prowler scan using the --compliance option
2. Prowler Cloud/App -&gt; Navigate to the compliance section to download csv outputs
![Download Compliance Scan](https://github.com/user-attachments/assets/42c11a60-8ce8-4c60-a663-2371199c052b)
The template supports the following CIS Benchmarks only:
| Compliance Framework | Version |
| ---------------------------------------------- | ------- |
| CIS Amazon Web Services Foundations Benchmark | v4.0.1 |
| CIS Google Cloud Platform Foundation Benchmark | v3.0.0 |
| CIS Microsoft Azure Foundations Benchmark | v3.0.0 |
| CIS Kubernetes Benchmark | v1.10.0 |
Ensure you run or download the correct benchmark versions.
3. Create a local directory to store Prowler csvoutputs
Once downloaded, place your csv outputs in a directory on your local machine. If you rename the files, they must maintain the provider in the filename.
To use time-series capabilities such as "compliance percent over time" you'll need scans from multiple dates.
4. Download and run the PowerBI template file (.pbit)
Running the .pbit file will open PowerBI Desktop and prompt you for the full filepath to the local directory
5. Enter the full filepath to the directory created in step 3
Provide the full filepath from the root directory.
Ensure that the filepath is not wrapped in quotation marks (""). If you use Window's "copy as path" feature, it will automatically include quotation marks.
6. Save the report as a PowerBI file (.pbix)
Once the filepath is entered, the template will automatically ingest and populate the report. You can then save this file as a new PowerBI report. If you'd like to generate another report, simply re-run the template file (.pbit) from step 4.
## Validation
After setting up your dashboard, you may want to validate the Prowler csv files were ingested correctly. To do this, navigate to the "Configuration" tab.
The "loaded CIS Benchmarks" table shows the supported benchmarks and versions. This is defined by the template file and not editable by the user. All benchmarks will be loaded regardless of which providers you provided csv outputs for.
The "Prowler CSV Folder" shows the path to the local directory you provided.
The "Loaded Prowler Exports" table shows the ingested csv files from the local directory. It will mark files that are treated as the latest assessment with a green checkmark.
![Prowler Validation](https://github.com/user-attachments/assets/a543ca9b-6cbe-4ad1-b32a-d4ac2163d447)
## Report Sections
The PowerBI Report is broken into three main report pages
| Report Page | Description |
| ----------- | ----------------------------------------------------------------------------------- |
| Overview | Provides general CIS Benchmark overview across both AWS, Azure, GCP, and Kubernetes |
| Benchmark | Provides overview of a single CIS Benchmark |
| Requirement | Drill-through page to view details of a single requirement |
### Overview Page
The overview page is a general CIS Benchmark overview across both AWS, Azure, GCP, and Kubernetes.
![image](https://github.com/user-attachments/assets/94164fa9-36a4-4bb9-890d-e9a9a63a3e7d)
The page has the following components:
| Component | Description |
| ---------------------------------------- | ------------------------------------------------------------------------ |
| CIS Benchmark Overview | Table with benchmark name, Version, and overall compliance percentage |
| Provider by Requirement Status | Bar chart showing benchmark requirements by status by provider |
| Compliance Percent Heatmap | Heatmap showing compliance percent by benchmark and profile level |
| Profile level by Requirement Status | Bar chart showing requirements by status and profile level |
| Compliance Percent Over Time by Provider | Line chart showing overall compliance perecentage over time by provider. |
### Benchmark Page
The benchmark page provides an overview of a single CIS Benchmark. You can select the benchmark from the dropdown as well as scope down to specific profile levels or regions.
![image](https://github.com/user-attachments/assets/34498ee8-317b-4b81-b241-c561451d8def)
The page has the following components:
| Component | Description |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Compliance Percent Heatmap | Heatmap showing compliance percent by region and profile level |
| Benchmark Section by Requirement Status | Bar chart showing benchmark requirements by bennchmark section and status |
| Compliance percent Over Time by Region | Line chart showing overall compliance percentage over time by region |
| Benchmark Requirements | Table showing requirement section, requirement number, reuqirement title, number of resources tested, status, and number of failing checks |
### Requirement Page
The requirement page is a drill-through page to view details of a single requirement. To populate the requirement page right click on a requiement from the "Benchmark Requirements" table on the benchmark page and select "Drill through" -&gt; "Requirement".
![image](https://github.com/user-attachments/assets/5c9172d9-56fe-4514-b341-7e708863fad6)
The requirement page has the following components:
| Component | Description |
| ------------------------------------------ | --------------------------------------------------------------------------------- |
| Title | Title of the requirement |
| Rationale | Rationale of the requirement |
| Remediation | Remedation guidance for the requirement |
| Region by Check Status | Bar chart showing Prowler checks by region and status |
| Resource Checks for Benchmark Requirements | Table showing Resource ID, Resource Name, Status, Description, and Prowler Checkl |
## Walkthrough Video
[![image](https://github.com/user-attachments/assets/866642c6-43ac-4aac-83d3-bb625002da0b)](https://www.youtube.com/watch?v=lfKFkTqBxjU)
+4
View File
@@ -4,6 +4,10 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
## [0.7.0] (Prowler UNRELEASED)
### 🚀 Added
- MCP Server tools for Prowler Finding Groups Management [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140)
### 🔐 Security
- `cryptography` from 46.0.1 to 47.0.0 (transitive) for CVE-2026-39892 and CVE-2026-26007 / CVE-2026-34073 [(#10978)](https://github.com/prowler-cloud/prowler/pull/10978)
+11 -2
View File
@@ -10,6 +10,7 @@
Full access to Prowler Cloud platform and self-managed Prowler App for:
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
- **Finding Groups Analysis**: Triage findings grouped by check ID and drill down into affected resources
- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.)
- **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments
- **Resource Inventory**: Search and view detailed information about your audited resources
@@ -56,13 +57,21 @@ Prowler MCP Server can be used in three ways:
- Managed and maintained by Prowler team
- Always up-to-date
Install a reviewed version of `mcp-remote` in a dedicated local workspace first. Avoid running `npx mcp-remote` directly because it can download and execute a new package version on each run.
```bash
mkdir -p ~/.local/share/prowler-mcp-bridge
cd ~/.local/share/prowler-mcp-bridge
npm init -y
npm install --save-exact mcp-remote@0.1.38
```
```json
{
"mcpServers": {
"prowler": {
"command": "npx",
"command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
"args": [
"mcp-remote",
"https://mcp.prowler.com/mcp",
"--header",
"Authorization: Bearer pk_YOUR_API_KEY_HERE"
@@ -0,0 +1,300 @@
"""Pydantic models for Prowler Finding Groups responses."""
from typing import Literal
from pydantic import Field
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
FindingStatus = Literal["FAIL", "PASS", "MANUAL"]
FindingSeverity = Literal["critical", "high", "medium", "low", "informational"]
FindingDelta = Literal["new", "changed"]
def _attributes(data: dict) -> dict:
return data.get("attributes", {})
def _counter(attributes: dict, key: str) -> int:
return attributes.get(key) or 0
def _simplified_group_kwargs(data: dict) -> dict:
attributes = _attributes(data)
return {
"check_id": attributes.get("check_id", data.get("id", "")),
"check_title": attributes.get("check_title"),
"severity": attributes.get("severity", "informational"),
"status": attributes.get("status", "MANUAL"),
"muted": attributes.get("muted", False),
"impacted_providers": attributes.get("impacted_providers") or [],
"resources_fail": _counter(attributes, "resources_fail"),
"resources_total": _counter(attributes, "resources_total"),
"pass_count": _counter(attributes, "pass_count"),
"fail_count": _counter(attributes, "fail_count"),
"manual_count": _counter(attributes, "manual_count"),
"muted_count": _counter(attributes, "muted_count"),
"new_count": _counter(attributes, "new_count"),
"changed_count": _counter(attributes, "changed_count"),
"first_seen_at": attributes.get("first_seen_at"),
"last_seen_at": attributes.get("last_seen_at"),
"failing_since": attributes.get("failing_since"),
}
class SimplifiedFindingGroup(MinimalSerializerMixin):
"""Finding group summary optimized for browsing many checks."""
check_id: str = Field(description="Public check ID that identifies this group")
check_title: str | None = Field(
default=None, description="Human-readable check title"
)
severity: FindingSeverity = Field(description="Highest severity in the group")
status: FindingStatus = Field(description="Aggregated finding group status")
muted: bool = Field(
description="Whether all findings in this group are muted or accepted"
)
impacted_providers: list[str] = Field(
default_factory=list,
description="Provider types impacted by this finding group",
)
resources_fail: int = Field(
description="Number of non-muted failing resources in this group", ge=0
)
resources_total: int = Field(
description="Total number of resources in this group", ge=0
)
pass_count: int = Field(
description="Number of non-muted PASS findings in this group", ge=0
)
fail_count: int = Field(
description="Number of non-muted FAIL findings in this group", ge=0
)
manual_count: int = Field(
description="Number of non-muted MANUAL findings in this group", ge=0
)
muted_count: int = Field(description="Total muted findings in this group", ge=0)
new_count: int = Field(description="Number of new non-muted findings", ge=0)
changed_count: int = Field(
description="Number of changed non-muted findings", ge=0
)
first_seen_at: str | None = Field(
default=None, description="First time this group was detected"
)
last_seen_at: str | None = Field(
default=None, description="Last time this group was detected"
)
failing_since: str | None = Field(
default=None, description="First time this group started failing"
)
@classmethod
def from_api_response(cls, data: dict) -> "SimplifiedFindingGroup":
"""Transform JSON:API finding group response to simplified format."""
return cls(**_simplified_group_kwargs(data))
class DetailedFindingGroup(SimplifiedFindingGroup):
"""Finding group with complete counters and descriptive context."""
check_description: str | None = Field(
default=None, description="Description of the check behind this group"
)
pass_muted_count: int = Field(description="Muted PASS findings", ge=0)
fail_muted_count: int = Field(description="Muted FAIL findings", ge=0)
manual_muted_count: int = Field(description="Muted MANUAL findings", ge=0)
new_fail_count: int = Field(description="New non-muted FAIL findings", ge=0)
new_fail_muted_count: int = Field(description="New muted FAIL findings", ge=0)
new_pass_count: int = Field(description="New non-muted PASS findings", ge=0)
new_pass_muted_count: int = Field(description="New muted PASS findings", ge=0)
new_manual_count: int = Field(description="New non-muted MANUAL findings", ge=0)
new_manual_muted_count: int = Field(
description="New muted MANUAL findings", ge=0
)
changed_fail_count: int = Field(
description="Changed non-muted FAIL findings", ge=0
)
changed_fail_muted_count: int = Field(
description="Changed muted FAIL findings", ge=0
)
changed_pass_count: int = Field(
description="Changed non-muted PASS findings", ge=0
)
changed_pass_muted_count: int = Field(
description="Changed muted PASS findings", ge=0
)
changed_manual_count: int = Field(
description="Changed non-muted MANUAL findings", ge=0
)
changed_manual_muted_count: int = Field(
description="Changed muted MANUAL findings", ge=0
)
@classmethod
def from_api_response(cls, data: dict) -> "DetailedFindingGroup":
"""Transform JSON:API finding group response to detailed format."""
attributes = _attributes(data)
return cls(
**_simplified_group_kwargs(data),
check_description=attributes.get("check_description"),
pass_muted_count=_counter(attributes, "pass_muted_count"),
fail_muted_count=_counter(attributes, "fail_muted_count"),
manual_muted_count=_counter(attributes, "manual_muted_count"),
new_fail_count=_counter(attributes, "new_fail_count"),
new_fail_muted_count=_counter(attributes, "new_fail_muted_count"),
new_pass_count=_counter(attributes, "new_pass_count"),
new_pass_muted_count=_counter(attributes, "new_pass_muted_count"),
new_manual_count=_counter(attributes, "new_manual_count"),
new_manual_muted_count=_counter(attributes, "new_manual_muted_count"),
changed_fail_count=_counter(attributes, "changed_fail_count"),
changed_fail_muted_count=_counter(attributes, "changed_fail_muted_count"),
changed_pass_count=_counter(attributes, "changed_pass_count"),
changed_pass_muted_count=_counter(attributes, "changed_pass_muted_count"),
changed_manual_count=_counter(attributes, "changed_manual_count"),
changed_manual_muted_count=_counter(
attributes, "changed_manual_muted_count"
),
)
class FindingGroupsListResponse(MinimalSerializerMixin):
"""Paginated response for finding group list queries."""
groups: list[SimplifiedFindingGroup] = Field(
description="Finding groups matching the query"
)
total_num_groups: int = Field(
description="Total groups matching the query across all pages", ge=0
)
total_num_pages: int = Field(description="Total pages available", ge=0)
current_page: int = Field(description="Current page number", ge=1)
@classmethod
def from_api_response(cls, response: dict) -> "FindingGroupsListResponse":
"""Transform JSON:API list response to simplified format."""
pagination = response.get("meta", {}).get("pagination", {})
groups = [
SimplifiedFindingGroup.from_api_response(item)
for item in response.get("data", [])
]
return cls(
groups=groups,
total_num_groups=pagination.get("count", len(groups)),
total_num_pages=pagination.get("pages", 1),
current_page=pagination.get("page", 1),
)
class FindingGroupResourceInfo(MinimalSerializerMixin):
"""Nested resource information for a finding group row."""
uid: str = Field(description="Provider-native resource UID")
name: str = Field(description="Resource name")
service: str = Field(description="Cloud service")
region: str = Field(description="Cloud region")
type: str = Field(description="Resource type")
resource_group: str | None = Field(
default=None, description="Provider resource group or equivalent"
)
@classmethod
def from_api_response(cls, data: dict) -> "FindingGroupResourceInfo":
"""Transform nested resource data to simplified format."""
return cls(
uid=data.get("uid", ""),
name=data.get("name", ""),
service=data.get("service", ""),
region=data.get("region", ""),
type=data.get("type", ""),
resource_group=data.get("resource_group"),
)
class FindingGroupProviderInfo(MinimalSerializerMixin):
"""Nested provider information for a finding group resource row."""
type: str = Field(description="Provider type")
uid: str = Field(description="Provider-native account or subscription ID")
alias: str | None = Field(default=None, description="Provider alias")
@classmethod
def from_api_response(cls, data: dict) -> "FindingGroupProviderInfo":
"""Transform nested provider data to simplified format."""
return cls(
type=data.get("type", ""),
uid=data.get("uid", ""),
alias=data.get("alias"),
)
class FindingGroupResource(MinimalSerializerMixin):
"""Resource row affected by a finding group."""
id: str = Field(description="Row identifier for this finding group resource")
resource: FindingGroupResourceInfo = Field(description="Affected resource")
provider: FindingGroupProviderInfo = Field(description="Affected provider")
finding_id: str = Field(
description="Finding UUID to use with prowler_app_get_finding_details"
)
status: FindingStatus = Field(description="Finding status for this resource")
severity: FindingSeverity = Field(description="Finding severity")
muted: bool = Field(description="Whether the finding is muted")
delta: FindingDelta | None = Field(default=None, description="Change status")
first_seen_at: str | None = Field(default=None, description="First seen time")
last_seen_at: str | None = Field(default=None, description="Last seen time")
muted_reason: str | None = Field(default=None, description="Mute reason")
@classmethod
def from_api_response(cls, data: dict) -> "FindingGroupResource":
"""Transform JSON:API finding group resource response."""
attributes = _attributes(data)
return cls(
id=data.get("id", ""),
resource=FindingGroupResourceInfo.from_api_response(
attributes.get("resource") or {}
),
provider=FindingGroupProviderInfo.from_api_response(
attributes.get("provider") or {}
),
finding_id=str(attributes.get("finding_id", "")),
status=attributes.get("status", "MANUAL"),
severity=attributes.get("severity", "informational"),
muted=attributes.get("muted", False),
delta=attributes.get("delta"),
first_seen_at=attributes.get("first_seen_at"),
last_seen_at=attributes.get("last_seen_at"),
muted_reason=attributes.get("muted_reason"),
)
class FindingGroupResourcesListResponse(MinimalSerializerMixin):
"""Paginated response for finding group resource queries."""
resources: list[FindingGroupResource] = Field(
description="Resources matching the finding group query"
)
total_num_resources: int = Field(
description="Total resources matching the query across all pages", ge=0
)
total_num_pages: int = Field(description="Total pages available", ge=0)
current_page: int = Field(description="Current page number", ge=1)
@classmethod
def from_api_response(cls, response: dict) -> "FindingGroupResourcesListResponse":
"""Transform JSON:API resource list response to simplified format."""
pagination = response.get("meta", {}).get("pagination", {})
resources = [
FindingGroupResource.from_api_response(item)
for item in response.get("data", [])
]
return cls(
resources=resources,
total_num_resources=pagination.get("count", len(resources)),
total_num_pages=pagination.get("pages", 1),
current_page=pagination.get("page", 1),
)
@@ -0,0 +1,473 @@
"""Finding Groups tools for Prowler App MCP Server.
This module provides read-only tools for finding group triage and drill-downs.
"""
from typing import Any, Literal
from urllib.parse import quote
from pydantic import Field
from prowler_mcp_server.prowler_app.models.finding_groups import (
DetailedFindingGroup,
FindingGroupResourcesListResponse,
FindingGroupsListResponse,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
StatusFilter = Literal["FAIL", "PASS", "MANUAL"]
SeverityFilter = Literal["critical", "high", "medium", "low", "informational"]
DeltaFilter = Literal["new", "changed"]
GROUP_DETAIL_FIELDS = (
"check_id,check_title,check_description,severity,status,muted,"
"impacted_providers,resources_fail,resources_total,pass_count,fail_count,"
"manual_count,pass_muted_count,fail_muted_count,manual_muted_count,"
"muted_count,new_count,changed_count,new_fail_count,new_fail_muted_count,"
"new_pass_count,new_pass_muted_count,new_manual_count,new_manual_muted_count,"
"changed_fail_count,changed_fail_muted_count,changed_pass_count,"
"changed_pass_muted_count,changed_manual_count,changed_manual_muted_count,"
"first_seen_at,last_seen_at,failing_since"
)
GROUP_LIST_FIELDS = (
"check_id,check_title,severity,status,muted,impacted_providers,"
"resources_fail,resources_total,pass_count,fail_count,manual_count,"
"muted_count,new_count,changed_count,first_seen_at,last_seen_at,failing_since"
)
RESOURCE_FIELDS = (
"resource,provider,finding_id,status,severity,muted,delta,"
"first_seen_at,last_seen_at,muted_reason"
)
class FindingGroupsTools(BaseTool):
"""Tools for Finding Groups operations."""
@staticmethod
def _bool_value(value: bool | str) -> bool:
"""Normalize bool-like MCP client values."""
if isinstance(value, bool):
return value
return value.lower() == "true"
@staticmethod
def _group_endpoint(date_range: tuple[str, str] | None) -> str:
return "/finding-groups/latest" if date_range is None else "/finding-groups"
@staticmethod
def _resource_endpoint(check_id: str, date_range: tuple[str, str] | None) -> str:
escaped_check_id = quote(check_id, safe="")
if date_range is None:
return f"/finding-groups/latest/{escaped_check_id}/resources"
return f"/finding-groups/{escaped_check_id}/resources"
def _base_date_params(
self, date_from: str | None, date_to: str | None
) -> tuple[tuple[str, str] | None, dict[str, Any]]:
date_range = self.api_client.normalize_date_range(
date_from, date_to, max_days=2
)
if date_range is None:
return None, {}
return date_range, {
"filter[inserted_at__gte]": date_range[0],
"filter[inserted_at__lte]": date_range[1],
}
def _apply_common_filters(
self,
params: dict[str, Any],
provider: list[str],
provider_type: list[str],
provider_uid: list[str],
provider_alias: str | None,
region: list[str],
service: list[str],
resource_type: list[str],
resource_name: str | None,
resource_uid: str | None,
resource_group: list[str],
category: list[str],
check_id: list[str],
check_title: str | None,
severity: list[SeverityFilter],
status: list[StatusFilter],
muted: bool | str | None,
delta: list[DeltaFilter],
) -> None:
if provider:
params["filter[provider__in]"] = provider
if provider_type:
params["filter[provider_type__in]"] = provider_type
if provider_uid:
params["filter[provider_uid__in]"] = provider_uid
if provider_alias:
params["filter[provider_alias__icontains]"] = provider_alias
if region:
params["filter[region__in]"] = region
if service:
params["filter[service__in]"] = service
if resource_type:
params["filter[resource_type__in]"] = resource_type
if resource_name:
params["filter[resource_name__icontains]"] = resource_name
if resource_uid:
params["filter[resource_uid__icontains]"] = resource_uid
if resource_group:
params["filter[resource_groups__in]"] = resource_group
if category:
params["filter[category__in]"] = category
if check_id:
params["filter[check_id__in]"] = check_id
if check_title:
params["filter[check_title__icontains]"] = check_title
if severity:
params["filter[severity__in]"] = severity
if status:
params["filter[status__in]"] = status
if muted is not None:
params["filter[muted]"] = self._bool_value(muted)
if delta:
params["filter[delta__in]"] = delta
async def list_finding_groups(
self,
provider: list[str] = Field(
default=[],
description="Filter by provider UUIDs. Multiple values allowed. If empty, all visible providers are returned.",
),
provider_type: list[str] = Field(
default=[],
description="Filter by provider type. Multiple values allowed, such as aws, azure, gcp, kubernetes, github, or m365.",
),
provider_uid: list[str] = Field(
default=[],
description="Filter by provider-native account, subscription, or project IDs. Multiple values allowed.",
),
provider_alias: str | None = Field(
default=None,
description="Filter by provider alias/name using partial matching.",
),
region: list[str] = Field(
default=[],
description="Filter by cloud regions. Multiple values allowed.",
),
service: list[str] = Field(
default=[],
description="Filter by cloud services. Multiple values allowed.",
),
resource_type: list[str] = Field(
default=[],
description="Filter by resource types. Multiple values allowed.",
),
resource_name: str | None = Field(
default=None,
description="Filter by resource name using partial matching.",
),
resource_uid: str | None = Field(
default=None,
description="Filter by resource UID using partial matching.",
),
resource_group: list[str] = Field(
default=[],
description="Filter by resource group values. Multiple values allowed.",
),
category: list[str] = Field(
default=[],
description="Filter by finding categories. Multiple values allowed.",
),
check_id: list[str] = Field(
default=[],
description="Filter by check IDs. Multiple values allowed.",
),
check_title: str | None = Field(
default=None,
description="Filter by check title using partial matching.",
),
severity: list[SeverityFilter] = Field(
default=[],
description="Filter by aggregated severity. Empty returns all severities.",
),
status: list[StatusFilter] = Field(
default=["FAIL"],
description="Filter by aggregated status. Default returns failing groups. Pass [] to return all statuses.",
),
muted: bool | str | None = Field(
default=None,
description="Filter by fully muted group state. Accepts true/false.",
),
include_muted: bool | str = Field(
default=False,
description="When false, excludes fully muted groups. Set true to include fully muted groups.",
),
delta: list[DeltaFilter] = Field(
default=[],
description="Filter by group delta values: new or changed.",
),
date_from: str | None = Field(
default=None,
description="Start date for historical query in YYYY-MM-DD format. Maximum range is 2 days.",
),
date_to: str | None = Field(
default=None,
description="End date for historical query in YYYY-MM-DD format. Maximum range is 2 days.",
),
sort: str | None = Field(
default=None,
description="Optional sort expression supported by the finding-groups API, such as -fail_count,-severity,check_id.",
),
page_size: int = Field(
default=50, description="Number of groups to return per page"
),
page_number: int = Field(
default=1, description="Page number to retrieve (1-indexed)"
),
) -> dict[str, Any]:
"""List finding groups aggregated by check ID.
Default behavior returns the latest non-muted FAIL groups for fast triage.
Without dates this uses `/finding-groups/latest`. With `date_from` or
`date_to`, this uses `/finding-groups` with a maximum 2-day date window.
Use this tool to find noisy or high-impact checks, then call
prowler_app_get_finding_group_details for complete counters or
prowler_app_list_finding_group_resources to drill into affected resources.
"""
try:
self.api_client.validate_page_size(page_size)
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._group_endpoint(date_range)
self._apply_common_filters(
params,
provider,
provider_type,
provider_uid,
provider_alias,
region,
service,
resource_type,
resource_name,
resource_uid,
resource_group,
category,
check_id,
check_title,
severity,
status,
muted,
delta,
)
params["filter[include_muted]"] = self._bool_value(include_muted)
params["page[size]"] = page_size
params["page[number]"] = page_number
params["fields[finding-groups]"] = GROUP_LIST_FIELDS
if sort:
params["sort"] = sort
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
response = FindingGroupsListResponse.from_api_response(api_response)
return response.model_dump()
except Exception as e:
self.logger.error(f"Error listing finding groups: {e}")
return {"error": str(e), "status": "failed"}
async def get_finding_group_details(
self,
check_id: str = Field(
description="Public check ID that identifies the finding group. This is not a UUID."
),
date_from: str | None = Field(
default=None,
description="Start date for historical query in YYYY-MM-DD format. Maximum range is 2 days.",
),
date_to: str | None = Field(
default=None,
description="End date for historical query in YYYY-MM-DD format. Maximum range is 2 days.",
),
) -> dict[str, Any]:
"""Get complete details for one finding group by exact check ID.
Uses `filter[check_id]` exact matching against latest data by default,
or historical data when dates are provided. Fully muted groups are
included by default so accepted risk does not look like a missing group.
"""
try:
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._group_endpoint(date_range)
params.update(
{
"filter[check_id]": check_id,
"filter[include_muted]": True,
"page[size]": 1,
"page[number]": 1,
"fields[finding-groups]": GROUP_DETAIL_FIELDS,
}
)
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
data = api_response.get("data", [])
if not data:
return {
"error": f"Finding group '{check_id}' not found.",
"status": "not_found",
}
group = DetailedFindingGroup.from_api_response(data[0])
return group.model_dump()
except Exception as e:
self.logger.error(f"Error getting finding group details: {e}")
return {"error": str(e), "status": "failed"}
async def list_finding_group_resources(
self,
check_id: str = Field(
description="Public check ID that identifies the finding group. This is not a UUID."
),
provider: list[str] = Field(
default=[],
description="Filter by provider UUIDs. Multiple values allowed.",
),
provider_type: list[str] = Field(
default=[],
description="Filter by provider type. Multiple values allowed.",
),
provider_uid: list[str] = Field(
default=[],
description="Filter by provider-native account, subscription, or project IDs. Multiple values allowed.",
),
provider_alias: str | None = Field(
default=None,
description="Filter by provider alias/name using partial matching.",
),
region: list[str] = Field(
default=[],
description="Filter by cloud regions. Multiple values allowed.",
),
service: list[str] = Field(
default=[],
description="Filter by cloud services. Multiple values allowed.",
),
resource_type: list[str] = Field(
default=[],
description="Filter by resource types. Multiple values allowed.",
),
resource_name: str | None = Field(
default=None,
description="Filter by resource name using partial matching.",
),
resource_uid: str | None = Field(
default=None,
description="Filter by resource UID using partial matching.",
),
resource_group: list[str] = Field(
default=[],
description="Filter by resource group values. Multiple values allowed.",
),
category: list[str] = Field(
default=[],
description="Filter by finding categories. Multiple values allowed.",
),
severity: list[SeverityFilter] = Field(
default=[],
description="Filter by severity. Empty returns all severities.",
),
status: list[StatusFilter] = Field(
default=["FAIL"],
description="Filter by status. Default returns failing resources. Pass [] to return all statuses.",
),
muted: bool | str | None = Field(
default=None,
description="Filter by muted state. Accepts true/false. Overrides include_muted when provided.",
),
include_muted: bool | str = Field(
default=False,
description="When false, returns only actionable unmuted resources by applying muted=false. Set true to include muted and unmuted resources.",
),
delta: list[DeltaFilter] = Field(
default=[], description="Filter by delta values: new or changed."
),
date_from: str | None = Field(
default=None,
description="Start date for historical query in YYYY-MM-DD format. Maximum range is 2 days.",
),
date_to: str | None = Field(
default=None,
description="End date for historical query in YYYY-MM-DD format. Maximum range is 2 days.",
),
sort: str | None = Field(
default=None,
description="Optional sort expression supported by the finding group resources API.",
),
page_size: int = Field(
default=50, description="Number of resources to return per page"
),
page_number: int = Field(
default=1, description="Page number to retrieve (1-indexed)"
),
) -> dict[str, Any]:
"""List resources affected by a finding group.
Without dates this uses `/finding-groups/latest/{check_id}/resources`.
With `date_from` or `date_to`, this uses
`/finding-groups/{check_id}/resources` with a maximum 2-day date window.
Default behavior returns FAIL, unmuted resources so the result is
actionable. Set `include_muted=True` to include accepted/suppressed
resources too. Each row includes nested resource and provider data plus
`finding_id`. Use `prowler_app_get_finding_details(finding_id)` to
retrieve complete remediation guidance for a specific resource finding.
"""
try:
self.api_client.validate_page_size(page_size)
date_range, params = self._base_date_params(date_from, date_to)
endpoint = self._resource_endpoint(check_id, date_range)
if muted is None and not self._bool_value(include_muted):
muted = False
self._apply_common_filters(
params,
provider,
provider_type,
provider_uid,
provider_alias,
region,
service,
resource_type,
resource_name,
resource_uid,
resource_group,
category,
[],
None,
severity,
status,
muted,
delta,
)
params["page[size]"] = page_size
params["page[number]"] = page_number
params["fields[finding-group-resources]"] = RESOURCE_FIELDS
if sort:
params["sort"] = sort
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
response = FindingGroupResourcesListResponse.from_api_response(
api_response
)
return response.model_dump()
except Exception as e:
self.logger.error(f"Error listing finding group resources: {e}")
return {"error": str(e), "status": "failed"}
Generated
-6897
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -85,7 +85,7 @@ class {check_name}(Check):
## TECH STACK
Python 3.10+ | Poetry 2.3+ | pytest | moto (AWS mocking) | Pre-commit hooks (black, flake8, pylint, bandit)
Python 3.10+ | uv | pytest | moto (AWS mocking) | Pre-commit hooks (black, flake8, pylint, bandit)
---
@@ -112,20 +112,20 @@ prowler/
```bash
# Setup
poetry install --with dev
poetry run pre-commit install
uv sync
uv run pre-commit install
# Run Prowler
poetry run python prowler-cli.py {provider}
poetry run python prowler-cli.py {provider} --check {check_name}
poetry run python prowler-cli.py {provider} --list-checks
uv run python prowler-cli.py {provider}
uv run python prowler-cli.py {provider} --check {check_name}
uv run python prowler-cli.py {provider} --list-checks
# Testing
poetry run pytest -n auto -vvv tests/
poetry run pytest tests/providers/{provider}/services/{service}/ -v
uv run pytest -n auto -vvv tests/
uv run pytest tests/providers/{provider}/services/{service}/ -v
# Code Quality
poetry run pre-commit run --all-files
uv run pre-commit run --all-files
```
---
@@ -145,8 +145,8 @@ poetry run pre-commit run --all-files
## QA CHECKLIST
- [ ] `poetry run pytest` passes
- [ ] `poetry run pre-commit run --all-files` passes
- [ ] `uv run pytest` passes
- [ ] `uv run pre-commit run --all-files` passes
- [ ] Check metadata JSON is valid
- [ ] Tests cover PASS, FAIL, and empty resource scenarios
- [ ] Docstrings follow Google style
+13 -1
View File
@@ -6,15 +6,26 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- 6 Chat file sharing, external messaging, spaces, and apps access checks for Google Workspace provider using the Cloud Identity Policy API [(#11126)](https://github.com/prowler-cloud/prowler/pull/11126)
- `entra_service_principal_no_secrets_for_permanent_tier0_roles` check for M365 provider [(#10788)](https://github.com/prowler-cloud/prowler/pull/10788)
- `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858)
- Okta provider with OAuth 2.0 authentication and `signon_global_session_idle_timeout_15min` check [(#11079)](https://github.com/prowler-cloud/prowler/pull/11079)
- `sagemaker_domain_sso_configured` check for AWS provider [(#11094)](https://github.com/prowler-cloud/prowler/pull/11094)
### 🔄 Changed
- `entra_emergency_access_exclusion` check for M365 provider now scopes the exclusion requirement to enabled Conditional Access policies with a `Block` grant control instead of every enabled policy, focusing on the lockout-relevant policy set [(#10849)](https://github.com/prowler-cloud/prowler/pull/10849)
- `okta` SDK dependency moved to a new `okta` optional extra; install with `pip install prowler[okta]` (or `poetry install --all-extras`) to use the Okta provider. Prevents resolution conflicts with downstream consumers pinning the legacy `okta<1.0.0` package
- AWS IAM customer-managed policy checks no longer emit `FAIL` on unattached policies unless `--scan-unused-services` is enabled [(#11150)](https://github.com/prowler-cloud/prowler/pull/11150)
### 🐞 Fixed
- Google Workspace Directory checks sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11176)](https://github.com/prowler-cloud/prowler/pull/11176)
- Google Workspace Calendar and Drive services sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11161)](https://github.com/prowler-cloud/prowler/pull/11161)
- `zone_waf_enabled` check for Cloudflare provider now appends a plan-aware hint to the FAIL `status_extended`: a possible-false-positive note on paid plans (Pro, Business, Enterprise) where the legacy `waf` zone setting can read `off` even though WAF managed rulesets are deployed via the dashboard, and a "not available on the Cloudflare Free plan" note on Free zones [(#9896)](https://github.com/prowler-cloud/prowler/pull/9896)
- Google Workspace Gmail checks sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11169)](https://github.com/prowler-cloud/prowler/pull/11169)
- Google Workspace Drive and Calendar services missing server-side policy filters [(#11195)](https://github.com/prowler-cloud/prowler/pull/11195)
- `VercelSession.token` is now excluded from serialization and representation to prevent the Vercel API token from leaking through `.dict()`, `.json()` or logs [(#11198)](https://github.com/prowler-cloud/prowler/pull/11198)
---
@@ -23,6 +34,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- `entra_users_mfa_capable` and `entra_break_glass_account_fido2_security_key_registered` report a preventive FAIL per affected user (with the missing permission named) when the M365 service principal lacks `AuditLog.Read.All`, instead of mass false positives [(#10907)](https://github.com/prowler-cloud/prowler/pull/10907)
- Update duplicated GCP CIS requirements IDs [(#11180)](https://github.com/prowler-cloud/prowler/pull/11180)
---
+2 -2
View File
@@ -914,7 +914,7 @@
]
},
{
"Id": "3.1",
"Id": "3.10",
"Description": "Use Identity Aware Proxy (IAP) to Ensure Only Traffic From Google IP Addresses are 'Allowed'",
"Checks": [],
"Attributes": [
@@ -1132,7 +1132,7 @@
]
},
{
"Id": "4.1",
"Id": "4.10",
"Description": "Ensure That App Engine Applications Enforce HTTPS Connections",
"Checks": [],
"Attributes": [
@@ -1084,7 +1084,9 @@
{
"Id": "3.1.4.1.1",
"Description": "Ensure external filesharing in Google Chat and Hangouts is disabled",
"Checks": [],
"Checks": [
"chat_external_file_sharing_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1105,7 +1107,9 @@
{
"Id": "3.1.4.1.2",
"Description": "Ensure internal filesharing in Google Chat and Hangouts is disabled",
"Checks": [],
"Checks": [
"chat_internal_file_sharing_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1126,7 +1130,9 @@
{
"Id": "3.1.4.2.1",
"Description": "Ensure Google Chat externally is restricted to allowed domains",
"Checks": [],
"Checks": [
"chat_external_messaging_restricted"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1147,7 +1153,9 @@
{
"Id": "3.1.4.3.1",
"Description": "Ensure external spaces in Google Chat and Hangouts are restricted",
"Checks": [],
"Checks": [
"chat_external_spaces_restricted"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1168,7 +1176,9 @@
{
"Id": "3.1.4.4.1",
"Description": "Ensure allow users to install Chat apps is disabled",
"Checks": [],
"Checks": [
"chat_apps_installation_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1189,7 +1199,9 @@
{
"Id": "3.1.4.4.2",
"Description": "Ensure allow users to add and use incoming webhooks is disabled",
"Checks": [],
"Checks": [
"chat_incoming_webhooks_disabled"
],
"Attributes": [
{
"Section": "3 Apps",
@@ -1466,7 +1466,9 @@
{
"Id": "GWS.CHAT.2.1",
"Description": "External file sharing SHALL be disabled to protect sensitive information from unauthorized or accidental sharing",
"Checks": [],
"Checks": [
"chat_external_file_sharing_disabled"
],
"Attributes": [
{
"Section": "Chat",
@@ -1492,7 +1494,9 @@
{
"Id": "GWS.CHAT.4.1",
"Description": "External chat messaging SHALL be restricted to allowlisted domains only",
"Checks": [],
"Checks": [
"chat_external_messaging_restricted"
],
"Attributes": [
{
"Section": "Chat",
@@ -16,6 +16,8 @@ class iam_no_custom_policy_permissive_role_assumption(Check):
for policy in iam_client.policies.values():
# Check only custom policies
if policy.type == "Custom":
if not policy.attached and not iam_client.provider.scan_unused_services:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
report.region = iam_client.region
report.status = "PASS"
@@ -11,6 +11,8 @@ class iam_policy_allows_privilege_escalation(Check):
for policy in iam_client.policies.values():
if policy.type == "Custom":
if not policy.attached and not iam_client.provider.scan_unused_services:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
report.region = iam_client.region
report.status = "PASS"
@@ -11,6 +11,8 @@ class iam_policy_no_full_access_to_cloudtrail(Check):
for policy in iam_client.policies.values():
# Check only custom policies
if policy.type == "Custom":
if not policy.attached and not iam_client.provider.scan_unused_services:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
report.region = iam_client.region
report.status = "PASS"
@@ -11,6 +11,8 @@ class iam_policy_no_full_access_to_kms(Check):
for policy in iam_client.policies.values():
# Check only custom policies
if policy.type == "Custom":
if not policy.attached and not iam_client.provider.scan_unused_services:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
report.region = iam_client.region
report.status = "PASS"
@@ -10,6 +10,8 @@ class iam_policy_no_wildcard_marketplace_subscribe(Check):
findings = []
for policy in iam_client.policies.values():
if policy.type == "Custom":
if not policy.attached and not iam_client.provider.scan_unused_services:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=policy)
report.region = iam_client.region
report.status = "PASS"
@@ -0,0 +1,39 @@
{
"Provider": "aws",
"CheckID": "sagemaker_domain_sso_configured",
"CheckTitle": "SageMaker domains use SSO authentication instead of IAM mode",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "sagemaker",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**SageMaker Domain** configured with **IAM Identity Center (SSO) authentication**. The check validates that each SageMaker Domain uses SSO mode (`AuthMode: SSO`) and is associated with an IAM Identity Center instance (`SingleSignOnManagedApplicationInstanceId` or `SingleSignOnApplicationArn` present), ensuring user access is centrally managed through AWS IAM Identity Center.",
"Risk": "IAM-mode domains create per-user IAM users or roles managed locally to SageMaker, drifting from the organization's identity provider and weakening lifecycle controls such as offboarding, MFA enforcement, and session policies. SSO-mode domains without an IAM Identity Center association leave authentication in an inconsistent state and bypass centralized access governance.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/sagemaker/latest/dg/onboard-sso-users.html"
],
"Remediation": {
"Code": {
"CLI": "aws sagemaker describe-domain --domain-id <domain_id> --query '{AuthMode:AuthMode,SingleSignOnManagedApplicationInstanceId:SingleSignOnManagedApplicationInstanceId,SingleSignOnApplicationArn:SingleSignOnApplicationArn}'",
"NativeIaC": "```yaml\n# CloudFormation: Create a SageMaker Domain with SSO authentication\nResources:\n SageMakerDomain:\n Type: AWS::SageMaker::Domain\n Properties:\n DomainName: <example_domain_name>\n AuthMode: SSO # Critical: enables IAM Identity Center authentication\n DefaultUserSettings:\n ExecutionRole: <example_role_arn>\n VpcId: <example_vpc_id>\n SubnetIds:\n - <example_subnet_id>\n```",
"Other": "SageMaker Domains cannot be switched from IAM to SSO mode after creation. To remediate, create a new Domain with AuthMode set to SSO and migrate user profiles.",
"Terraform": "```hcl\n# Terraform: Create a SageMaker Domain with SSO authentication\nresource \"aws_sagemaker_domain\" \"example\" {\n domain_name = \"<example_domain_name>\"\n auth_mode = \"SSO\" # Critical: enables IAM Identity Center authentication\n vpc_id = \"<example_vpc_id>\"\n subnet_ids = [\"<example_subnet_id>\"]\n\n default_user_settings {\n execution_role = \"<example_role_arn>\"\n }\n}\n```"
},
"Recommendation": {
"Text": "Configure SageMaker Domains with SSO authentication mode to anchor user access to AWS IAM Identity Center. This enforces centralized identity lifecycle management, MFA policies, and session controls. Domains created with IAM mode must be recreated with SSO mode since the auth mode cannot be changed after creation.",
"Url": "https://hub.prowler.com/check/sagemaker_domain_sso_configured"
}
},
"Categories": [
"identity-access",
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

Some files were not shown because too many files have changed in this diff Show More