Merge branch 'master' into PROWLER-1403-enhancement-improve-ui-logging-for-better-observability-and-debugging

This commit is contained in:
Pedro Martín
2026-04-23 14:05:51 +02:00
committed by GitHub
114 changed files with 7686 additions and 540 deletions
+3 -4
View File
@@ -60,6 +60,7 @@ jobs:
files: |
api/**
.github/workflows/api-security.yml
.safety-policy.yml
files_ignore: |
api/docs/**
api/README.md
@@ -80,10 +81,8 @@ jobs:
- name: Safety
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run safety check --ignore 79023,79027,86217,71600
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
# TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0`
# TODO: 71600 CVE-2024-1135 false positive - fixed in gunicorn 22.0.0, project uses 23.0.0
# Accepted CVEs, severity threshold, and ignore expirations live in ../.safety-policy.yml
run: poetry run safety check --policy-file ../.safety-policy.yml
- name: Vulture
if: steps.check-changes.outputs.any_changed == 'true'
@@ -20,7 +20,13 @@ permissions: {}
jobs:
check-compliance-mapping:
if: contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false
if: >-
github.event.pull_request.state == 'open' &&
contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false &&
(
(github.event.action != 'labeled' && github.event.action != 'unlabeled')
|| github.event.label.name == 'no-compliance-check'
)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
+2 -1
View File
@@ -83,7 +83,8 @@ jobs:
- name: Security scan with Safety
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run safety check -r pyproject.toml
# Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml
run: poetry run safety check -r pyproject.toml --policy-file .safety-policy.yml
- name: Dead code detection with Vulture
if: steps.check-changes.outputs.any_changed == 'true'
+2
View File
@@ -151,6 +151,8 @@ node_modules
# Persistent data
_data/
/openspec/
/.gitmodules
# AI Instructions (generated by skills/setup.sh from AGENTS.md)
CLAUDE.md
+57 -30
View File
@@ -1,12 +1,11 @@
repos:
## GENERAL
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
## GENERAL (prek built-in — no external repo needed)
- repo: builtin
hooks:
- id: check-merge-conflict
- id: check-yaml
args: ["--unsafe"]
exclude: prowler/config/llm_config.yaml
args: ["--allow-multiple-documents"]
exclude: (prowler/config/llm_config.yaml|contrib/)
- id: check-json
- id: end-of-file-fixer
- id: trailing-whitespace
@@ -36,12 +35,13 @@ repos:
- id: shellcheck
exclude: contrib
## PYTHON
## PYTHON — SDK (prowler/, tests/, dashboard/, util/, scripts/)
- repo: https://github.com/myint/autoflake
rev: v2.3.3
hooks:
- id: autoflake
exclude: ^skills/
name: "SDK - autoflake"
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
args:
[
"--in-place",
@@ -53,97 +53,124 @@ repos:
rev: 8.0.1
hooks:
- id: isort
exclude: ^skills/
name: "SDK - isort"
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
args: ["--profile", "black"]
- repo: https://github.com/psf/black
rev: 26.3.1
hooks:
- id: black
exclude: ^skills/
name: "SDK - black"
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
exclude: (contrib|^skills/)
name: "SDK - flake8"
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
args: ["--ignore=E266,W503,E203,E501,W605"]
## PYTHON — API + MCP Server (ruff)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.11
hooks:
- id: ruff
name: "API + MCP - ruff check"
files: { glob: ["{api,mcp_server}/**/*.py"] }
args: ["--fix"]
- id: ruff-format
name: "API + MCP - ruff format"
files: { glob: ["{api,mcp_server}/**/*.py"] }
## PYTHON — Poetry
- repo: https://github.com/python-poetry/poetry
rev: 2.3.4
hooks:
- id: poetry-check
name: API - poetry-check
args: ["--directory=./api"]
files: { glob: ["api/{pyproject.toml,poetry.lock}"] }
pass_filenames: false
- id: poetry-lock
name: API - poetry-lock
args: ["--directory=./api"]
files: { glob: ["api/{pyproject.toml,poetry.lock}"] }
pass_filenames: false
- id: poetry-check
name: SDK - poetry-check
args: ["--directory=./"]
files: { glob: ["{pyproject.toml,poetry.lock}"] }
pass_filenames: false
- id: poetry-lock
name: SDK - poetry-lock
args: ["--directory=./"]
files: { glob: ["{pyproject.toml,poetry.lock}"] }
pass_filenames: false
## CONTAINERS
- repo: https://github.com/hadolint/hadolint
rev: v2.14.0
hooks:
- id: hadolint
args: ["--ignore=DL3013"]
## LOCAL HOOKS
- repo: local
hooks:
- id: pylint
name: pylint
entry: bash -c 'pylint --disable=W,C,R,E -j 0 -rn -sn prowler/'
name: "SDK - pylint"
entry: pylint --disable=W,C,R,E -j 0 -rn -sn
language: system
files: '.*\.py'
types: [python]
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
- id: trufflehog
name: TruffleHog
description: Detect secrets in your data.
entry: bash -c 'trufflehog --no-update git file://. --only-verified --fail'
entry: bash -c 'trufflehog --no-update git file://. --since-commit HEAD --only-verified --fail'
# For running trufflehog in docker, use the following entry instead:
# entry: bash -c 'docker run -v "$(pwd):/workdir" -i --rm trufflesecurity/trufflehog:latest git file:///workdir --only-verified --fail'
language: system
pass_filenames: false
stages: ["pre-commit", "pre-push"]
- id: bandit
name: bandit
description: "Bandit is a tool for finding common security issues in Python code"
entry: bash -c 'bandit -q -lll -x '*_test.py,./contrib/,./.venv/,./skills/' -r .'
entry: bandit -q -lll
language: system
types: [python]
files: '.*\.py'
exclude:
{ glob: ["{contrib,skills}/**", "**/.venv/**", "**/*_test.py"] }
- id: safety
name: safety
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
# TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
# TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0`
# TODO: 71600 CVE-2024-1135 false positive - fixed in gunicorn 22.0.0, project uses 23.0.0
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,86217,71600'
# Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml
entry: safety check --policy-file .safety-policy.yml
language: system
pass_filenames: false
files:
{
glob:
[
"**/pyproject.toml",
"**/poetry.lock",
"**/requirements*.txt",
".safety-policy.yml",
],
}
- id: vulture
name: vulture
description: "Vulture finds unused code in Python programs."
entry: bash -c 'vulture --exclude "contrib,.venv,api/src/backend/api/tests/,api/src/backend/conftest.py,api/src/backend/tasks/tests/,skills/" --min-confidence 100 .'
entry: vulture --min-confidence 100
language: system
types: [python]
files: '.*\.py'
- id: ui-checks
name: UI - Husky Pre-commit
description: "Run UI pre-commit checks (Claude Code validation + healthcheck)"
entry: bash -c 'cd ui && .husky/pre-commit'
language: system
files: '^ui/.*\.(ts|tsx|js|jsx|json|css)$'
pass_filenames: false
verbose: true
+58
View File
@@ -0,0 +1,58 @@
# Safety policy for `safety check` (Safety CLI 3.x, v2 schema).
# Applied in: .pre-commit-config.yaml, .github/workflows/api-security.yml,
# .github/workflows/sdk-security.yml via `--policy-file`.
#
# Validate: poetry run safety validate policy_file --path .safety-policy.yml
security:
# Scan unpinned requirements too. Prowler pins via poetry.lock, so this is
# defensive against accidental unpinned entries.
ignore-unpinned-requirements: False
# CVSS severity filter. 7 = report only HIGH (7.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'
+20 -2
View File
@@ -2,11 +2,29 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.26.0] (Prowler UNRELEASED)
### 🚀 Added
- CIS Benchmark PDF report generation for scans, exposing the latest CIS version per provider via `GET /scans/{id}/cis/{name}/` and picking the variant dynamically via `_pick_latest_cis_variant` (no hard-coded provider → version mapping) [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650)
### 🔄 Changed
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
---
## [1.25.3] (Prowler v5.24.3)
### 🚀 Added
- `/overviews/findings`, `/overviews/findings-severity` and `/overviews/services` now reflect newly-muted findings without waiting for the next scan. The post-mute `reaggregate-all-finding-group-summaries` task was extended to re-run the same per-scan pipeline that scan completion runs (`ScanSummary`, `DailySeveritySummary`, `FindingGroupDailySummary`) on the latest scan of every `(provider, day)` pair, keeping the pre-aggregated tables in sync with `Finding.muted` updates [(#10827)](https://github.com/prowler-cloud/prowler/pull/10827)
### 🐞 Fixed
- Finding groups aggregated `status` now treats muted findings as resolved: a group is `FAIL` only while at least one non-muted FAIL remains, otherwise it is `PASS` (including fully-muted groups). The `filter[status]` filter and the `sort=status` ordering share the same semantics, keeping `status` consistent with `fail_count` and the orthogonal `muted` flag [(#10825)](https://github.com/prowler-cloud/prowler/pull/10825)
- `aggregate_findings` is now idempotent: it deletes the scan's existing `ScanSummary` rows before `bulk_create`, so re-runs (such as the post-mute reaggregation pipeline) no longer violate the `unique_scan_summary` constraint and no longer abort the downstream `DailySeveritySummary` / `FindingGroupDailySummary` recomputation for the affected scan [(#10827)](https://github.com/prowler-cloud/prowler/pull/10827)
- Attack Paths: Findings on AWS were silently dropped during the Neo4j merge for resources whose Cartography node is keyed by a short identifier (e.g. EC2 instances) rather than the full ARN [(#10839)](https://github.com/prowler-cloud/prowler/pull/10839)
---
@@ -20,7 +38,6 @@ All notable changes to the **Prowler API** are documented in this file.
- `/finding-groups/latest/<check_id>/resources` now selects the latest completed scan per provider by `-completed_at` (then `-inserted_at`) instead of `-inserted_at`, matching the `/finding-groups/latest` summary path and the daily-summary upsert so overlapping scans no longer produce diverging `delta`/`new_count` between the two endpoints [(#10802)](https://github.com/prowler-cloud/prowler/pull/10802)
---
## [1.25.1] (Prowler v5.24.1)
@@ -34,6 +51,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Attack Paths: Missing `tenant_id` filter while getting related findings after scan completes [(#10722)](https://github.com/prowler-cloud/prowler/pull/10722)
- Finding group counters `pass_count`, `fail_count` and `manual_count` now exclude muted findings [(#10753)](https://github.com/prowler-cloud/prowler/pull/10753)
- Silent data loss in `ResourceFindingMapping` bulk insert that left findings orphaned when `INSERT ... ON CONFLICT DO NOTHING` dropped rows without raising; added explicit `unique_fields` [(#10724)](https://github.com/prowler-cloud/prowler/pull/10724)
- `DELETE /tenants/{tenant_pk}/memberships/{id}` now deletes the expelled user's account when the removed membership was their last one, and blacklists every outstanding refresh token for that user so their existing sessions can no longer mint new access tokens [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
---
@@ -722,4 +740,4 @@ All notable changes to the **Prowler API** are documented in this file.
- Findings metadata endpoint received a performance improvement [(#6863)](https://github.com/prowler-cloud/prowler/pull/6863)
- Increased the allowed length of the provider UID for Kubernetes providers [(#6869)](https://github.com/prowler-cloud/prowler/pull/6869)
---
---
+1
View File
@@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
git \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
+14 -14
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -2974,7 +2974,7 @@ files = [
[package.dependencies]
autopep8 = "*"
Django = ">=4.2"
gprof2dot = ">=2017.09.19"
gprof2dot = ">=2017.9.19"
sqlparse = "*"
[[package]]
@@ -4582,7 +4582,7 @@ files = [
[package.dependencies]
attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.03.6"
jsonschema-specifications = ">=2023.3.6"
referencing = ">=0.28.4"
rpds-py = ">=0.7.1"
@@ -4790,7 +4790,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""]
mongodb = ["pymongo (==4.15.3)"]
msgpack = ["msgpack (==1.1.2)"]
pyro = ["pyro4 (==4.82)"]
qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"]
qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"]
redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"]
slmq = ["softlayer_messaging (>=1.0.3)"]
sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"]
@@ -4811,7 +4811,7 @@ files = [
]
[package.dependencies]
certifi = ">=14.05.14"
certifi = ">=14.5.14"
durationpy = ">=0.7"
google-auth = ">=1.0.1"
oauthlib = ">=3.2.2"
@@ -6964,11 +6964,11 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
]
markers = {main = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
[[package]]
name = "pydantic"
@@ -7147,14 +7147,14 @@ urllib3 = ">=1.26.0"
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
@@ -7194,7 +7194,7 @@ files = [
]
[package.dependencies]
astroid = ">=3.2.2,<=3.3.0-dev0"
astroid = ">=3.2.2,<=3.3.0.dev0"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
dill = [
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
@@ -7216,7 +7216,7 @@ description = "The MSALRuntime Python Interop Package"
optional = false
python-versions = ">=3.6"
groups = ["main"]
markers = "(platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\") and sys_platform == \"win32\""
markers = "sys_platform == \"win32\" and (platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\")"
files = [
{file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0c22e2e83faa10de422bbfaacc1bb2887c9025ee8a53f0fc2e4f7db01c4a7b66"},
{file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8ce2944a0f944833d047bb121396091e00287e2b6373716106da86ea99abf379"},
@@ -8209,10 +8209,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a.0"
botocore = ">=1.37.4,<2.0a0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
[[package]]
name = "safety"
+1
View File
@@ -330,6 +330,7 @@ class MembershipFilter(FilterSet):
model = Membership
fields = {
"tenant": ["exact"],
"user": ["exact"],
"role": ["exact"],
"date_joined": ["date", "gte", "lte"],
}
+328
View File
@@ -32,6 +32,11 @@ from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
OutstandingToken,
)
from rest_framework_simplejwt.tokens import RefreshToken
from api.attack_paths import (
AttackPathsQueryDefinition,
@@ -47,6 +52,7 @@ from api.models import (
Finding,
Integration,
Invitation,
InvitationRoleRelationship,
LighthouseProviderConfiguration,
LighthouseProviderModels,
LighthouseTenantConfiguration,
@@ -746,6 +752,39 @@ class TestTenantViewSet:
# Test user + 2 extra users for tenant 2
assert len(response.json()["data"]) == 3
def test_tenants_list_memberships_filter_by_user(
self, authenticated_client, tenants_fixture, extra_users
):
_, tenant2, _ = tenants_fixture
_, user3_membership = extra_users
user3, membership3 = user3_membership
response = authenticated_client.get(
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
{"filter[user]": str(user3.id)},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
assert data[0]["id"] == str(membership3.id)
def test_tenants_list_memberships_filter_by_user_no_match(
self, authenticated_client, tenants_fixture, extra_users
):
_, tenant2, _ = tenants_fixture
unrelated_user = User.objects.create_user(
name="unrelated",
password=TEST_PASSWORD,
email="unrelated@gmail.com",
)
response = authenticated_client.get(
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
{"filter[user]": str(unrelated_user.id)},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"] == []
def test_tenants_list_memberships_as_member(
self, authenticated_client, tenants_fixture, extra_users
):
@@ -803,6 +842,7 @@ class TestTenantViewSet:
):
_, tenant2, _ = tenants_fixture
user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER)
user_id = user_membership.user_id
response = authenticated_client.delete(
reverse(
"tenant-membership-detail",
@@ -811,6 +851,127 @@ class TestTenantViewSet:
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Membership.objects.filter(id=user_membership.id).exists()
assert User.objects.filter(id=user_id).exists()
def test_expel_user_deletes_account_if_last_membership(
self, authenticated_client, tenants_fixture, extra_users
):
# TEST_USER is OWNER of tenant2; user3 is MEMBER only in tenant2
_, tenant2, _ = tenants_fixture
_, user3_membership = extra_users
user3, membership3 = user3_membership
assert Membership.objects.filter(user=user3).count() == 1
response = authenticated_client.delete(
reverse(
"tenant-membership-detail",
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Membership.objects.filter(id=membership3.id).exists()
assert not User.objects.filter(id=user3.id).exists()
def test_expel_user_blacklists_refresh_tokens(
self, authenticated_client, tenants_fixture, extra_users
):
_, tenant2, _ = tenants_fixture
_, user3_membership = extra_users
user3, membership3 = user3_membership
# Issue two refresh tokens to simulate active sessions
RefreshToken.for_user(user3)
RefreshToken.for_user(user3)
outstanding_ids = list(
OutstandingToken.objects.filter(user=user3).values_list("id", flat=True)
)
assert len(outstanding_ids) == 2
assert not BlacklistedToken.objects.filter(
token_id__in=outstanding_ids
).exists()
response = authenticated_client.delete(
reverse(
"tenant-membership-detail",
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert (
BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 2
)
def test_expel_user_blacklists_refresh_tokens_is_idempotent(
self, authenticated_client, tenants_fixture, extra_users
):
# Regression test for the bulk blacklisting path: if one of the
# user's refresh tokens is already blacklisted when the expel
# endpoint runs, the remaining tokens must still be blacklisted
# and the already-blacklisted one must not be duplicated.
tenant1, tenant2, _ = tenants_fixture
_, user3_membership = extra_users
user3, membership3 = user3_membership
# Keep the user alive after the expel so the assertions below can
# still query OutstandingToken by user_id.
Membership.objects.create(
user=user3,
tenant=tenant1,
role=Membership.RoleChoices.MEMBER,
)
RefreshToken.for_user(user3)
RefreshToken.for_user(user3)
outstanding_ids = list(
OutstandingToken.objects.filter(user=user3).values_list("id", flat=True)
)
assert len(outstanding_ids) == 2
# Pre-blacklist one of the two tokens to simulate a prior revocation.
BlacklistedToken.objects.create(token_id=outstanding_ids[0])
assert (
BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 1
)
response = authenticated_client.delete(
reverse(
"tenant-membership-detail",
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
blacklisted = BlacklistedToken.objects.filter(token_id__in=outstanding_ids)
assert blacklisted.count() == 2
assert set(blacklisted.values_list("token_id", flat=True)) == set(
outstanding_ids
)
def test_expel_user_keeps_account_if_has_other_memberships(
self, authenticated_client, tenants_fixture, extra_users
):
tenant1, tenant2, _ = tenants_fixture
_, user3_membership = extra_users
user3, membership3 = user3_membership
# Give user3 an additional membership in tenant1 so they are not orphaned
other_membership = Membership.objects.create(
user=user3,
tenant=tenant1,
role=Membership.RoleChoices.MEMBER,
)
response = authenticated_client.delete(
reverse(
"tenant-membership-detail",
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Membership.objects.filter(id=membership3.id).exists()
assert User.objects.filter(id=user3.id).exists()
assert Membership.objects.filter(id=other_membership.id).exists()
def test_tenants_delete_another_membership_as_owner(
self, authenticated_client, tenants_fixture, extra_users
@@ -882,6 +1043,128 @@ class TestTenantViewSet:
assert response.status_code == status.HTTP_404_NOT_FOUND
assert Membership.objects.filter(id=other_membership.id).exists()
def test_delete_membership_cleans_up_orphaned_role_grants(
self, authenticated_client, tenants_fixture
):
"""Test that deleting a membership removes UserRoleRelationship records
for that tenant while preserving grants in other tenants."""
tenant1, tenant2, _ = tenants_fixture
# Create a user with memberships in both tenants
user = User.objects.create_user(
name="Multi-tenant User",
password=TEST_PASSWORD,
email="multitenant@test.com",
)
# Create memberships in both tenants
Membership.objects.create(
user=user, tenant=tenant1, role=Membership.RoleChoices.MEMBER
)
membership2 = Membership.objects.create(
user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER
)
# Create roles in both tenants
role1 = Role.objects.create(
name="Test Role 1", tenant=tenant1, manage_providers=True
)
role2 = Role.objects.create(
name="Test Role 2", tenant=tenant2, manage_scans=True
)
# Create user role relationships for both tenants
UserRoleRelationship.objects.create(user=user, role=role1, tenant=tenant1)
UserRoleRelationship.objects.create(user=user, role=role2, tenant=tenant2)
# Verify initial state
assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists()
assert UserRoleRelationship.objects.filter(user=user, tenant=tenant2).exists()
assert Role.objects.filter(id=role1.id).exists()
assert Role.objects.filter(id=role2.id).exists()
# Delete membership from tenant2 (authenticated user is owner of tenant2)
response = authenticated_client.delete(
reverse(
"tenant-membership-detail",
kwargs={"tenant_pk": tenant2.id, "pk": membership2.id},
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify the membership was deleted
assert not Membership.objects.filter(id=membership2.id).exists()
# Verify UserRoleRelationship for tenant2 was deleted
assert not UserRoleRelationship.objects.filter(
user=user, tenant=tenant2
).exists()
# Verify UserRoleRelationship for tenant1 is preserved
assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists()
# Verify orphaned role2 was deleted (no more user or invitation relationships)
assert not Role.objects.filter(id=role2.id).exists()
# Verify role1 is preserved (still has user relationship)
assert Role.objects.filter(id=role1.id).exists()
# Verify the user still exists (has other memberships)
assert User.objects.filter(id=user.id).exists()
def test_delete_membership_preserves_role_with_invitation_relationship(
self, authenticated_client, tenants_fixture
):
"""Test that roles are not deleted if they have invitation relationships."""
_, tenant2, _ = tenants_fixture
# Create a user with membership
user = User.objects.create_user(
name="Test User", password=TEST_PASSWORD, email="testuser@test.com"
)
membership = Membership.objects.create(
user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER
)
# Create a role and user relationship
role = Role.objects.create(
name="Shared Role", tenant=tenant2, manage_providers=True
)
UserRoleRelationship.objects.create(user=user, role=role, tenant=tenant2)
# Create an invitation with the same role
invitation = Invitation.objects.create(email="pending@test.com", tenant=tenant2)
InvitationRoleRelationship.objects.create(
invitation=invitation, role=role, tenant=tenant2
)
# Verify initial state
assert UserRoleRelationship.objects.filter(user=user, role=role).exists()
assert InvitationRoleRelationship.objects.filter(
invitation=invitation, role=role
).exists()
assert Role.objects.filter(id=role.id).exists()
# Delete the membership
response = authenticated_client.delete(
reverse(
"tenant-membership-detail",
kwargs={"tenant_pk": tenant2.id, "pk": membership.id},
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify UserRoleRelationship was deleted
assert not UserRoleRelationship.objects.filter(user=user, role=role).exists()
# Verify role is preserved because invitation relationship exists
assert Role.objects.filter(id=role.id).exists()
assert InvitationRoleRelationship.objects.filter(
invitation=invitation, role=role
).exists()
def test_tenants_list_no_permissions(
self, authenticated_client_no_permissions_rbac, tenants_fixture
):
@@ -3830,6 +4113,51 @@ class TestScanViewSet:
assert cd.startswith('attachment; filename="')
assert cd.endswith(f'filename="{fname.name}"')
def test_cis_no_output(self, authenticated_client, scans_fixture):
"""CIS PDF endpoint must 404 when the scan has no output_location."""
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
scan.output_location = ""
scan.save()
url = reverse("scan-cis", kwargs={"pk": scan.id})
resp = authenticated_client.get(url)
assert resp.status_code == status.HTTP_404_NOT_FOUND
assert (
resp.json()["errors"]["detail"]
== "The scan has no reports, or the CIS report generation task has not started yet."
)
def test_cis_local_file(self, authenticated_client, scans_fixture, monkeypatch):
"""CIS PDF endpoint must serve the latest generated PDF."""
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
base = tmp_path / "reports"
cis_dir = base / "cis"
cis_dir.mkdir(parents=True, exist_ok=True)
fname = cis_dir / "prowler-output-aws-20260101000000_cis_report.pdf"
fname.write_bytes(b"%PDF-1.4 fake pdf")
scan.output_location = str(base / "scan.zip")
scan.save()
monkeypatch.setattr(
glob,
"glob",
lambda p: [str(fname)] if p.endswith("*_cis_report.pdf") else [],
)
url = reverse("scan-cis", kwargs={"pk": scan.id})
resp = authenticated_client.get(url)
assert resp.status_code == status.HTTP_200_OK
assert resp["Content-Type"] == "application/pdf"
cd = resp["Content-Disposition"]
assert cd.startswith('attachment; filename="')
assert cd.endswith(f'filename="{fname.name}"')
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.TaskSerializer")
def test__get_task_status_returns_none_if_task_not_executing(
+152 -4
View File
@@ -83,6 +83,10 @@ from rest_framework.permissions import SAFE_METHODS
from rest_framework_json_api import filters as jsonapi_filters
from rest_framework_json_api.views import RelationshipView, Response
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
OutstandingToken,
)
from tasks.beat import schedule_provider_scan
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.export import get_s3_client
@@ -169,6 +173,7 @@ from api.models import (
FindingGroupDailySummary,
Integration,
Invitation,
InvitationRoleRelationship,
LighthouseConfiguration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
@@ -1330,9 +1335,11 @@ class MembershipViewSet(BaseTenantViewset):
),
destroy=extend_schema(
summary="Delete tenant memberships",
description="Delete the membership details of users in a tenant. You need to be one of the owners to delete a "
"membership that is not yours. If you are the last owner of a tenant, you cannot delete your own "
"membership.",
description="Delete a user's membership from a tenant. This action: (1) removes the membership, "
"(2) revokes all refresh tokens for the expelled user, (3) removes their role grants for this tenant, "
"(4) cleans up orphaned roles, and (5) deletes the user account if this was their last membership. "
"You must be a tenant owner to delete another user's membership. The last owner of a tenant cannot "
"delete their own membership.",
tags=["Tenant"],
),
)
@@ -1341,6 +1348,7 @@ class TenantMembersViewSet(BaseTenantViewset):
http_method_names = ["get", "delete"]
serializer_class = MembershipSerializer
queryset = Membership.objects.none()
filterset_class = MembershipFilter
# Authorization is handled by get_requesting_membership (owner/member checks),
# not by RBAC, since the target tenant differs from the JWT tenant.
required_permissions = []
@@ -1398,7 +1406,84 @@ class TenantMembersViewSet(BaseTenantViewset):
"You do not have permission to delete this membership."
)
membership_to_delete.delete()
user_to_check_id = membership_to_delete.user_id
tenant_id = membership_to_delete.tenant_id
# All writes run on the admin connection so that the uncommitted
# membership delete is visible to the subsequent "other memberships"
# check. Splitting the delete and the check across the default
# (prowler_user, RLS) and admin connections caused the admin side to
# miss the just-deleted row and leave the User row orphaned.
with transaction.atomic(using=MainRouter.admin_db):
Membership.objects.using(MainRouter.admin_db).filter(
id=membership_to_delete.id
).delete()
# Remove role grants for this user in this tenant to prevent
# orphaned permissions that could allow access after expulsion
deleted_role_relationships = UserRoleRelationship.objects.using(
MainRouter.admin_db
).filter(user_id=user_to_check_id, tenant_id=tenant_id)
# Collect role IDs that might become orphaned after deletion
role_ids_to_check = list(
deleted_role_relationships.values_list("role_id", flat=True)
)
# Delete the user role relationships for this tenant
deleted_role_relationships.delete()
# Clean up orphaned roles that have no remaining user or invitation relationships
if role_ids_to_check:
for role_id in role_ids_to_check:
has_user_relationships = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role_id=role_id)
.exists()
)
has_invitation_relationships = (
InvitationRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role_id=role_id)
.exists()
)
if not has_user_relationships and not has_invitation_relationships:
Role.objects.using(MainRouter.admin_db).filter(
id=role_id
).delete()
# Revoke any refresh tokens the expelled user still holds so they
# cannot mint fresh access tokens. This must happen before the
# User row is deleted, because OutstandingToken.user is
# on_delete=SET_NULL in djangorestframework-simplejwt 5.5.1
# (see rest_framework_simplejwt/token_blacklist/models.py): once
# the user row is gone, user_id becomes NULL and we can no longer
# look up that user's outstanding tokens. Access tokens already
# issued remain valid until SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"]
# expires.
outstanding_token_ids = list(
OutstandingToken.objects.using(MainRouter.admin_db)
.filter(user_id=user_to_check_id)
.values_list("id", flat=True)
)
if outstanding_token_ids:
BlacklistedToken.objects.using(MainRouter.admin_db).bulk_create(
[
BlacklistedToken(token_id=token_id)
for token_id in outstanding_token_ids
],
ignore_conflicts=True,
)
has_other_memberships = (
Membership.objects.using(MainRouter.admin_db)
.filter(user_id=user_to_check_id)
.exists()
)
if not has_other_memberships:
User.objects.using(MainRouter.admin_db).filter(
id=user_to_check_id
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1841,6 +1926,27 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
),
},
),
cis=extend_schema(
tags=["Scan"],
summary="Retrieve CIS Benchmark compliance report",
description="Download the CIS Benchmark compliance report as a PDF file. "
"When a provider ships multiple CIS versions, the report is generated "
"for the highest available version.",
request=None,
responses={
200: OpenApiResponse(
description="PDF file containing the CIS compliance report"
),
202: OpenApiResponse(description="The task is in progress"),
401: OpenApiResponse(
description="API key missing or user not Authenticated"
),
403: OpenApiResponse(description="There is a problem with credentials"),
404: OpenApiResponse(
description="The scan has no CIS reports, or the CIS report generation task has not started yet"
),
},
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
@@ -1909,6 +2015,9 @@ class ScanViewSet(BaseRLSViewSet):
elif self.action == "csa":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
elif self.action == "cis":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return super().get_serializer_class()
def partial_update(self, request, *args, **kwargs):
@@ -2151,6 +2260,45 @@ class ScanViewSet(BaseRLSViewSet):
content, filename = loader
return self._serve_file(content, filename, "text/csv")
@action(
detail=True,
methods=["get"],
url_name="cis",
)
def cis(self, request, pk=None):
scan = self.get_object()
running_resp = self._get_task_status(scan)
if running_resp:
return running_resp
if not scan.output_location:
return Response(
{
"detail": "The scan has no reports, or the CIS report generation task has not started yet."
},
status=status.HTTP_404_NOT_FOUND,
)
if scan.output_location.startswith("s3://"):
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
prefix = os.path.join(
os.path.dirname(key_prefix),
"cis",
"*_cis_report.pdf",
)
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
else:
base = os.path.dirname(scan.output_location)
pattern = os.path.join(base, "cis", "*_cis_report.pdf")
loader = self._load_file(pattern, s3=False)
if isinstance(loader, Response):
return loader
content, filename = loader
return self._serve_file(content, filename, "application/pdf")
@action(
detail=True,
methods=["get"],
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -313,3 +313,16 @@ def sync_aws_account(
)
return failed_syncs
def extract_short_uid(uid: str) -> str:
"""Return the short identifier from an AWS ARN or resource ID.
Supported inputs end in one of:
- `<type>/<id>` (e.g. `instance/i-xxx`)
- `<type>:<id>` (e.g. `function:name`)
- `<id>` (e.g. `bucket-name` or `i-xxx`)
If `uid` is already a short resource ID, it is returned unchanged.
"""
return uid.rsplit("/", 1)[-1].rsplit(":", 1)[-1]
@@ -37,6 +37,8 @@ class ProviderConfig:
# Label for resources connected to the account node, enabling indexed finding lookups.
resource_label: str # e.g., "_AWSResource"
ingestion_function: Callable
# Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance).
short_uid_extractor: Callable[[str], str]
# Provider Configurations
@@ -48,6 +50,7 @@ AWS_CONFIG = ProviderConfig(
uid_field="arn",
resource_label="_AWSResource",
ingestion_function=aws.start_aws_ingestion,
short_uid_extractor=aws.extract_short_uid,
)
PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
@@ -116,6 +119,21 @@ def get_provider_resource_label(provider_type: str) -> str:
return config.resource_label if config else "_UnknownProviderResource"
def _identity_short_uid(uid: str) -> str:
"""Fallback short-uid extractor for providers without a custom mapping."""
return uid
def get_short_uid_extractor(provider_type: str) -> Callable[[str], str]:
"""Get the short-uid extractor for a provider type.
Returns an identity function when the provider is unknown, so callers can
rely on a callable always being returned.
"""
config = PROVIDER_CONFIGS.get(provider_type)
return config.short_uid_extractor if config else _identity_short_uid
# Dynamic Isolation Label Helpers
# --------------------------------
@@ -8,7 +8,7 @@ This module handles:
"""
from collections import defaultdict
from typing import Any, Generator
from typing import Any, Callable, Generator
from uuid import UUID
import neo4j
@@ -21,6 +21,7 @@ from tasks.jobs.attack_paths.config import (
get_node_uid_field,
get_provider_resource_label,
get_root_node_label,
get_short_uid_extractor,
)
from tasks.jobs.attack_paths.queries import (
ADD_RESOURCE_LABEL_TEMPLATE,
@@ -57,7 +58,9 @@ _DB_QUERY_FIELDS = [
]
def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]:
def _to_neo4j_dict(
record: dict[str, Any], resource_uid: str, resource_short_uid: str
) -> dict[str, Any]:
"""Transform a Django `.values()` record into a `dict` ready for Neo4j ingestion."""
return {
"id": str(record["id"]),
@@ -75,6 +78,7 @@ def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]:
"muted": record["muted"],
"muted_reason": record["muted_reason"],
"resource_uid": resource_uid,
"resource_short_uid": resource_short_uid,
}
@@ -170,6 +174,8 @@ def load_findings(
batch_num = 0
total_records = 0
edges_merged = 0
edges_dropped = 0
for batch in findings_batches:
batch_num += 1
batch_size = len(batch)
@@ -178,9 +184,15 @@ def load_findings(
parameters["findings_data"] = batch
logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
neo4j_session.run(query, parameters)
summary = neo4j_session.run(query, parameters).single()
if summary is not None:
edges_merged += summary.get("merged_count", 0)
edges_dropped += summary.get("dropped_count", 0)
logger.info(f"Finished loading {total_records} records in {batch_num} batches")
logger.info(
f"Finished loading {total_records} records in {batch_num} batches "
f"(edges_merged={edges_merged}, edges_dropped={edges_dropped})"
)
return total_records
@@ -205,8 +217,9 @@ def stream_findings_with_resources(
)
tenant_id = prowler_api_provider.tenant_id
short_uid_extractor = get_short_uid_extractor(prowler_api_provider.provider)
for batch in _paginate_findings(tenant_id, scan_id):
enriched = _enrich_batch_with_resources(batch, tenant_id)
enriched = _enrich_batch_with_resources(batch, tenant_id, short_uid_extractor)
if enriched:
yield enriched
@@ -269,6 +282,7 @@ def _fetch_findings_batch(
def _enrich_batch_with_resources(
findings_batch: list[dict[str, Any]],
tenant_id: str,
short_uid_extractor: Callable[[str], str],
) -> list[dict[str, Any]]:
"""
Enrich findings with their resource UIDs.
@@ -280,7 +294,7 @@ def _enrich_batch_with_resources(
resource_map = _build_finding_resource_map(finding_ids, tenant_id)
return [
_to_neo4j_dict(finding, resource_uid)
_to_neo4j_dict(finding, resource_uid, short_uid_extractor(resource_uid))
for finding in findings_batch
for resource_uid in resource_map.get(finding["id"], [])
]
@@ -35,46 +35,56 @@ INSERT_FINDING_TEMPLATE = f"""
UNWIND $findings_data AS finding_data
OPTIONAL MATCH (resource_by_uid:__RESOURCE_LABEL__ {{__NODE_UID_FIELD__: finding_data.resource_uid}})
WITH finding_data, resource_by_uid
OPTIONAL MATCH (resource_by_id:__RESOURCE_LABEL__ {{id: finding_data.resource_uid}})
WHERE resource_by_uid IS NULL
WITH finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource
WHERE resource IS NOT NULL
OPTIONAL MATCH (resource_by_short:__RESOURCE_LABEL__ {{id: finding_data.resource_short_uid}})
WHERE resource_by_uid IS NULL AND resource_by_id IS NULL
WITH finding_data,
resource_by_uid,
resource_by_id,
head(collect(resource_by_short)) AS resource_by_short
WITH finding_data,
COALESCE(resource_by_uid, resource_by_id, resource_by_short) AS resource
MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}})
ON CREATE SET
finding.id = finding_data.id,
finding.uid = finding_data.uid,
finding.inserted_at = finding_data.inserted_at,
finding.updated_at = finding_data.updated_at,
finding.first_seen_at = finding_data.first_seen_at,
finding.scan_id = finding_data.scan_id,
finding.delta = finding_data.delta,
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.severity = finding_data.severity,
finding.check_id = finding_data.check_id,
finding.check_title = finding_data.check_title,
finding.muted = finding_data.muted,
finding.muted_reason = finding_data.muted_reason,
finding.firstseen = timestamp(),
finding.lastupdated = $last_updated,
finding._module_name = 'cartography:prowler',
finding._module_version = $prowler_version
ON MATCH SET
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.lastupdated = $last_updated
FOREACH (_ IN CASE WHEN resource IS NOT NULL THEN [1] ELSE [] END |
MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}})
ON CREATE SET
finding.id = finding_data.id,
finding.uid = finding_data.uid,
finding.inserted_at = finding_data.inserted_at,
finding.updated_at = finding_data.updated_at,
finding.first_seen_at = finding_data.first_seen_at,
finding.scan_id = finding_data.scan_id,
finding.delta = finding_data.delta,
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.severity = finding_data.severity,
finding.check_id = finding_data.check_id,
finding.check_title = finding_data.check_title,
finding.muted = finding_data.muted,
finding.muted_reason = finding_data.muted_reason,
finding.firstseen = timestamp(),
finding.lastupdated = $last_updated,
finding._module_name = 'cartography:prowler',
finding._module_version = $prowler_version
ON MATCH SET
finding.status = finding_data.status,
finding.status_extended = finding_data.status_extended,
finding.lastupdated = $last_updated
MERGE (resource)-[rel:HAS_FINDING]->(finding)
ON CREATE SET
rel.firstseen = timestamp(),
rel.lastupdated = $last_updated,
rel._module_name = 'cartography:prowler',
rel._module_version = $prowler_version
ON MATCH SET
rel.lastupdated = $last_updated
)
MERGE (resource)-[rel:HAS_FINDING]->(finding)
ON CREATE SET
rel.firstseen = timestamp(),
rel.lastupdated = $last_updated,
rel._module_name = 'cartography:prowler',
rel._module_version = $prowler_version
ON MATCH SET
rel.lastupdated = $last_updated
WITH sum(CASE WHEN resource IS NOT NULL THEN 1 ELSE 0 END) AS merged_count,
sum(CASE WHEN resource IS NULL THEN 1 ELSE 0 END) AS dropped_count
RETURN merged_count, dropped_count
"""
# Internet queries (used by internet.py)
+223 -10
View File
@@ -1,3 +1,6 @@
import gc
import re
from collections.abc import Iterable
from pathlib import Path
from shutil import rmtree
@@ -6,6 +9,7 @@ from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3
from tasks.jobs.reports import (
FRAMEWORK_REGISTRY,
CISReportGenerator,
CSAReportGenerator,
ENSReportGenerator,
NIS2ReportGenerator,
@@ -17,10 +21,53 @@ from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.models import Provider, ScanSummary, ThreatScoreSnapshot
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.finding import Finding as FindingOutput
logger = get_task_logger(__name__)
# Matches CIS compliance_ids like "cis_1.4_aws", "cis_5.0_azure",
# "cis_1.10_kubernetes", "cis_3.0.1_aws". Requires at least one dotted
# component so malformed inputs like "cis_._aws" or "cis_5._aws" are rejected
# at the regex stage, rather than by a later ValueError fallback.
_CIS_VARIANT_RE = re.compile(r"^cis_(?P<version>\d+(?:\.\d+)+)_(?P<provider>.+)$")
def _pick_latest_cis_variant(compliance_ids: Iterable[str]) -> str | None:
"""Return the CIS compliance_id with the highest semantic version.
CIS ships many variants per provider (e.g. cis_1.4_aws, ..., cis_6.0_aws).
A lexicographic sort is incorrect for version strings like ``1.10`` vs
``1.2``; this helper parses the version into a tuple of ints so ``1.10``
is correctly ordered after ``1.2``. Malformed names are skipped so a
broken JSON cannot crash the whole CIS pipeline.
Args:
compliance_ids: Iterable of CIS compliance identifiers. Expected to
belong to a single provider (callers should pass the already
filtered keys from ``Compliance.get_bulk(provider_type)``).
Returns:
The compliance_id with the highest parsed version, or ``None`` if no
well-formed CIS identifier was found.
"""
best_key: tuple[int, ...] | None = None
best_name: str | None = None
for name in compliance_ids:
match = _CIS_VARIANT_RE.match(name)
if not match:
continue
try:
key = tuple(int(part) for part in match.group("version").split("."))
except ValueError:
# Defensive: the regex already guarantees numeric chunks, but we
# keep the guard so a future regex change cannot crash callers.
continue
if best_key is None or key > best_key:
best_key = key
best_name = name
return best_name
def generate_threatscore_report(
tenant_id: str,
@@ -191,6 +238,53 @@ def generate_csa_report(
)
def generate_cis_report(
tenant_id: str,
scan_id: str,
compliance_id: str,
output_path: str,
provider_id: str,
only_failed: bool = True,
include_manual: bool = False,
provider_obj: Provider | None = None,
requirement_statistics: dict[str, dict[str, int]] | None = None,
findings_cache: dict[str, list[FindingOutput]] | None = None,
) -> None:
"""
Generate a PDF compliance report for a specific CIS Benchmark variant.
Unlike single-version frameworks (ENS, NIS2, CSA), CIS has multiple
variants per provider (e.g., cis_1.4_aws, cis_5.0_aws, cis_6.0_aws). This
wrapper is called once per variant, receiving the specific compliance_id.
Args:
tenant_id: The tenant ID for Row-Level Security context.
scan_id: ID of the scan executed by Prowler.
compliance_id: ID of the specific CIS variant (e.g., "cis_5.0_aws").
output_path: Output PDF file path.
provider_id: Provider ID for the scan.
only_failed: If True, only include failed requirements in detailed section.
include_manual: If True, include manual requirements in detailed section.
provider_obj: Pre-fetched Provider object to avoid duplicate queries.
requirement_statistics: Pre-aggregated requirement statistics.
findings_cache: Cache of already loaded findings to avoid duplicate queries.
"""
generator = CISReportGenerator(FRAMEWORK_REGISTRY["cis"])
generator.generate(
tenant_id=tenant_id,
scan_id=scan_id,
compliance_id=compliance_id,
output_path=output_path,
provider_id=provider_id,
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
only_failed=only_failed,
include_manual=include_manual,
)
def generate_compliance_reports(
tenant_id: str,
scan_id: str,
@@ -199,6 +293,7 @@ def generate_compliance_reports(
generate_ens: bool = True,
generate_nis2: bool = True,
generate_csa: bool = True,
generate_cis: bool = True,
only_failed_threatscore: bool = True,
min_risk_level_threatscore: int = 4,
include_manual_ens: bool = True,
@@ -206,6 +301,8 @@ def generate_compliance_reports(
only_failed_nis2: bool = True,
only_failed_csa: bool = True,
include_manual_csa: bool = False,
only_failed_cis: bool = True,
include_manual_cis: bool = False,
) -> dict[str, dict[str, bool | str]]:
"""
Generate multiple compliance reports with shared database queries.
@@ -215,6 +312,13 @@ def generate_compliance_reports(
- Aggregating requirement statistics once (shared across all reports)
- Reusing compliance framework data when possible
For CIS a single PDF is produced per run: the one matching the highest
available CIS version for the scan's provider (picked dynamically from
``Compliance.get_bulk`` via :func:`_pick_latest_cis_variant`). The
returned ``results["cis"]`` entry has the same flat shape as the other
single-version frameworks — the picked variant is an internal detail,
not surfaced in the result.
Args:
tenant_id: The tenant ID for Row-Level Security context.
scan_id: The ID of the scan to generate reports for.
@@ -223,6 +327,8 @@ def generate_compliance_reports(
generate_ens: Whether to generate ENS report.
generate_nis2: Whether to generate NIS2 report.
generate_csa: Whether to generate CSA CCM report.
generate_cis: Whether to generate a CIS Benchmark report for the
latest CIS version available for the provider.
only_failed_threatscore: For ThreatScore, only include failed requirements.
min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements.
include_manual_ens: For ENS, include manual requirements.
@@ -230,22 +336,26 @@ def generate_compliance_reports(
only_failed_nis2: For NIS2, only include failed requirements.
only_failed_csa: For CSA CCM, only include failed requirements.
include_manual_csa: For CSA CCM, include manual requirements.
only_failed_cis: For CIS, only include failed requirements in detailed section.
include_manual_cis: For CIS, include manual requirements in detailed section.
Returns:
Dictionary with results for each report type.
Dictionary with results for each report type. Every value has the
same flat shape: ``{"upload": bool, "path": str, "error"?: str}``.
"""
logger.info(
"Generating compliance reports for scan %s with provider %s"
" (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s)",
" (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s, CIS: %s)",
scan_id,
provider_id,
generate_threatscore,
generate_ens,
generate_nis2,
generate_csa,
generate_cis,
)
results = {}
results: dict = {}
# Validate that the scan has findings and get provider info
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
@@ -259,6 +369,8 @@ def generate_compliance_reports(
results["nis2"] = {"upload": False, "path": ""}
if generate_csa:
results["csa"] = {"upload": False, "path": ""}
if generate_cis:
results["cis"] = {"upload": False, "path": ""}
return results
provider_obj = Provider.objects.get(id=provider_id)
@@ -299,11 +411,18 @@ def generate_compliance_reports(
results["csa"] = {"upload": False, "path": ""}
generate_csa = False
# For CIS we do NOT pre-check the provider against a hard-coded whitelist
# (that list drifts the moment a new CIS JSON ships). Instead, we let
# `_pick_latest_cis_variant` over `Compliance.get_bulk(provider_type)`
# return None for providers that lack CIS, and treat that as "nothing to
# do" below.
if (
not generate_threatscore
and not generate_ens
and not generate_nis2
and not generate_csa
and not generate_cis
):
return results
@@ -350,6 +469,13 @@ def generate_compliance_reports(
scan_id,
compliance_framework="csa",
)
cis_path = _generate_compliance_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY,
provider_uid,
tenant_id,
scan_id,
compliance_framework="cis",
)
out_dir = str(Path(threatscore_path).parent.parent)
except Exception as e:
logger.error("Error generating output directory: %s", e)
@@ -362,6 +488,8 @@ def generate_compliance_reports(
results["nis2"] = error_dict.copy()
if generate_csa:
results["csa"] = error_dict.copy()
if generate_cis:
results["cis"] = error_dict.copy()
return results
# Generate ThreatScore report
@@ -569,12 +697,92 @@ def generate_compliance_reports(
logger.error("Error generating CSA CCM report: %s", e)
results["csa"] = {"upload": False, "path": "", "error": str(e)}
# Clean up temporary files if all reports were uploaded successfully
all_uploaded = all(
result.get("upload", False)
for result in results.values()
if result.get("upload") is not None
)
# Generate CIS Benchmark report for the latest available version only.
# CIS ships multiple versions per provider (e.g. cis_1.4_aws, cis_5.0_aws,
# cis_6.0_aws); we dynamically pick the highest semantic version at run
# time rather than hard-coding a per-provider mapping. `Compliance.get_bulk`
# is the single source of truth for which providers have CIS.
if generate_cis:
latest_cis: str | None = None
try:
frameworks_bulk = Compliance.get_bulk(provider_type)
latest_cis = _pick_latest_cis_variant(
name for name in frameworks_bulk.keys() if name.startswith("cis_")
)
except Exception as e:
logger.error("Error discovering CIS variants for %s: %s", provider_type, e)
results["cis"] = {"upload": False, "path": "", "error": str(e)}
if "cis" not in results:
if latest_cis is None:
logger.info("No CIS variants available for provider %s", provider_type)
results["cis"] = {"upload": False, "path": ""}
else:
logger.info(
"Selected latest CIS variant for provider %s: %s",
provider_type,
latest_cis,
)
pdf_path_cis = f"{cis_path}_cis_report.pdf"
try:
generate_cis_report(
tenant_id=tenant_id,
scan_id=scan_id,
compliance_id=latest_cis,
output_path=pdf_path_cis,
provider_id=provider_id,
only_failed=only_failed_cis,
include_manual=include_manual_cis,
provider_obj=provider_obj,
requirement_statistics=requirement_statistics,
findings_cache=findings_cache,
)
upload_uri_cis = _upload_to_s3(
tenant_id,
scan_id,
pdf_path_cis,
f"cis/{Path(pdf_path_cis).name}",
)
if upload_uri_cis:
results["cis"] = {
"upload": True,
"path": upload_uri_cis,
}
logger.info(
"CIS report %s uploaded to %s",
latest_cis,
upload_uri_cis,
)
else:
results["cis"] = {"upload": False, "path": out_dir}
logger.warning(
"CIS report %s saved locally at %s",
latest_cis,
out_dir,
)
except Exception as e:
logger.error("Error generating CIS report %s: %s", latest_cis, e)
results["cis"] = {
"upload": False,
"path": "",
"error": str(e),
}
finally:
# Free ReportLab/matplotlib memory before moving on.
gc.collect()
# Clean up temporary files only if every requested report has been
# successfully uploaded. All result entries now share the same flat
# shape, so the check is a single comprehension.
upload_flags = [
bool(entry.get("upload", False))
for entry in results.values()
if isinstance(entry, dict) and entry.get("upload") is not None
]
all_uploaded = bool(upload_flags) and all(upload_flags)
if all_uploaded:
try:
@@ -595,6 +803,7 @@ def generate_compliance_reports_job(
generate_ens: bool = True,
generate_nis2: bool = True,
generate_csa: bool = True,
generate_cis: bool = True,
) -> dict[str, dict[str, bool | str]]:
"""
Celery task wrapper for generate_compliance_reports.
@@ -607,9 +816,12 @@ def generate_compliance_reports_job(
generate_ens: Whether to generate ENS report.
generate_nis2: Whether to generate NIS2 report.
generate_csa: Whether to generate CSA CCM report.
generate_cis: Whether to generate the CIS Benchmark report for the
latest CIS version available for the provider.
Returns:
Dictionary with results for each report type.
Dictionary with results for each report type. Every entry shares the
same flat ``{"upload", "path", "error"?}`` shape.
"""
return generate_compliance_reports(
tenant_id=tenant_id,
@@ -619,4 +831,5 @@ def generate_compliance_reports_job(
generate_ens=generate_ens,
generate_nis2=generate_nis2,
generate_csa=generate_csa,
generate_cis=generate_cis,
)
@@ -17,6 +17,9 @@ from .charts import (
get_chart_color_for_percentage,
)
# Framework-specific generators
from .cis import CISReportGenerator
# Reusable components
# Reusable components: Color helpers, Badge components, Risk component,
# Table components, Section components
@@ -31,10 +34,12 @@ from .components import (
create_section_header,
create_status_badge,
create_summary_table,
escape_html,
get_color_for_compliance,
get_color_for_risk_level,
get_color_for_weight,
get_status_color,
truncate_text,
)
# Framework configuration: Main configuration, Color constants, ENS colors,
@@ -90,8 +95,6 @@ from .config import (
FrameworkConfig,
get_framework_config,
)
# Framework-specific generators
from .csa import CSAReportGenerator
from .ens import ENSReportGenerator
from .nis2 import NIS2ReportGenerator
@@ -109,6 +112,7 @@ __all__ = [
"ENSReportGenerator",
"NIS2ReportGenerator",
"CSAReportGenerator",
"CISReportGenerator",
# Configuration
"FrameworkConfig",
"FRAMEWORK_REGISTRY",
@@ -182,6 +186,9 @@ __all__ = [
# Section components
"create_section_header",
"create_summary_table",
# Text helpers
"truncate_text",
"escape_html",
# Chart functions
"get_chart_color_for_percentage",
"create_vertical_bar_chart",
+755
View File
@@ -0,0 +1,755 @@
import os
import re
from collections import defaultdict
from typing import Any
from reportlab.lib.units import inch
from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
from api.models import StatusChoices
from .base import (
BaseComplianceReportGenerator,
ComplianceData,
RequirementData,
get_requirement_metadata,
)
from .charts import (
create_horizontal_bar_chart,
create_pie_chart,
create_stacked_bar_chart,
get_chart_color_for_percentage,
)
from .components import ColumnConfig, create_data_table, escape_html, truncate_text
from .config import (
CHART_COLOR_GREEN_1,
CHART_COLOR_RED,
CHART_COLOR_YELLOW,
COLOR_BG_BLUE,
COLOR_BLUE,
COLOR_BORDER_GRAY,
COLOR_DARK_GRAY,
COLOR_GRAY,
COLOR_GRID_GRAY,
COLOR_HIGH_RISK,
COLOR_LIGHT_BLUE,
COLOR_SAFE,
COLOR_WHITE,
)
# Ordered buckets used both in the executive summary tables and the charts
# section. Exposed as module constants so the two call sites never drift.
_PROFILE_BUCKET_ORDER: tuple[str, ...] = ("L1", "L2", "Other")
_ASSESSMENT_BUCKET_ORDER: tuple[str, ...] = ("Automated", "Manual")
# Anchored matchers for profile normalization — substring checks on "L1"/"L2"
# would happily match unrelated tokens like "CL2 Worker" or "HL2" coming from
# future CIS profile enum values.
_LEVEL_2_RE = re.compile(r"(?:\bLevel\s*2\b|\bL2\b|Level_2)")
_LEVEL_1_RE = re.compile(r"(?:\bLevel\s*1\b|\bL1\b|Level_1)")
def _normalize_profile(profile: Any) -> str:
"""Bucket a CIS Profile enum/string into one of: ``L1``, ``L2``, ``Other``.
The ``CIS_Requirement_Attribute_Profile`` enum has values like
``"Level 1"``, ``"Level 2"``, ``"E3 Level 1"``, ``"E5 Level 2"``. We
collapse them into three buckets to keep charts and badges readable
across CIS variants, using anchored regex matches so that future enum
values cannot accidentally promote e.g. ``"CL2 Worker"`` into ``L2``.
Args:
profile: The profile value (enum member, string, or ``None``).
Returns:
One of ``"L1"``, ``"L2"``, ``"Other"``.
"""
if profile is None:
return "Other"
value = getattr(profile, "value", None) or str(profile)
if _LEVEL_2_RE.search(value):
return "L2"
if _LEVEL_1_RE.search(value):
return "L1"
return "Other"
def _profile_badge_text(bucket: str) -> str:
"""Map a normalized profile bucket (L1/L2/Other) to a short badge label."""
return {"L1": "Level 1", "L2": "Level 2"}.get(bucket, "Other")
# =============================================================================
# CIS Report Generator
# =============================================================================
class CISReportGenerator(BaseComplianceReportGenerator):
"""
PDF report generator for CIS (Center for Internet Security) Benchmarks.
CIS differs from single-version frameworks (ENS, NIS2, CSA) in that:
- Each provider has multiple CIS versions (e.g. AWS: 1.4, 1.5, ..., 6.0).
- Section names differ across versions and providers and MUST be derived
at runtime from the loaded compliance data.
- Requirements carry Profile (Level 1/Level 2) and AssessmentStatus
(Automated/Manual) attributes that drive the executive summary and
charts.
This generator produces:
- Cover page with Prowler logo and dynamic CIS version/provider metadata
- Executive summary with overall compliance score, counts, and breakdowns
by Profile and AssessmentStatus
- Charts: overall status pie, pass rate by section (horizontal bar),
Level 1 vs Level 2 pass/fail distribution (stacked bar)
- Requirements index grouped by dynamic section
- Detailed findings for FAIL requirements with CIS-specific audit /
remediation / rationale details
"""
# Per-run memoization cache for ``_compute_statistics``. ``generate()``
# is the public entry point and is called once per PDF, so scoping the
# cache to the last seen ComplianceData instance is enough to avoid the
# double computation between executive summary and charts section.
_stats_cache_key: int | None = None
_stats_cache_value: dict | None = None
# Body section ordering — ensure every top-level section starts on its
# own clean page. The base class only puts a PageBreak AFTER Charts and
# Requirements Index, so Executive Summary and Charts end up sharing a
# page. This override prepends a PageBreak so Compliance Analysis always
# begins on a fresh page.
def _build_body_sections(self, data: ComplianceData) -> list:
return [PageBreak(), *super()._build_body_sections(data)]
# -------------------------------------------------------------------------
# Cover page override — shows dynamic CIS version + provider in the title
# -------------------------------------------------------------------------
def create_cover_page(self, data: ComplianceData) -> list:
"""Create the CIS report cover page with Prowler + CIS logos side by side."""
elements = []
# Create logos side by side (same pattern as NIS2 / ENS)
prowler_logo_path = os.path.join(
os.path.dirname(__file__), "../../assets/img/prowler_logo.png"
)
cis_logo_path = os.path.join(
os.path.dirname(__file__), "../../assets/img/cis_logo.png"
)
if os.path.exists(cis_logo_path):
prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch)
cis_logo = Image(cis_logo_path, width=2.3 * inch, height=1.1 * inch)
logos_table = Table(
[[prowler_logo, cis_logo]], colWidths=[4 * inch, 2.5 * inch]
)
logos_table.setStyle(
TableStyle(
[
("ALIGN", (0, 0), (0, 0), "LEFT"),
("ALIGN", (1, 0), (1, 0), "RIGHT"),
("VALIGN", (0, 0), (0, 0), "MIDDLE"),
("VALIGN", (1, 0), (1, 0), "MIDDLE"),
]
)
)
elements.append(logos_table)
elif os.path.exists(prowler_logo_path):
# Fallback: only the Prowler logo if the CIS asset is missing
elements.append(Image(prowler_logo_path, width=5 * inch, height=1 * inch))
elements.append(Spacer(1, 0.5 * inch))
# Dynamic title: "CIS Benchmark v5.0 — AWS Compliance Report"
provider_label = ""
if data.provider_obj:
provider_label = f"{data.provider_obj.provider.upper()}"
title_text = (
f"CIS Benchmark v{data.version}{provider_label}<br/>Compliance Report"
)
elements.append(Paragraph(title_text, self.styles["title"]))
elements.append(Spacer(1, 0.5 * inch))
# Metadata table via base class helper
info_rows = self._build_info_rows(data, language=self.config.language)
metadata_data = []
for label, value in info_rows:
if label in ("Name:", "Description:") and value:
metadata_data.append(
[label, Paragraph(str(value), self.styles["normal_center"])]
)
else:
metadata_data.append([label, value])
metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch])
metadata_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE),
("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE),
("FONTNAME", (0, 0), (0, -1), "FiraCode"),
("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE),
("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY),
("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("FONTSIZE", (0, 0), (-1, -1), 11),
("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
elements.append(metadata_table)
return elements
# -------------------------------------------------------------------------
# Executive Summary
# -------------------------------------------------------------------------
def create_executive_summary(self, data: ComplianceData) -> list:
"""Create the CIS executive summary section."""
elements = []
elements.append(Paragraph("Executive Summary", self.styles["h1"]))
elements.append(Spacer(1, 0.1 * inch))
stats = self._compute_statistics(data)
# --- Summary metrics table ---
summary_data = [
["Metric", "Value"],
["Total Requirements", str(stats["total"])],
["Passed", str(stats["passed"])],
["Failed", str(stats["failed"])],
["Manual", str(stats["manual"])],
["Overall Compliance", f"{stats['overall_compliance']:.1f}%"],
]
summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
summary_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE),
("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE),
("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK),
("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE),
("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY),
("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE),
("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"),
("FONTSIZE", (0, 0), (-1, 0), 12),
("FONTSIZE", (0, 1), (-1, -1), 10),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
("BOTTOMPADDING", (0, 0), (-1, 0), 10),
(
"ROWBACKGROUNDS",
(1, 1),
(1, -1),
[COLOR_WHITE, COLOR_BG_BLUE],
),
]
)
)
elements.append(summary_table)
elements.append(Spacer(1, 0.25 * inch))
# --- Profile breakdown table ---
elements.append(Paragraph("Breakdown by Profile", self.styles["h2"]))
elements.append(Spacer(1, 0.1 * inch))
profile_counts = stats["profile_counts"]
profile_table_data = [["Profile", "Passed", "Failed", "Manual", "Total"]]
for bucket in _PROFILE_BUCKET_ORDER:
counts = profile_counts.get(bucket, {"passed": 0, "failed": 0, "manual": 0})
total = counts["passed"] + counts["failed"] + counts["manual"]
if total == 0:
continue
profile_table_data.append(
[
_profile_badge_text(bucket),
str(counts["passed"]),
str(counts["failed"]),
str(counts["manual"]),
str(total),
]
)
profile_table = Table(
profile_table_data,
colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch],
)
profile_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTSIZE", (0, 1), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
(
"ROWBACKGROUNDS",
(0, 1),
(-1, -1),
[COLOR_WHITE, COLOR_BG_BLUE],
),
]
)
)
elements.append(profile_table)
elements.append(Spacer(1, 0.25 * inch))
# --- Assessment status breakdown ---
elements.append(Paragraph("Breakdown by Assessment Status", self.styles["h2"]))
elements.append(Spacer(1, 0.1 * inch))
assessment_counts = stats["assessment_counts"]
assessment_table_data = [["Assessment", "Passed", "Failed", "Manual", "Total"]]
for bucket in _ASSESSMENT_BUCKET_ORDER:
counts = assessment_counts.get(
bucket, {"passed": 0, "failed": 0, "manual": 0}
)
total = counts["passed"] + counts["failed"] + counts["manual"]
if total == 0:
continue
assessment_table_data.append(
[
bucket,
str(counts["passed"]),
str(counts["failed"]),
str(counts["manual"]),
str(total),
]
)
assessment_table = Table(
assessment_table_data,
colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch],
)
assessment_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), COLOR_LIGHT_BLUE),
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTSIZE", (0, 1), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
(
"ROWBACKGROUNDS",
(0, 1),
(-1, -1),
[COLOR_WHITE, COLOR_BG_BLUE],
),
]
)
)
elements.append(assessment_table)
elements.append(Spacer(1, 0.25 * inch))
# --- Top 5 failing sections ---
top_failing = stats["top_failing_sections"]
if top_failing:
elements.append(
Paragraph("Top Sections with Lowest Compliance", self.styles["h2"])
)
elements.append(Spacer(1, 0.1 * inch))
top_table_data = [["Section", "Passed", "Failed", "Compliance"]]
for section_label, section_stats in top_failing:
passed = section_stats["passed"]
failed = section_stats["failed"]
total = passed + failed
pct = (passed / total * 100) if total > 0 else 100
top_table_data.append(
[
truncate_text(section_label, 55),
str(passed),
str(failed),
f"{pct:.1f}%",
]
)
top_table = Table(
top_table_data,
colWidths=[3.5 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch],
)
top_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK),
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTSIZE", (0, 1), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
(
"ROWBACKGROUNDS",
(0, 1),
(-1, -1),
[COLOR_WHITE, COLOR_BG_BLUE],
),
]
)
)
elements.append(top_table)
return elements
# -------------------------------------------------------------------------
# Charts section
# -------------------------------------------------------------------------
def create_charts_section(self, data: ComplianceData) -> list:
"""Create the CIS charts section."""
elements = []
elements.append(Paragraph("Compliance Analysis", self.styles["h1"]))
elements.append(Spacer(1, 0.1 * inch))
# --- Pie chart: overall Pass / Fail / Manual ---
stats = self._compute_statistics(data)
pie_labels = []
pie_values = []
pie_colors = []
if stats["passed"] > 0:
pie_labels.append(f"Pass ({stats['passed']})")
pie_values.append(stats["passed"])
pie_colors.append(CHART_COLOR_GREEN_1)
if stats["failed"] > 0:
pie_labels.append(f"Fail ({stats['failed']})")
pie_values.append(stats["failed"])
pie_colors.append(CHART_COLOR_RED)
if stats["manual"] > 0:
pie_labels.append(f"Manual ({stats['manual']})")
pie_values.append(stats["manual"])
pie_colors.append(CHART_COLOR_YELLOW)
if pie_values:
elements.append(Paragraph("Overall Status Distribution", self.styles["h2"]))
elements.append(Spacer(1, 0.1 * inch))
pie_buffer = create_pie_chart(
labels=pie_labels,
values=pie_values,
colors=pie_colors,
)
pie_buffer.seek(0)
elements.append(Image(pie_buffer, width=4.5 * inch, height=4.5 * inch))
elements.append(Spacer(1, 0.2 * inch))
# --- Horizontal bar: pass rate by section ---
section_stats = stats["section_stats"]
if section_stats:
elements.append(PageBreak())
elements.append(Paragraph("Compliance by Section", self.styles["h1"]))
elements.append(Spacer(1, 0.1 * inch))
elements.append(
Paragraph(
"The following chart shows compliance percentage for each CIS "
"section based on automated checks:",
self.styles["normal_center"],
)
)
elements.append(Spacer(1, 0.1 * inch))
# Sort sections by pass rate descending for readability
sorted_sections = sorted(
section_stats.items(),
key=lambda item: (
(item[1]["passed"] / (item[1]["passed"] + item[1]["failed"]) * 100)
if (item[1]["passed"] + item[1]["failed"]) > 0
else 100
),
reverse=True,
)
bar_labels = []
bar_values = []
for section_label, section_data in sorted_sections:
total = section_data["passed"] + section_data["failed"]
if total == 0:
continue
pct = (section_data["passed"] / total) * 100
bar_labels.append(truncate_text(section_label, 60))
bar_values.append(pct)
if bar_values:
bar_buffer = create_horizontal_bar_chart(
labels=bar_labels,
values=bar_values,
xlabel="Compliance (%)",
color_func=get_chart_color_for_percentage,
label_fontsize=9,
)
bar_buffer.seek(0)
elements.append(Image(bar_buffer, width=6.5 * inch, height=5 * inch))
# --- Stacked bar: Level 1 vs Level 2 pass/fail ---
profile_counts = stats["profile_counts"]
has_profile_data = any(
(counts["passed"] + counts["failed"]) > 0
for counts in profile_counts.values()
)
if has_profile_data:
elements.append(PageBreak())
elements.append(Paragraph("Profile Breakdown", self.styles["h1"]))
elements.append(Spacer(1, 0.1 * inch))
elements.append(
Paragraph(
"Distribution of Pass / Fail / Manual across CIS profile levels.",
self.styles["normal_center"],
)
)
elements.append(Spacer(1, 0.1 * inch))
profile_labels = []
pass_series = []
fail_series = []
manual_series = []
for bucket in _PROFILE_BUCKET_ORDER:
counts = profile_counts.get(bucket)
if not counts:
continue
total = counts["passed"] + counts["failed"] + counts["manual"]
if total == 0:
continue
profile_labels.append(_profile_badge_text(bucket))
pass_series.append(counts["passed"])
fail_series.append(counts["failed"])
manual_series.append(counts["manual"])
if profile_labels:
stacked_buffer = create_stacked_bar_chart(
labels=profile_labels,
data_series={
"Pass": pass_series,
"Fail": fail_series,
"Manual": manual_series,
},
xlabel="Profile",
ylabel="Requirements",
)
stacked_buffer.seek(0)
elements.append(Image(stacked_buffer, width=6 * inch, height=4 * inch))
return elements
# -------------------------------------------------------------------------
# Requirements Index
# -------------------------------------------------------------------------
def create_requirements_index(self, data: ComplianceData) -> list:
"""Create the CIS requirements index grouped by dynamic section."""
elements = []
elements.append(Paragraph("Requirements Index", self.styles["h1"]))
elements.append(Spacer(1, 0.1 * inch))
sections = self._derive_sections(data)
by_section: dict[str, list[dict]] = defaultdict(list)
for req in data.requirements:
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
section = "Other"
profile_bucket = "Other"
assessment = ""
if meta:
section = getattr(meta, "Section", "Other") or "Other"
profile_bucket = _normalize_profile(getattr(meta, "Profile", None))
assessment_enum = getattr(meta, "AssessmentStatus", None)
assessment = getattr(assessment_enum, "value", None) or str(
assessment_enum or ""
)
by_section[section].append(
{
"id": req.id,
"description": truncate_text(req.description, 80),
"profile": _profile_badge_text(profile_bucket),
"assessment": assessment or "-",
"status": (req.status or "").upper(),
}
)
columns = [
ColumnConfig("ID", 0.9 * inch, "id", align="LEFT"),
ColumnConfig("Description", 3.0 * inch, "description", align="LEFT"),
ColumnConfig("Profile", 0.9 * inch, "profile"),
ColumnConfig("Assessment", 1 * inch, "assessment"),
ColumnConfig("Status", 0.9 * inch, "status"),
]
for section in sections:
rows = by_section.get(section, [])
if not rows:
continue
elements.append(Paragraph(truncate_text(section, 90), self.styles["h2"]))
elements.append(Spacer(1, 0.05 * inch))
table = create_data_table(
data=rows,
columns=columns,
header_color=self.config.primary_color,
normal_style=self.styles["normal_center"],
)
elements.append(table)
elements.append(Spacer(1, 0.15 * inch))
return elements
# -------------------------------------------------------------------------
# Detailed findings hook — inject CIS-specific rationale / audit content
# -------------------------------------------------------------------------
def _render_requirement_detail_extras(
self, req: RequirementData, data: ComplianceData
) -> list:
"""Render CIS rationale, impact, audit, remediation and references."""
extras = []
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
if meta is None:
return extras
field_map = [
("Rationale", "RationaleStatement"),
("Impact", "ImpactStatement"),
("Audit Procedure", "AuditProcedure"),
("Remediation", "RemediationProcedure"),
("References", "References"),
]
for label, attr_name in field_map:
value = getattr(meta, attr_name, None)
if not value:
continue
text = str(value).strip()
if not text:
continue
extras.append(Paragraph(f"<b>{label}:</b>", self.styles["h3"]))
extras.append(Paragraph(escape_html(text), self.styles["normal"]))
extras.append(Spacer(1, 0.08 * inch))
return extras
# -------------------------------------------------------------------------
# Private helpers
# -------------------------------------------------------------------------
def _derive_sections(self, data: ComplianceData) -> list[str]:
"""Extract ordered unique Section names from loaded compliance data."""
seen: dict[str, bool] = {}
for req in data.requirements:
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
if meta is None:
continue
section = getattr(meta, "Section", None) or "Other"
if section not in seen:
seen[section] = True
return list(seen.keys())
def _compute_statistics(self, data: ComplianceData) -> dict:
"""Aggregate all statistics needed for summary and charts.
Memoized per-``ComplianceData`` instance via ``_stats_cache_*``: the
executive summary and the charts section both need the same numbers,
so they would otherwise re-iterate the requirements twice. We key on
``id(data)`` because ``ComplianceData`` is a dataclass and its
instances are not hashable.
Returns a dict with:
- total, passed, failed, manual: int
- overall_compliance: float (percentage)
- profile_counts: {"L1": {"passed", "failed", "manual"}, ...}
- assessment_counts: {"Automated": {...}, "Manual": {...}}
- section_stats: {section_name: {"passed", "failed", "manual"}, ...}
- top_failing_sections: list[(section_name, stats)] (up to 5)
"""
cache_key = id(data)
if self._stats_cache_key == cache_key and self._stats_cache_value is not None:
return self._stats_cache_value
stats = self._compute_statistics_uncached(data)
self._stats_cache_key = cache_key
self._stats_cache_value = stats
return stats
def _compute_statistics_uncached(self, data: ComplianceData) -> dict:
"""Actual aggregation kernel; call ``_compute_statistics`` instead."""
total = len(data.requirements)
passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS)
failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL)
manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL)
evaluated = passed + failed
overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100.0
profile_counts: dict[str, dict[str, int]] = {
"L1": {"passed": 0, "failed": 0, "manual": 0},
"L2": {"passed": 0, "failed": 0, "manual": 0},
"Other": {"passed": 0, "failed": 0, "manual": 0},
}
assessment_counts: dict[str, dict[str, int]] = {
"Automated": {"passed": 0, "failed": 0, "manual": 0},
"Manual": {"passed": 0, "failed": 0, "manual": 0},
}
section_stats: dict[str, dict[str, int]] = defaultdict(
lambda: {"passed": 0, "failed": 0, "manual": 0}
)
for req in data.requirements:
meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
if meta is None:
continue
profile_bucket = _normalize_profile(getattr(meta, "Profile", None))
assessment_enum = getattr(meta, "AssessmentStatus", None)
assessment_value = getattr(assessment_enum, "value", None) or str(
assessment_enum or ""
)
assessment_bucket = (
"Automated" if assessment_value == "Automated" else "Manual"
)
section = getattr(meta, "Section", None) or "Other"
status_key = {
StatusChoices.PASS: "passed",
StatusChoices.FAIL: "failed",
StatusChoices.MANUAL: "manual",
}.get(req.status)
if status_key is None:
continue
profile_counts[profile_bucket][status_key] += 1
assessment_counts[assessment_bucket][status_key] += 1
section_stats[section][status_key] += 1
# Top 5 sections with lowest pass rate (only sections with evaluated reqs)
def _section_rate(item):
_, stats_ = item
evaluated_ = stats_["passed"] + stats_["failed"]
if evaluated_ == 0:
return 101 # sort evaluated=0 to the bottom
return stats_["passed"] / evaluated_ * 100
top_failing_sections = sorted(
(
item
for item in section_stats.items()
if (item[1]["passed"] + item[1]["failed"]) > 0
),
key=_section_rate,
)[:5]
return {
"total": total,
"passed": passed,
"failed": failed,
"manual": manual,
"overall_compliance": overall_compliance,
"profile_counts": profile_counts,
"assessment_counts": assessment_counts,
"section_stats": dict(section_stats),
"top_failing_sections": top_failing_sections,
}
@@ -26,6 +26,52 @@ from .config import (
)
def truncate_text(text: str, max_len: int) -> str:
"""Truncate ``text`` to ``max_len`` characters, appending an ellipsis if cut.
Used by report generators that need to squeeze long descriptions, section
titles or finding titles into a fixed-width table cell.
Args:
text: Source string. ``None`` and non-string values are treated as empty.
max_len: Maximum output length including the ellipsis. Values < 4 are
clamped so the result never grows beyond ``max_len``.
Returns:
The original string if short enough, otherwise ``text[: max_len - 3] + "..."``.
When ``max_len < 4`` a plain substring of length ``max_len`` is returned
so callers never get a string longer than they asked for.
"""
if not text:
return ""
text = str(text)
if len(text) <= max_len:
return text
if max_len < 4:
return text[:max_len]
return text[: max_len - 3] + "..."
def escape_html(text: str) -> str:
"""Escape the minimal HTML entities required for safe ReportLab Paragraph rendering.
ReportLab's ``Paragraph`` parses a small HTML subset, so raw ``<``, ``>``
and ``&`` in user-provided content (rationale, remediation, etc.) would
break layout or be interpreted as tags. This helper mirrors
``html.escape`` but avoids pulling in the stdlib dependency and keeps the
output deterministic.
Args:
text: Untrusted source string.
Returns:
A string safe to embed inside a ReportLab Paragraph.
"""
return (
str(text or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
def get_color_for_risk_level(risk_level: int) -> colors.Color:
"""
Get color based on risk level.
@@ -313,6 +313,32 @@ FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = {
has_niveles=False,
has_weight=False,
),
"cis": FrameworkConfig(
name="cis",
display_name="CIS Benchmark",
logo_filename=None,
primary_color=COLOR_BLUE,
secondary_color=COLOR_LIGHT_BLUE,
bg_color=COLOR_BG_BLUE,
attribute_fields=[
"Section",
"SubSection",
"Profile",
"AssessmentStatus",
"Description",
"RationaleStatement",
"ImpactStatement",
"RemediationProcedure",
"AuditProcedure",
"References",
],
sections=None, # Derived dynamically per CIS variant (section names differ across versions/providers)
language="en",
has_risk_levels=False,
has_dimensions=False,
has_niveles=False,
has_weight=False,
),
}
@@ -336,5 +362,7 @@ def get_framework_config(compliance_id: str) -> FrameworkConfig | None:
return FRAMEWORK_REGISTRY["nis2"]
if "csa" in compliance_lower or "ccm" in compliance_lower:
return FRAMEWORK_REGISTRY["csa_ccm"]
if compliance_lower.startswith("cis_") or "cis" in compliance_lower:
return FRAMEWORK_REGISTRY["cis"]
return None
+3
View File
@@ -1198,6 +1198,9 @@ def aggregate_findings(tenant_id: str, scan_id: str):
)
for agg in aggregation
}
# Delete first so re-runs (e.g. post-mute reaggregation) don't hit
# the `unique_scan_summary` constraint.
ScanSummary.objects.filter(tenant_id=tenant_id, scan_id=scan_id).delete()
ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000)
+33 -10
View File
@@ -771,15 +771,22 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
)
@set_tenant(keep_tenant=True)
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
"""Reaggregate finding group summaries for every (provider, day) combination.
"""Reaggregate every pre-aggregated summary table for this tenant.
Mirrors the unbounded scope of `mute_historical_findings_task`: that task
rewrites every Finding row whose UID matches a mute rule, with no time
limit. To keep the daily summaries consistent with that update, this task
re-runs the aggregator on the latest completed scan of every (provider,
day) pair that exists in the database. Tasks are dispatched in parallel
via a Celery group so the wallclock scales with the worker pool, not with
the number of pairs.
limit. To keep the pre-aggregated tables consistent with that update,
this task re-runs the same per-scan aggregation pipeline that scan
completion runs on the latest completed scan of every (provider, day)
pair, rebuilding the three tables that power the read endpoints:
- `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`,
`/overviews/findings-severity`, `/overviews/services`.
- `FindingGroupDailySummary` -> `/finding-groups` and
`/finding-groups/latest`.
Per-scan pipelines are dispatched in parallel via a Celery group so
wallclock scales with the worker pool.
"""
completed_scans = list(
Scan.objects.filter(
@@ -804,12 +811,23 @@ def reaggregate_all_finding_group_summaries_task(tenant_id: str):
scan_ids = list(latest_scans.values())
if scan_ids:
logger.info(
"Reaggregating finding group summaries for %d scans (provider x day)",
"Reaggregating overview/finding summaries for %d scans (provider x day)",
len(scan_ids),
)
# DailySeveritySummary reads from ScanSummary, so ScanSummary must be
# recomputed first; FindingGroupDailySummary reads from Finding
# directly and can run in parallel with the severity step.
group(
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id, scan_id=scan_id
),
),
)
for scan_id in scan_ids
).apply_async()
@@ -982,13 +1000,17 @@ def jira_integration_task(
@handle_provider_deletion
def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str):
"""
Optimized task to generate ThreatScore, ENS, NIS2, and CSA CCM reports with shared queries.
Optimized task to generate ThreatScore, ENS, NIS2, CSA CCM and CIS reports with shared queries.
This task is more efficient than running separate report tasks because it reuses database queries:
- Provider object fetched once (instead of multiple times)
- Requirement statistics aggregated once (instead of multiple times)
- Can reduce database load by up to 50-70%
CIS emits a single PDF per run: the one matching the highest CIS version
available for the scan's provider, picked dynamically from
``Compliance.get_bulk`` (no hard-coded provider version mapping).
Args:
tenant_id (str): The tenant identifier.
scan_id (str): The scan identifier.
@@ -1005,6 +1027,7 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
generate_ens=True,
generate_nis2=True,
generate_csa=True,
generate_cis=True,
)
@@ -1285,6 +1285,12 @@ class TestAttackPathsFindingsHelpers:
config = SimpleNamespace(update_tag=12345)
mock_session = MagicMock()
first_result = MagicMock()
first_result.single.return_value = {"merged_count": 1, "dropped_count": 0}
second_result = MagicMock()
second_result.single.return_value = {"merged_count": 0, "dropped_count": 1}
mock_session.run.side_effect = [first_result, second_result]
with (
patch(
"tasks.jobs.attack_paths.findings.get_node_uid_field",
@@ -1294,6 +1300,7 @@ class TestAttackPathsFindingsHelpers:
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
return_value="_AWSResource",
),
patch("tasks.jobs.attack_paths.findings.logger") as mock_logger,
):
findings_module.load_findings(
mock_session, findings_generator(), provider, config
@@ -1305,6 +1312,14 @@ class TestAttackPathsFindingsHelpers:
assert params["last_updated"] == config.update_tag
assert "findings_data" in params
summary_log = next(
call_args.args[0]
for call_args in mock_logger.info.call_args_list
if call_args.args and "Finished loading" in call_args.args[0]
)
assert "edges_merged=1" in summary_log
assert "edges_dropped=1" in summary_log
def test_stream_findings_with_resources_returns_latest_scan_data(
self,
tenants_fixture,
@@ -1484,11 +1499,12 @@ class TestAttackPathsFindingsHelpers:
"default",
):
result = findings_module._enrich_batch_with_resources(
[finding_dict], str(tenant.id)
[finding_dict], str(tenant.id), lambda uid: f"short:{uid}"
)
assert len(result) == 1
assert result[0]["resource_uid"] == resource.uid
assert result[0]["resource_short_uid"] == f"short:{resource.uid}"
assert result[0]["id"] == str(finding.id)
assert result[0]["status"] == "FAIL"
@@ -1572,7 +1588,7 @@ class TestAttackPathsFindingsHelpers:
"default",
):
result = findings_module._enrich_batch_with_resources(
[finding_dict], str(tenant.id)
[finding_dict], str(tenant.id), lambda uid: uid
)
assert len(result) == 3
@@ -1646,7 +1662,7 @@ class TestAttackPathsFindingsHelpers:
patch("tasks.jobs.attack_paths.findings.logger") as mock_logger,
):
result = findings_module._enrich_batch_with_resources(
[finding_dict], str(tenant.id)
[finding_dict], str(tenant.id), lambda uid: uid
)
assert len(result) == 0
@@ -1693,6 +1709,63 @@ class TestAttackPathsFindingsHelpers:
mock_session.run.assert_not_called()
@pytest.mark.parametrize(
"uid, expected",
[
(
"arn:aws:ec2:us-east-1:552455647653:instance/i-05075b63eb51baacb",
"i-05075b63eb51baacb",
),
(
"arn:aws:ec2:us-east-1:123456789012:volume/vol-0abcd1234ef567890",
"vol-0abcd1234ef567890",
),
(
"arn:aws:ec2:us-east-1:123456789012:security-group/sg-0123abcd",
"sg-0123abcd",
),
("arn:aws:s3:::my-bucket-name", "my-bucket-name"),
("arn:aws:iam::123456789012:role/MyRole", "MyRole"),
(
"arn:aws:lambda:us-east-1:123456789012:function:my-function",
"my-function",
),
("i-05075b63eb51baacb", "i-05075b63eb51baacb"),
],
)
def test_extract_short_uid_aws_variants(self, uid, expected):
from tasks.jobs.attack_paths.aws import extract_short_uid
assert extract_short_uid(uid) == expected
def test_insert_finding_template_has_short_id_fallback(self):
from tasks.jobs.attack_paths.queries import (
INSERT_FINDING_TEMPLATE,
render_cypher_template,
)
rendered = render_cypher_template(
INSERT_FINDING_TEMPLATE,
{
"__NODE_UID_FIELD__": "arn",
"__RESOURCE_LABEL__": "_AWSResource",
},
)
assert (
"resource_by_uid:_AWSResource {arn: finding_data.resource_uid}" in rendered
)
assert "resource_by_id:_AWSResource {id: finding_data.resource_uid}" in rendered
assert (
"resource_by_short:_AWSResource {id: finding_data.resource_short_uid}"
in rendered
)
assert "head(collect(resource_by_short)) AS resource_by_short" in rendered
assert (
"COALESCE(resource_by_uid, resource_by_id, resource_by_short)" in rendered
)
assert "RETURN merged_count, dropped_count" in rendered
class TestAddResourceLabel:
def test_add_resource_label_applies_private_label(self):
+265 -1
View File
@@ -4,7 +4,11 @@ from unittest.mock import Mock, patch
import matplotlib
import pytest
from reportlab.lib import colors
from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report
from tasks.jobs.report import (
_pick_latest_cis_variant,
generate_compliance_reports,
generate_threatscore_report,
)
from tasks.jobs.reports import (
CHART_COLOR_GREEN_1,
CHART_COLOR_GREEN_2,
@@ -422,6 +426,266 @@ class TestGenerateComplianceReportsOptimized:
mock_ens.assert_not_called()
mock_nis2.assert_not_called()
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
def test_no_findings_returns_flat_cis_entry(
self,
mock_cis,
mock_upload,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Scan with no findings and ``generate_cis=True`` must yield a flat
``{"upload": False, "path": ""}`` entry, consistent with the other
frameworks (no nested dict, no sentinel keys)."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
result = generate_compliance_reports(
tenant_id=str(tenant.id),
scan_id=str(scan.id),
provider_id=str(provider.id),
generate_threatscore=False,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=True,
)
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
@pytest.mark.django_db
class TestGenerateComplianceReportsCIS:
"""Test suite covering the CIS branch of generate_compliance_reports."""
def _force_scan_has_findings(self, monkeypatch):
"""Bypass the ScanSummary.exists() early-return guard."""
class _FakeManager:
def filter(self, **kwargs):
class _Q:
def exists(self):
return True
return _Q()
monkeypatch.setattr("tasks.jobs.report.ScanSummary.objects", _FakeManager())
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@patch("tasks.jobs.report.Compliance.get_bulk")
def test_cis_picks_latest_version(
self,
mock_get_bulk,
mock_cis,
mock_upload,
mock_stats,
monkeypatch,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""CIS branch should generate a single PDF for the highest version.
The returned ``results["cis"]`` must have the same flat shape as the
other single-version frameworks (``{"upload", "path"}``) the picked
variant is an internal detail and is not exposed in the result.
"""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
# Multiple CIS variants + a non-CIS framework that must be ignored.
# Includes 1.10 to verify the selection is not lexicographic.
mock_get_bulk.return_value = {
"cis_1.4_aws": Mock(),
"cis_1.10_aws": Mock(),
"cis_2.0_aws": Mock(),
"cis_5.0_aws": Mock(),
"ens_rd2022_aws": Mock(),
}
mock_upload.return_value = "s3://bucket/path"
result = generate_compliance_reports(
tenant_id=str(tenant.id),
scan_id=str(scan.id),
provider_id=str(provider.id),
generate_threatscore=False,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=True,
)
# Exactly one call for the latest version, never for older variants
# or non-CIS frameworks.
assert mock_cis.call_count == 1
assert mock_cis.call_args.kwargs["compliance_id"] == "cis_5.0_aws"
assert result["cis"]["upload"] is True
assert result["cis"]["path"] == "s3://bucket/path"
assert "compliance_id" not in result["cis"]
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@patch("tasks.jobs.report.Compliance.get_bulk")
def test_cis_latest_variant_failure_captured_in_results(
self,
mock_get_bulk,
mock_cis,
mock_upload,
mock_stats,
monkeypatch,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""A failure in the latest CIS variant must be surfaced in the flat results entry."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
mock_get_bulk.return_value = {
"cis_1.4_aws": Mock(),
"cis_5.0_aws": Mock(),
}
mock_cis.side_effect = RuntimeError("boom")
result = generate_compliance_reports(
tenant_id=str(tenant.id),
scan_id=str(scan.id),
provider_id=str(provider.id),
generate_threatscore=False,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=True,
)
# Only the latest variant is attempted; its failure lands in a flat
# entry keyed under "cis" with the same shape as sibling frameworks.
assert mock_cis.call_count == 1
assert result["cis"]["upload"] is False
assert result["cis"]["error"] == "boom"
assert "compliance_id" not in result["cis"]
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@patch("tasks.jobs.report.Compliance.get_bulk")
def test_cis_provider_without_cis_skipped_cleanly(
self,
mock_get_bulk,
mock_cis,
mock_upload,
mock_stats,
monkeypatch,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""When ``Compliance.get_bulk`` returns no CIS entry the CIS branch
must skip cleanly and record a flat ``{"upload": False, "path": ""}``
entry no hard-coded provider whitelist is consulted."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
# No ``cis_*`` keys in the bulk → no variant picked.
mock_get_bulk.return_value = {"ens_rd2022_aws": Mock()}
result = generate_compliance_reports(
tenant_id=str(tenant.id),
scan_id=str(scan.id),
provider_id=str(provider.id),
generate_threatscore=False,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=True,
)
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
class TestPickLatestCisVariant:
"""Unit tests for `_pick_latest_cis_variant` helper."""
def test_empty_returns_none(self):
assert _pick_latest_cis_variant([]) is None
def test_single_variant(self):
assert _pick_latest_cis_variant(["cis_5.0_aws"]) == "cis_5.0_aws"
def test_numeric_not_lexicographic(self):
"""1.10 must beat 1.2 (lex sort would pick 1.2)."""
variants = ["cis_1.2_kubernetes", "cis_1.10_kubernetes"]
assert _pick_latest_cis_variant(variants) == "cis_1.10_kubernetes"
def test_major_version_wins(self):
variants = ["cis_1.4_aws", "cis_2.0_aws", "cis_5.0_aws", "cis_6.0_aws"]
assert _pick_latest_cis_variant(variants) == "cis_6.0_aws"
def test_minor_version_breaks_tie(self):
variants = ["cis_3.0_aws", "cis_3.1_aws", "cis_2.9_aws"]
assert _pick_latest_cis_variant(variants) == "cis_3.1_aws"
def test_three_part_version(self):
"""Versions like 3.0.1 must win over 3.0."""
variants = ["cis_3.0_aws", "cis_3.0.1_aws"]
assert _pick_latest_cis_variant(variants) == "cis_3.0.1_aws"
def test_malformed_names_ignored(self):
variants = ["notcis_1.0_aws", "cis_abc_aws", "cis_5.0_aws"]
assert _pick_latest_cis_variant(variants) == "cis_5.0_aws"
def test_only_malformed_returns_none(self):
variants = ["notcis_1.0_aws", "cis_abc_aws"]
assert _pick_latest_cis_variant(variants) is None
def test_multidigit_provider_name(self):
"""Provider name with underscores (e.g. googleworkspace) must parse."""
variants = ["cis_1.3_googleworkspace"]
assert _pick_latest_cis_variant(variants) == "cis_1.3_googleworkspace"
def test_accepts_iterator(self):
"""The helper must accept any iterable, not just lists."""
def _gen():
yield "cis_1.4_aws"
yield "cis_5.0_aws"
assert _pick_latest_cis_variant(_gen()) == "cis_5.0_aws"
def test_rejects_single_integer_version(self):
"""The regex requires at least one dotted component. ``cis_5_aws``
without a minor version is malformed per the backend contract."""
assert _pick_latest_cis_variant(["cis_5_aws"]) is None
def test_rejects_trailing_dot(self):
"""Inputs like ``cis_5._aws`` must be rejected at the regex stage
instead of silently normalising to ``(5, 0)``."""
assert _pick_latest_cis_variant(["cis_5._aws", "cis_1.0_aws"]) == "cis_1.0_aws"
def test_rejects_lone_dot_version(self):
"""``cis_._aws`` has no numeric component and must be skipped."""
assert _pick_latest_cis_variant(["cis_._aws", "cis_1.0_aws"]) == "cis_1.0_aws"
class TestOptimizationImprovements:
"""Test suite for optimization-related functionality."""
@@ -0,0 +1,532 @@
from unittest.mock import Mock, patch
import pytest
from reportlab.platypus import Image, LongTable, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
from tasks.jobs.reports.cis import (
CISReportGenerator,
_normalize_profile,
_profile_badge_text,
)
from api.models import StatusChoices
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def cis_generator():
"""Create a CISReportGenerator instance for testing."""
config = FRAMEWORK_REGISTRY["cis"]
return CISReportGenerator(config)
def _make_attr(
section: str,
profile_value: str = "Level 1",
assessment_value: str = "Automated",
sub_section: str = "",
**extras,
) -> Mock:
"""Build a mock CIS_Requirement_Attribute with duck-typed fields."""
attr = Mock()
attr.Section = section
attr.SubSection = sub_section
# CIS enums have `.value`. Use a simple Mock that exposes `.value`.
attr.Profile = Mock(value=profile_value)
attr.AssessmentStatus = Mock(value=assessment_value)
attr.Description = extras.get("description", "desc")
attr.RationaleStatement = extras.get("rationale", "the rationale")
attr.ImpactStatement = extras.get("impact", "the impact")
attr.RemediationProcedure = extras.get("remediation", "the remediation")
attr.AuditProcedure = extras.get("audit", "the audit")
attr.AdditionalInformation = ""
attr.DefaultValue = ""
attr.References = extras.get("references", "https://example.com")
return attr
@pytest.fixture
def basic_cis_compliance_data():
"""Create basic ComplianceData for CIS testing (no requirements)."""
return ComplianceData(
tenant_id="tenant-123",
scan_id="scan-456",
provider_id="provider-789",
compliance_id="cis_5.0_aws",
framework="CIS",
name="CIS Amazon Web Services Foundations Benchmark v5.0.0",
version="5.0",
description="Center for Internet Security AWS Foundations Benchmark",
)
@pytest.fixture
def populated_cis_compliance_data(basic_cis_compliance_data):
"""CIS data with mixed requirements across 2 sections, Profile L1/L2, Pass/Fail/Manual."""
data = basic_cis_compliance_data
data.requirements = [
RequirementData(
id="1.1",
description="Maintain current contact details",
status=StatusChoices.PASS,
passed_findings=5,
failed_findings=0,
total_findings=5,
checks=["aws_check_1"],
),
RequirementData(
id="1.2",
description="Ensure root account has no access keys",
status=StatusChoices.FAIL,
passed_findings=0,
failed_findings=3,
total_findings=3,
checks=["aws_check_2"],
),
RequirementData(
id="1.3",
description="Ensure MFA is enabled for all IAM users",
status=StatusChoices.MANUAL,
checks=[],
),
RequirementData(
id="2.1",
description="Ensure S3 Buckets are logging",
status=StatusChoices.PASS,
passed_findings=2,
failed_findings=0,
total_findings=2,
checks=["aws_check_3"],
),
RequirementData(
id="2.2",
description="Ensure encryption at rest is enabled",
status=StatusChoices.FAIL,
passed_findings=0,
failed_findings=4,
total_findings=4,
checks=["aws_check_4"],
),
]
data.attributes_by_requirement_id = {
"1.1": {
"attributes": {
"req_attributes": [
_make_attr(
"1 Identity and Access Management",
profile_value="Level 1",
assessment_value="Automated",
)
],
"checks": ["aws_check_1"],
}
},
"1.2": {
"attributes": {
"req_attributes": [
_make_attr(
"1 Identity and Access Management",
profile_value="Level 1",
assessment_value="Automated",
)
],
"checks": ["aws_check_2"],
}
},
"1.3": {
"attributes": {
"req_attributes": [
_make_attr(
"1 Identity and Access Management",
profile_value="Level 2",
assessment_value="Manual",
)
],
"checks": [],
}
},
"2.1": {
"attributes": {
"req_attributes": [
_make_attr(
"2 Storage",
profile_value="Level 2",
assessment_value="Automated",
)
],
"checks": ["aws_check_3"],
}
},
"2.2": {
"attributes": {
"req_attributes": [
_make_attr(
"2 Storage",
profile_value="Level 1",
assessment_value="Automated",
)
],
"checks": ["aws_check_4"],
}
},
}
return data
# =============================================================================
# Helper function tests
# =============================================================================
class TestNormalizeProfile:
"""Test suite for _normalize_profile helper."""
def test_level_1_string(self):
assert _normalize_profile(Mock(value="Level 1")) == "L1"
def test_level_2_string(self):
assert _normalize_profile(Mock(value="Level 2")) == "L2"
def test_e3_level_1(self):
assert _normalize_profile(Mock(value="E3 Level 1")) == "L1"
def test_e5_level_2(self):
assert _normalize_profile(Mock(value="E5 Level 2")) == "L2"
def test_none_returns_other(self):
assert _normalize_profile(None) == "Other"
def test_substring_trap_rejected(self):
"""Unrelated tokens containing the literal ``L2`` must NOT map to L2."""
# A future enum value like "CL2 Kubernetes Worker" would be silently
# misclassified by a naive substring check.
assert _normalize_profile(Mock(value="CL2 Worker")) == "Other"
assert _normalize_profile(Mock(value="HL2 Legacy")) == "Other"
def test_raw_string_level_1(self):
# Mock without .value falls back to str(profile); use a real string
class NoValue:
def __str__(self):
return "Level 1"
assert _normalize_profile(NoValue()) == "L1"
def test_unknown_profile_returns_other(self):
assert _normalize_profile(Mock(value="Custom Profile")) == "Other"
class TestProfileBadgeText:
def test_l1_label(self):
assert _profile_badge_text("L1") == "Level 1"
def test_l2_label(self):
assert _profile_badge_text("L2") == "Level 2"
def test_other_label(self):
assert _profile_badge_text("Other") == "Other"
# =============================================================================
# Generator initialization
# =============================================================================
class TestCISGeneratorInitialization:
def test_generator_created(self, cis_generator):
assert cis_generator is not None
assert cis_generator.config.name == "cis"
def test_generator_language(self, cis_generator):
assert cis_generator.config.language == "en"
def test_generator_sections_dynamic(self, cis_generator):
# CIS sections differ per variant so config.sections MUST be None
assert cis_generator.config.sections is None
def test_attribute_fields_contain_cis_specific(self, cis_generator):
for field in ("Profile", "AssessmentStatus", "RationaleStatement"):
assert field in cis_generator.config.attribute_fields
# =============================================================================
# _derive_sections
# =============================================================================
class TestDeriveSections:
def test_preserves_first_seen_order(
self, cis_generator, populated_cis_compliance_data
):
sections = cis_generator._derive_sections(populated_cis_compliance_data)
assert sections == [
"1 Identity and Access Management",
"2 Storage",
]
def test_deduplicates_sections(self, cis_generator, basic_cis_compliance_data):
basic_cis_compliance_data.requirements = [
RequirementData(id="1.1", description="a", status=StatusChoices.PASS),
RequirementData(id="1.2", description="b", status=StatusChoices.PASS),
]
attr = _make_attr("1 IAM")
basic_cis_compliance_data.attributes_by_requirement_id = {
"1.1": {"attributes": {"req_attributes": [attr], "checks": []}},
"1.2": {"attributes": {"req_attributes": [attr], "checks": []}},
}
assert cis_generator._derive_sections(basic_cis_compliance_data) == ["1 IAM"]
def test_empty_data_returns_empty(self, cis_generator, basic_cis_compliance_data):
basic_cis_compliance_data.requirements = []
basic_cis_compliance_data.attributes_by_requirement_id = {}
assert cis_generator._derive_sections(basic_cis_compliance_data) == []
# =============================================================================
# _compute_statistics
# =============================================================================
class TestComputeStatistics:
def test_totals(self, cis_generator, populated_cis_compliance_data):
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
assert stats["total"] == 5
assert stats["passed"] == 2
assert stats["failed"] == 2
assert stats["manual"] == 1
def test_overall_compliance_excludes_manual(
self, cis_generator, populated_cis_compliance_data
):
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
# 2 passed / 4 evaluated (pass + fail) = 50%
assert stats["overall_compliance"] == pytest.approx(50.0)
def test_overall_compliance_all_manual(
self, cis_generator, basic_cis_compliance_data
):
basic_cis_compliance_data.requirements = [
RequirementData(id="x", description="d", status=StatusChoices.MANUAL),
]
attr = _make_attr("1 IAM", profile_value="Level 1", assessment_value="Manual")
basic_cis_compliance_data.attributes_by_requirement_id = {
"x": {"attributes": {"req_attributes": [attr], "checks": []}},
}
stats = cis_generator._compute_statistics(basic_cis_compliance_data)
# No evaluated → defaults to 100%
assert stats["overall_compliance"] == 100.0
def test_profile_counts(self, cis_generator, populated_cis_compliance_data):
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
profile = stats["profile_counts"]
# From fixture:
# L1: 1.1 (PASS, Auto), 1.2 (FAIL, Auto), 2.2 (FAIL, Auto) → pass=1, fail=2, manual=0
# L2: 1.3 (MANUAL, Manual), 2.1 (PASS, Auto) → pass=1, fail=0, manual=1
assert profile["L1"] == {"passed": 1, "failed": 2, "manual": 0}
assert profile["L2"] == {"passed": 1, "failed": 0, "manual": 1}
def test_assessment_counts(self, cis_generator, populated_cis_compliance_data):
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
assessment = stats["assessment_counts"]
# Automated: 1.1 PASS, 1.2 FAIL, 2.1 PASS, 2.2 FAIL → pass=2, fail=2, manual=0
# Manual: 1.3 MANUAL → pass=0, fail=0, manual=1
assert assessment["Automated"] == {"passed": 2, "failed": 2, "manual": 0}
assert assessment["Manual"] == {"passed": 0, "failed": 0, "manual": 1}
def test_top_failing_sections_includes_all_evaluated(
self, cis_generator, populated_cis_compliance_data
):
stats = cis_generator._compute_statistics(populated_cis_compliance_data)
top = stats["top_failing_sections"]
# Both sections have 1 PASS + 1 FAIL evaluated → tied at 50%. The
# sort is stable, so both must appear and both must be capped at
# 5 entries.
assert len(top) == 2
section_names = {name for name, _ in top}
assert section_names == {
"1 Identity and Access Management",
"2 Storage",
}
def test_compute_statistics_is_memoized(
self, cis_generator, populated_cis_compliance_data
):
"""Calling ``_compute_statistics`` twice with the same data must
reuse the cached value and not re-run the uncached kernel."""
with patch.object(
CISReportGenerator,
"_compute_statistics_uncached",
wraps=cis_generator._compute_statistics_uncached,
) as spy:
cis_generator._compute_statistics(populated_cis_compliance_data)
cis_generator._compute_statistics(populated_cis_compliance_data)
assert spy.call_count == 1
# =============================================================================
# Executive summary
# =============================================================================
class TestCISExecutiveSummary:
def test_title_present(self, cis_generator, populated_cis_compliance_data):
elements = cis_generator.create_executive_summary(populated_cis_compliance_data)
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
text = " ".join(str(p.text) for p in paragraphs)
assert "Executive Summary" in text
def test_tables_rendered(self, cis_generator, populated_cis_compliance_data):
elements = cis_generator.create_executive_summary(populated_cis_compliance_data)
tables = [e for e in elements if isinstance(e, Table)]
# Exact count: Summary, Profile, Assessment, Top Failing Sections = 4.
assert len(tables) == 4
def test_no_requirements(self, cis_generator, basic_cis_compliance_data):
basic_cis_compliance_data.requirements = []
basic_cis_compliance_data.attributes_by_requirement_id = {}
elements = cis_generator.create_executive_summary(basic_cis_compliance_data)
# With no requirements: Summary table always renders, and both Profile
# and Assessment breakdown tables render with a 0-filled default row,
# but Top Failing Sections is suppressed → exactly 3 tables.
tables = [e for e in elements if isinstance(e, Table)]
assert len(tables) == 3
# =============================================================================
# Charts section
# =============================================================================
class TestCISChartsSection:
def test_charts_rendered(self, cis_generator, populated_cis_compliance_data):
elements = cis_generator.create_charts_section(populated_cis_compliance_data)
# At least 1 image for the pie + 1 for section bar + 1 for stacked
images = [e for e in elements if isinstance(e, Image)]
assert len(images) >= 1
def test_charts_no_data_no_crash(self, cis_generator, basic_cis_compliance_data):
basic_cis_compliance_data.requirements = []
basic_cis_compliance_data.attributes_by_requirement_id = {}
elements = cis_generator.create_charts_section(basic_cis_compliance_data)
# Must not raise; may or may not have any Image
assert isinstance(elements, list)
# =============================================================================
# Requirements index
# =============================================================================
class TestCISRequirementsIndex:
def test_title_present(self, cis_generator, populated_cis_compliance_data):
elements = cis_generator.create_requirements_index(
populated_cis_compliance_data
)
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
text = " ".join(str(p.text) for p in paragraphs)
assert "Requirements Index" in text
def test_groups_by_section(self, cis_generator, populated_cis_compliance_data):
elements = cis_generator.create_requirements_index(
populated_cis_compliance_data
)
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
text = " ".join(str(p.text) for p in paragraphs)
assert "1 Identity and Access Management" in text
assert "2 Storage" in text
def test_renders_tables_per_section(
self, cis_generator, populated_cis_compliance_data
):
elements = cis_generator.create_requirements_index(
populated_cis_compliance_data
)
# One table per section with requirements. ``create_data_table``
# returns a LongTable when the row count exceeds its threshold and a
# plain Table otherwise — both are valid.
tables = [e for e in elements if isinstance(e, (Table, LongTable))]
assert len(tables) == 2
# =============================================================================
# Detailed findings extras hook
# =============================================================================
class TestRenderRequirementDetailExtras:
def test_inserts_all_fields(self, cis_generator, populated_cis_compliance_data):
req = populated_cis_compliance_data.requirements[1] # 1.2 FAIL
extras = cis_generator._render_requirement_detail_extras(
req, populated_cis_compliance_data
)
text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph))
assert "Rationale" in text
assert "Impact" in text
assert "Audit Procedure" in text
assert "Remediation" in text
assert "References" in text
def test_missing_metadata_returns_empty(
self, cis_generator, basic_cis_compliance_data
):
basic_cis_compliance_data.attributes_by_requirement_id = {}
req = RequirementData(id="99", description="unknown", status=StatusChoices.FAIL)
extras = cis_generator._render_requirement_detail_extras(
req, basic_cis_compliance_data
)
assert extras == []
def test_escapes_html_chars(self, cis_generator, basic_cis_compliance_data):
attr = _make_attr(
"1 IAM",
rationale="<script>alert('x')</script>",
)
basic_cis_compliance_data.attributes_by_requirement_id = {
"1.1": {"attributes": {"req_attributes": [attr], "checks": []}}
}
req = RequirementData(id="1.1", description="d", status=StatusChoices.FAIL)
extras = cis_generator._render_requirement_detail_extras(
req, basic_cis_compliance_data
)
text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph))
assert "<script>" not in text
assert "&lt;script&gt;" in text
# =============================================================================
# Cover page
# =============================================================================
class TestCISCoverPage:
@patch("tasks.jobs.reports.cis.Image")
def test_cover_page_has_logo(
self, mock_image, cis_generator, basic_cis_compliance_data
):
elements = cis_generator.create_cover_page(basic_cis_compliance_data)
assert len(elements) > 0
assert mock_image.call_count >= 1
def test_cover_page_title_includes_version(
self, cis_generator, basic_cis_compliance_data
):
elements = cis_generator.create_cover_page(basic_cis_compliance_data)
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
content = " ".join(str(p.text) for p in paragraphs)
assert "CIS Benchmark" in content
assert "5.0" in content
def test_cover_page_title_includes_provider_when_set(
self, cis_generator, basic_cis_compliance_data
):
provider = Mock()
provider.provider = "aws"
provider.uid = "123456789012"
provider.alias = "test-account"
basic_cis_compliance_data.provider_obj = provider
elements = cis_generator.create_cover_page(basic_cis_compliance_data)
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
content = " ".join(str(p.text) for p in paragraphs)
assert "AWS" in content
+59
View File
@@ -36,6 +36,7 @@ from api.models import (
Provider,
Resource,
Scan,
ScanSummary,
StateChoices,
StatusChoices,
)
@@ -3358,6 +3359,64 @@ class TestAggregateFindings:
regions = {s.region for s in summaries}
assert regions == {"us-east-1", "us-west-2"}
def test_aggregate_findings_is_idempotent_on_rerun(
self,
tenants_fixture,
scans_fixture,
findings_fixture,
):
"""Re-running `aggregate_findings` for the same scan must not violate
the `unique_scan_summary` constraint, and the resulting row set for
the scan must match the single-run output. This is exercised by the
post-mute reaggregation pipeline, which re-dispatches
`perform_scan_summary_task` against scans whose summaries already
exist."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
aggregate_findings(str(tenant.id), str(scan.id))
first_run_ids = set(
ScanSummary.all_objects.filter(
tenant_id=tenant.id, scan_id=scan.id
).values_list("id", flat=True)
)
first_run_rows = list(
ScanSummary.all_objects.filter(tenant_id=tenant.id, scan_id=scan.id).values(
"check_id",
"service",
"severity",
"region",
"fail",
"_pass",
"muted",
"total",
)
)
# Second invocation must not raise and must replace the rows without
# leaving duplicates behind.
aggregate_findings(str(tenant.id), str(scan.id))
second_run_ids = set(
ScanSummary.all_objects.filter(
tenant_id=tenant.id, scan_id=scan.id
).values_list("id", flat=True)
)
second_run_rows = list(
ScanSummary.all_objects.filter(tenant_id=tenant.id, scan_id=scan.id).values(
"check_id",
"service",
"severity",
"region",
"fail",
"_pass",
"muted",
"total",
)
)
assert second_run_rows == first_run_rows
assert first_run_ids.isdisjoint(second_run_ids)
@pytest.mark.django_db
class TestAggregateFindingsByRegion:
+66 -22
View File
@@ -2359,11 +2359,20 @@ class TestReaggregateAllFindingGroupSummaries:
def setup_method(self):
self.tenant_id = str(uuid.uuid4())
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dispatches_subtasks_for_each_provider_per_day(
self, mock_scan_filter, mock_agg_task, mock_group
self,
mock_scan_filter,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_group,
mock_chain,
):
provider_id_1 = uuid.uuid4()
provider_id_2 = uuid.uuid4()
@@ -2373,8 +2382,13 @@ class TestReaggregateAllFindingGroupSummaries:
today = datetime.now(tz=timezone.utc)
yesterday = today - timedelta(days=1)
mock_group_result = MagicMock()
mock_group.side_effect = lambda gen: (list(gen), mock_group_result)[1]
mock_outer_group_result = MagicMock()
# The first `group()` call wraps the inner (severity, finding-group)
# parallel step; subsequent calls wrap the outer per-scan generator.
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
{
@@ -2397,23 +2411,40 @@ class TestReaggregateAllFindingGroupSummaries:
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 3}
assert mock_agg_task.si.call_count == 3
mock_agg_task.si.assert_any_call(
tenant_id=self.tenant_id, scan_id=str(scan_id_today_p1)
)
mock_agg_task.si.assert_any_call(
tenant_id=self.tenant_id, scan_id=str(scan_id_today_p2)
)
mock_agg_task.si.assert_any_call(
tenant_id=self.tenant_id, scan_id=str(scan_id_yesterday_p1)
)
mock_group_result.apply_async.assert_called_once()
expected_scan_ids = {
str(scan_id_today_p1),
str(scan_id_today_p2),
str(scan_id_yesterday_p1),
}
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
):
assert task_mock.si.call_count == 3
dispatched = {
call.kwargs["scan_id"] for call in task_mock.si.call_args_list
}
assert dispatched == expected_scan_ids
for call in task_mock.si.call_args_list:
assert call.kwargs["tenant_id"] == self.tenant_id
assert mock_chain.call_count == 3
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.aggregate_finding_group_summaries_task")
@patch("tasks.tasks.aggregate_daily_severity_task")
@patch("tasks.tasks.perform_scan_summary_task")
@patch("tasks.tasks.Scan.objects.filter")
def test_dedupes_scans_to_latest_per_provider_per_day(
self, mock_scan_filter, mock_agg_task, mock_group
self,
mock_scan_filter,
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
mock_group,
mock_chain,
):
"""When several scans run on the same day for the same provider, only
the latest one is dispatched (matching the daily summary unique key)."""
@@ -2423,8 +2454,11 @@ class TestReaggregateAllFindingGroupSummaries:
today_late = datetime.now(tz=timezone.utc)
today_early = today_late - timedelta(hours=4)
mock_group_result = MagicMock()
mock_group.side_effect = lambda gen: (list(gen), mock_group_result)[1]
mock_outer_group_result = MagicMock()
mock_group.side_effect = lambda *args, **kwargs: (
list(args[0]) if args and hasattr(args[0], "__iter__") else None,
mock_outer_group_result,
)[1]
# Returned ordered by `-completed_at`, so the most recent comes first.
mock_scan_filter.return_value.order_by.return_value.values.return_value = [
@@ -2443,17 +2477,27 @@ class TestReaggregateAllFindingGroupSummaries:
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 1}
mock_agg_task.si.assert_called_once_with(
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
)
mock_group_result.apply_async.assert_called_once()
for task_mock in (
mock_scan_summary_task,
mock_daily_severity_task,
mock_finding_group_task,
):
task_mock.si.assert_called_once_with(
tenant_id=self.tenant_id, scan_id=str(latest_scan_today)
)
mock_chain.assert_called_once()
mock_outer_group_result.apply_async.assert_called_once()
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
@patch("tasks.tasks.Scan.objects.filter")
def test_no_completed_scans_skips_dispatch(self, mock_scan_filter, mock_group):
def test_no_completed_scans_skips_dispatch(
self, mock_scan_filter, mock_group, mock_chain
):
mock_scan_filter.return_value.order_by.return_value.values.return_value = []
result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id)
assert result == {"scans_reaggregated": 0}
mock_group.assert_not_called()
mock_chain.assert_not_called()
+1 -1
View File
@@ -8,7 +8,7 @@ Prowler supports multiple output formats, allowing users to tailor findings pres
- Output Organization in Prowler
Prowler outputs are managed within the `/lib/outputs` directory. Each format—such as JSON, CSV, HTML—is implemented as a Python class.
Prowler outputs are managed within the `/lib/outputs` directory. Each format—such as JSON, CSV, HTML, SARIF—is implemented as a Python class.
- Outputs are generated based on scan findings, which are stored as structured dictionaries containing details such as:
@@ -25,7 +25,12 @@ If you prefer the former verbose output, use: `--verbose`. This allows seeing mo
## Report Generation
By default, Prowler generates CSV, JSON-OCSF, and HTML reports. To generate a JSON-ASFF report (used by AWS Security Hub), specify `-M` or `--output-modes`:
By default, Prowler generates CSV, JSON-OCSF, and HTML reports. Additional provider-specific formats are available:
* **JSON-ASFF** (AWS only): Used by AWS Security Hub
* **SARIF** (IaC only): Used by GitHub Code Scanning
To specify output formats, use the `-M` or `--output-modes` flag:
```console
prowler <provider> -M csv json-asff json-ocsf html
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -61,6 +61,7 @@ Prowler natively supports the following reporting output formats:
- JSON-OCSF
- JSON-ASFF (AWS only)
- HTML
- SARIF (IaC only)
Hereunder is the structure for each of the supported report formats by Prowler:
@@ -368,6 +369,29 @@ Each finding is a `json` object within a list.
The following image is an example of the HTML output:
<img src="/images/cli/reporting/html-output.png" />
### SARIF (IaC Only)
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="5.25.0" />
The SARIF (Static Analysis Results Interchange Format) output generates a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) document compatible with GitHub Code Scanning and other SARIF-compatible tools. This format is exclusively available for the IaC provider, as it is designed for static analysis results that reference specific files and line numbers.
```console
prowler iac --scan-repository-url https://github.com/user/repo -M sarif
```
<Note>
The SARIF output format is only available when using the `iac` provider. Attempting to use it with other providers results in an error.
</Note>
The SARIF output includes:
* **Rules:** Each unique check ID produces a rule entry with severity, description, remediation, and a markdown help panel.
* **Results:** Only failed (non-muted) findings are included, with file paths and line numbers for precise annotation.
* **Severity mapping:** Prowler severities map to SARIF levels (`critical`/`high` → `error`, `medium` → `warning`, `low`/`informational` → `note`).
## V4 Deprecations
Some deprecations have been made to unify formats and improve outputs.
@@ -29,7 +29,7 @@ Prowler IaC provider scans the following Infrastructure as Code configurations f
- For remote repository scans, authentication can be provided via [git URL](https://git-scm.com/docs/git-clone#_git_urls), CLI flags or environment variables.
- Check the [IaC Authentication](/user-guide/providers/iac/authentication) page for more details.
- Mutelist logic ([filtering](https://trivy.dev/latest/docs/configuration/filtering/)) is handled by Trivy, not Prowler.
- Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
- Results are output in the same formats as other Prowler providers (CSV, JSON-OCSF, HTML), plus [SARIF](/user-guide/cli/tutorials/reporting#sarif-iac-only) for GitHub Code Scanning integration.
## Prowler Cloud
@@ -140,8 +140,20 @@ prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/tes
### Output
Use the standard Prowler output options, for example:
Use the standard Prowler output options. The IaC provider also supports [SARIF](/user-guide/cli/tutorials/reporting#sarif-iac-only) output for GitHub Code Scanning integration:
```sh
prowler iac --scan-path ./iac --output-formats csv json html
prowler iac --scan-path ./iac --output-formats csv json-ocsf html
```
#### SARIF Output
<VersionBadge version="5.25.0" />
To generate SARIF output for integration with SARIF-compatible tools:
```sh
prowler iac --scan-repository-url https://github.com/user/repo -M sarif
```
See the [SARIF reporting documentation](/user-guide/cli/tutorials/reporting#sarif-iac-only) for details on the format and severity mapping.
@@ -140,6 +140,34 @@ Invitations expire after 7 days. If an invitation has expired, contact the organ
</Note>
## Expelling a User From an Organization
Organization owners can expel a member from the organization. Expelling removes the membership immediately, revoking access to all providers, scans, and findings scoped to that organization. Owners expelling themselves are blocked if they are the last remaining owner of the organization.
To expel a user:
1. Navigate to the **Users** page.
2. Locate the user to remove and open the row actions menu.
3. Select **Expel user**.
<img src="/images/prowler-app/multi-tenant/expel-user-organization.png" alt="Users table row action menu showing the 'Expel user' destructive option" width="700" />
4. Confirm the action in the dialog. The membership is removed immediately and the expelled user loses access to the organization.
<img src="/images/prowler-app/multi-tenant/expel-user-organization-modal.png" alt="Confirmation dialog asking to expel the selected user from the current organization" width="700" />
<Warning>
Expelling a user revokes any refresh tokens the account holds, but access tokens already issued remain valid until they expire. The default access token lifetime is 30 minutes, so an expelled user may retain access to the organization for up to that window before being fully locked out.
</Warning>
<Warning>
If the expelled organization was the user's **only** organization, the account is permanently deleted along with the membership. All personal profile data associated with that account is removed and cannot be recovered. To preserve the account, confirm that the user belongs to another organization before expelling.
</Warning>
## Permissions Reference
| Action | Required Conditions |
@@ -149,3 +177,4 @@ Invitations expire after 7 days. If an invitation has expired, contact the organ
| Switch organizations | Any authenticated user |
| Edit organization name | Organization owner with **Manage Account** permission |
| Delete an organization | Organization owner with **Manage Account** permission; must belong to more than one organization |
| Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves |
+3 -3
View File
@@ -879,11 +879,11 @@ wheels = [
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
Generated
+7 -7
View File
@@ -4702,14 +4702,14 @@ dev = ["black (==22.6.0)", "flake8", "mypy", "pytest"]
[[package]]
name = "pyasn1"
version = "0.6.2"
version = "0.6.3"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
]
[[package]]
@@ -4941,14 +4941,14 @@ urllib3 = ">=1.26.0"
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
+10
View File
@@ -2,11 +2,21 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.25.0] (Prowler UNRELEASED)
### 🚀 Added
- SARIF output format for the IaC provider, enabling GitHub Code Scanning integration via `--output-formats sarif` [(#10626)](https://github.com/prowler-cloud/prowler/pull/10626)
- `repository_default_branch_dismisses_stale_reviews` check for GitHub provider to ensure stale pull request approvals are dismissed when new commits are pushed [(#10569)](https://github.com/prowler-cloud/prowler/pull/10569)
---
## [5.24.3] (Prowler v5.24.3)
### 🐞 Fixed
- CloudTrail resource timeline uses resource name as fallback in `LookupEvents` [(#10828)](https://github.com/prowler-cloud/prowler/pull/10828)
- Exclude `me-south-1` and `me-central-1` from default AWS scans to prevent hangs when the host can't reach those regional endpoints [(#10837)](https://github.com/prowler-cloud/prowler/pull/10837)
---
+9
View File
@@ -18,6 +18,7 @@ from prowler.config.config import (
json_asff_file_suffix,
json_ocsf_file_suffix,
orange_color,
sarif_file_suffix,
)
from prowler.lib.banner import print_banner
from prowler.lib.check.check import (
@@ -122,6 +123,7 @@ from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.ocsf.ingestion import send_ocsf_to_api
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.lib.outputs.outputs import extract_findings_statistics, report
from prowler.lib.outputs.sarif.sarif import SARIF
from prowler.lib.outputs.slack.slack import Slack
from prowler.lib.outputs.summary_table import display_summary_table
from prowler.providers.alibabacloud.models import AlibabaCloudOutputOptions
@@ -552,6 +554,13 @@ def prowler():
html_output.batch_write_data_to_file(
provider=global_provider, stats=stats
)
if mode == "sarif":
sarif_output = SARIF(
findings=finding_outputs,
file_path=f"{filename}{sarif_file_suffix}",
)
generated_outputs["regular"].append(sarif_output)
sarif_output.batch_write_data_to_file()
if getattr(args, "push_to_cloud", False):
if not ocsf_output or not getattr(ocsf_output, "file_path", None):
@@ -73,7 +73,9 @@
{
"Id": "1.1.4",
"Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.",
"Checks": [],
"Checks": [
"repository_default_branch_dismisses_stale_reviews"
],
"Attributes": [
{
"Section": "1 Source Code",
+2 -1
View File
@@ -110,6 +110,7 @@ json_file_suffix = ".json"
json_asff_file_suffix = ".asff.json"
json_ocsf_file_suffix = ".ocsf.json"
html_file_suffix = ".html"
sarif_file_suffix = ".sarif"
default_config_file_path = (
f"{pathlib.Path(os.path.dirname(os.path.realpath(__file__)))}/config.yaml"
)
@@ -120,7 +121,7 @@ default_redteam_config_file_path = (
f"{pathlib.Path(os.path.dirname(os.path.realpath(__file__)))}/llm_config.yaml"
)
encoding_format_utf_8 = "utf-8"
available_output_formats = ["csv", "json-asff", "json-ocsf", "html"]
available_output_formats = ["csv", "json-asff", "json-ocsf", "html", "sarif"]
# Prowler Cloud API settings
cloud_api_base_url = os.getenv("PROWLER_CLOUD_API_BASE_URL", "https://api.prowler.com")
+3 -1
View File
@@ -6,7 +6,9 @@ aws:
# aws.disallowed_regions --> List of AWS regions to exclude from the scan.
# Also settable via the PROWLER_AWS_DISALLOWED_REGIONS environment variable or
# the --excluded-region CLI flag. Precedence: CLI > env var > config file.
# disallowed_regions: []
disallowed_regions:
- me-south-1
- me-central-1
# If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`:
# Mutelist:
# Accounts:
+4 -9
View File
@@ -1094,15 +1094,10 @@ class CheckReportIAC(Check_Report):
self.resource = finding
self.resource_name = file_path
self.resource_line_range = (
(
str(finding.get("CauseMetadata", {}).get("StartLine", ""))
+ ":"
+ str(finding.get("CauseMetadata", {}).get("EndLine", ""))
)
if finding.get("CauseMetadata", {}).get("StartLine", "")
else ""
)
cause = finding.get("CauseMetadata", {})
start = cause.get("StartLine") or finding.get("StartLine")
end = cause.get("EndLine") or finding.get("EndLine")
self.resource_line_range = f"{start}:{end}" if start else ""
@dataclass
+7
View File
@@ -18,6 +18,7 @@ from prowler.providers.common.arguments import (
init_providers_parser,
validate_asff_usage,
validate_provider_arguments,
validate_sarif_usage,
)
@@ -153,6 +154,12 @@ Detailed documentation at https://docs.prowler.com
if not asff_is_valid:
self.parser.error(asff_error)
sarif_is_valid, sarif_error = validate_sarif_usage(
args.provider, getattr(args, "output_formats", None)
)
if not sarif_is_valid:
self.parser.error(sarif_error)
return args
def __set_default_provider__(self, args: list) -> list:
@@ -0,0 +1,395 @@
import os
from datetime import datetime
from typing import List
from py_ocsf_models.events.base_event import SeverityID
from py_ocsf_models.events.base_event import StatusID as EventStatusID
from py_ocsf_models.events.findings.compliance_finding import ComplianceFinding
from py_ocsf_models.events.findings.compliance_finding_type_id import (
ComplianceFindingTypeID,
)
from py_ocsf_models.events.findings.finding import ActivityID, FindingInformation
from py_ocsf_models.objects.check import Check
from py_ocsf_models.objects.compliance import Compliance
from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID
from py_ocsf_models.objects.group import Group
from py_ocsf_models.objects.metadata import Metadata
from py_ocsf_models.objects.product import Product
from py_ocsf_models.objects.resource_details import ResourceDetails
from prowler.config.config import prowler_version
from prowler.lib.check.compliance_models import ComplianceFramework
from prowler.lib.logger import logger
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.lib.outputs.utils import unroll_dict_to_list
from prowler.lib.utils.utils import open_file
PROWLER_TO_COMPLIANCE_STATUS = {
"PASS": ComplianceStatusID.Pass,
"FAIL": ComplianceStatusID.Fail,
"MANUAL": ComplianceStatusID.Unknown,
}
def _to_snake_case(name: str) -> str:
"""Convert a PascalCase or camelCase string to snake_case."""
import re
# Insert underscore before uppercase letters preceded by lowercase
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
# Insert underscore between consecutive uppercase and following lowercase
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)
return s.lower()
def _build_requirement_attrs(requirement, framework) -> dict:
"""Build a dict with requirement attributes for the unmapped section.
Keys are normalized to snake_case for OCSF consistency.
Only includes attributes whose AttributeMetadata has output_formats.ocsf=True.
When no metadata is declared, all attributes are included.
"""
attrs = requirement.attributes
if not attrs:
return {}
# Build set of keys allowed for OCSF output
metadata = framework.attributes_metadata
if metadata:
ocsf_keys = {m.key for m in metadata if m.output_formats.ocsf}
else:
ocsf_keys = None # No metadata → include all
result = {}
for key, value in attrs.items():
if ocsf_keys is not None and key not in ocsf_keys:
continue
result[_to_snake_case(key)] = value
return result
class OCSFComplianceOutput:
"""Produces OCSF ComplianceFinding (class_uid 2003) events from
universal compliance framework data.
Each finding × requirement combination produces one ComplianceFinding event
with structured Compliance and Check objects.
"""
def __init__(
self,
findings: list,
framework: ComplianceFramework,
file_path: str = None,
from_cli: bool = True,
provider: str = None,
) -> None:
self._data = []
self._file_descriptor = None
self.file_path = file_path
self._from_cli = from_cli
self._provider = provider
self.close_file = False
if findings:
compliance_name = (
framework.framework + "-" + framework.version
if framework.version
else framework.framework
)
self._transform(findings, framework, compliance_name)
if not self._file_descriptor and file_path:
self._create_file_descriptor(file_path)
@property
def data(self):
return self._data
def _transform(
self,
findings: List[Finding],
framework: ComplianceFramework,
compliance_name: str,
) -> None:
# Build check -> requirements map
check_req_map = {}
for req in framework.requirements:
checks = req.checks
if self._provider:
all_checks = checks.get(self._provider.lower(), [])
else:
all_checks = []
for check_list in checks.values():
all_checks.extend(check_list)
for check_id in all_checks:
check_req_map.setdefault(check_id, []).append(req)
for finding in findings:
if finding.check_id in check_req_map:
for req in check_req_map[finding.check_id]:
cf = self._build_compliance_finding(
finding, framework, req, compliance_name
)
if cf:
self._data.append(cf)
# Manual requirements (no checks or empty for current provider)
for req in framework.requirements:
checks = req.checks
if self._provider:
has_checks = bool(checks.get(self._provider.lower(), []))
else:
has_checks = any(checks.values())
if not has_checks:
cf = self._build_manual_compliance_finding(
framework, req, compliance_name
)
if cf:
self._data.append(cf)
def _build_unmapped(self, finding, requirement, framework) -> dict:
"""Build the unmapped dict with cloud info and requirement attributes."""
unmapped = {}
# Cloud info (from finding, when available)
if finding and getattr(finding, "provider", None) != "kubernetes":
unmapped["cloud"] = {
"provider": finding.provider,
"region": finding.region,
"account": {
"uid": finding.account_uid,
"name": finding.account_name,
},
"org": {
"uid": finding.account_organization_uid,
"name": finding.account_organization_name,
},
}
# Requirement attributes
req_attrs = _build_requirement_attrs(requirement, framework)
if req_attrs:
unmapped["requirement_attributes"] = req_attrs
return unmapped or None
def _build_compliance_finding(
self,
finding: Finding,
framework: ComplianceFramework,
requirement,
compliance_name: str,
) -> ComplianceFinding:
try:
compliance_status = PROWLER_TO_COMPLIANCE_STATUS.get(
finding.status, ComplianceStatusID.Unknown
)
check_status = PROWLER_TO_COMPLIANCE_STATUS.get(
finding.status, ComplianceStatusID.Unknown
)
finding_severity = getattr(
SeverityID,
finding.metadata.Severity.capitalize(),
SeverityID.Unknown,
)
event_status = OCSF.get_finding_status_id(finding.muted)
time_value = (
int(finding.timestamp.timestamp())
if isinstance(finding.timestamp, datetime)
else finding.timestamp
)
cf = ComplianceFinding(
activity_id=ActivityID.Create.value,
activity_name=ActivityID.Create.name,
compliance=Compliance(
standards=[compliance_name],
requirements=[requirement.id],
control=requirement.description,
status_id=compliance_status,
checks=[
Check(
uid=finding.check_id,
name=finding.metadata.CheckTitle,
desc=finding.metadata.Description,
status=finding.status,
status_id=check_status,
)
],
),
finding_info=FindingInformation(
uid=f"{finding.uid}-{requirement.id}",
title=requirement.id,
desc=requirement.description,
created_time=time_value,
created_time_dt=(
finding.timestamp
if isinstance(finding.timestamp, datetime)
else None
),
),
message=finding.status_extended,
metadata=Metadata(
event_code=finding.check_id,
product=Product(
uid="prowler",
name="Prowler",
vendor_name="Prowler",
version=finding.prowler_version,
),
profiles=(
["cloud", "datetime"]
if finding.provider != "kubernetes"
else ["container", "datetime"]
),
tenant_uid=finding.account_organization_uid,
),
resources=[
ResourceDetails(
labels=unroll_dict_to_list(finding.resource_tags),
name=finding.resource_name,
uid=finding.resource_uid,
group=Group(name=finding.metadata.ServiceName),
type=finding.metadata.ResourceType,
cloud_partition=(
finding.partition
if finding.provider != "kubernetes"
else None
),
region=(
finding.region if finding.provider != "kubernetes" else None
),
namespace=(
finding.region.replace("namespace: ", "")
if finding.provider == "kubernetes"
else None
),
data={
"details": finding.resource_details,
"metadata": finding.resource_metadata,
},
)
],
severity_id=finding_severity.value,
severity=finding_severity.name,
status_id=event_status.value,
status=event_status.name,
status_code=finding.status,
status_detail=finding.status_extended,
time=time_value,
time_dt=(
finding.timestamp
if isinstance(finding.timestamp, datetime)
else None
),
type_uid=ComplianceFindingTypeID.Create,
type_name=f"Compliance Finding: {ComplianceFindingTypeID.Create.name}",
unmapped=self._build_unmapped(finding, requirement, framework),
)
return cf
except Exception as e:
logger.debug(f"Skipping OCSF compliance finding for {requirement.id}: {e}")
return None
def _build_manual_compliance_finding(
self,
framework: ComplianceFramework,
requirement,
compliance_name: str,
) -> ComplianceFinding:
try:
from prowler.config.config import timestamp as config_timestamp
time_value = int(config_timestamp.timestamp())
return ComplianceFinding(
activity_id=ActivityID.Create.value,
activity_name=ActivityID.Create.name,
compliance=Compliance(
standards=[compliance_name],
requirements=[requirement.id],
control=requirement.description,
status_id=ComplianceStatusID.Unknown,
),
finding_info=FindingInformation(
uid=f"manual-{requirement.id}",
title=requirement.id,
desc=requirement.description,
created_time=time_value,
),
message="Manual check",
metadata=Metadata(
event_code="manual",
product=Product(
uid="prowler",
name="Prowler",
vendor_name="Prowler",
version=prowler_version,
),
),
severity_id=SeverityID.Informational.value,
severity=SeverityID.Informational.name,
status_id=EventStatusID.New.value,
status=EventStatusID.New.name,
status_code="MANUAL",
status_detail="Manual check",
time=time_value,
type_uid=ComplianceFindingTypeID.Create,
type_name=f"Compliance Finding: {ComplianceFindingTypeID.Create.name}",
unmapped=self._build_unmapped(None, requirement, framework),
)
except Exception as e:
logger.debug(
f"Skipping manual OCSF compliance finding for {requirement.id}: {e}"
)
return None
def _create_file_descriptor(self, file_path: str) -> None:
try:
self._file_descriptor = open_file(file_path, "a")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def batch_write_data_to_file(self) -> None:
"""Write ComplianceFinding events to a JSON array file."""
try:
if (
getattr(self, "_file_descriptor", None)
and not self._file_descriptor.closed
and self._data
):
if self._file_descriptor.tell() == 0:
self._file_descriptor.write("[")
for finding in self._data:
try:
if hasattr(finding, "model_dump_json"):
json_output = finding.model_dump_json(
exclude_none=True, indent=4
)
else:
json_output = finding.json(exclude_none=True, indent=4)
self._file_descriptor.write(json_output)
self._file_descriptor.write(",")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if self.close_file or self._from_cli:
if self._file_descriptor.tell() != 1:
self._file_descriptor.seek(
self._file_descriptor.tell() - 1, os.SEEK_SET
)
self._file_descriptor.truncate()
self._file_descriptor.write("]")
self._file_descriptor.close()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -0,0 +1,490 @@
from colorama import Fore, Style
from tabulate import tabulate
from prowler.config.config import orange_color
from prowler.lib.check.compliance_models import ComplianceFramework
def get_universal_table(
findings: list,
bulk_checks_metadata: dict,
compliance_framework_name: str,
output_filename: str,
output_directory: str,
compliance_overview: bool,
framework: ComplianceFramework = None,
provider: str = None,
output_formats: list = None,
) -> None:
"""Render a compliance console table driven by TableConfig.
Supports 3 modes:
- Grouped: group_by only (generic, C5, CSA, ISO, KISA)
- Split: group_by + split_by (CIS Level 1/2, ENS alto/medio/bajo/opcional)
- Scored: group_by + scoring (ThreatScore weighted risk %)
When ``provider`` is given and ``checks`` is a multi-provider dict,
only the checks for that provider are matched against findings.
"""
if framework is None or not framework.outputs or not framework.outputs.table_config:
return
tc = framework.outputs.table_config
labels = tc.labels or _default_labels()
group_by = tc.group_by
split_by = tc.split_by
scoring = tc.scoring
if scoring:
_render_scored(
findings,
bulk_checks_metadata,
compliance_framework_name,
output_filename,
output_directory,
compliance_overview,
framework,
group_by,
scoring,
labels,
provider,
output_formats=output_formats,
)
elif split_by:
_render_split(
findings,
bulk_checks_metadata,
compliance_framework_name,
output_filename,
output_directory,
compliance_overview,
framework,
group_by,
split_by,
labels,
provider,
output_formats=output_formats,
)
else:
_render_grouped(
findings,
bulk_checks_metadata,
compliance_framework_name,
output_filename,
output_directory,
compliance_overview,
framework,
group_by,
labels,
provider,
output_formats=output_formats,
)
def _default_labels():
"""Return a simple namespace with default labels."""
from prowler.lib.check.compliance_models import TableLabels
return TableLabels()
def _build_requirement_check_map(framework, provider=None):
"""Build a map of check_id -> list of requirements for fast lookup.
When *provider* is given, only the checks for that provider are included.
"""
check_map = {}
for req in framework.requirements:
checks = req.checks
if provider:
all_checks = checks.get(provider.lower(), [])
else:
all_checks = []
for check_list in checks.values():
all_checks.extend(check_list)
for check_id in all_checks:
if check_id not in check_map:
check_map[check_id] = []
check_map[check_id].append(req)
return check_map
def _get_group_key(req, group_by):
"""Extract the group key from a requirement."""
if group_by == "_Tactics":
return req.tactics or []
return [req.attributes.get(group_by, "Unknown")]
def _print_overview(pass_count, fail_count, muted_count, framework_name, labels):
"""Print the overview pass/fail/muted summary."""
total = len(fail_count) + len(pass_count) + len(muted_count)
if total < 2:
return False
title = (
labels.title
or f"Compliance Status of {Fore.YELLOW}{framework_name.upper()}{Style.RESET_ALL} Framework:"
)
print(f"\n{title}")
fail_pct = round(len(fail_count) / total * 100, 2)
pass_pct = round(len(pass_count) / total * 100, 2)
muted_pct = round(len(muted_count) / total * 100, 2)
fail_label = labels.fail_label
pass_label = labels.pass_label
overview_table = [
[
f"{Fore.RED}{fail_pct}% ({len(fail_count)}) {fail_label}{Style.RESET_ALL}",
f"{Fore.GREEN}{pass_pct}% ({len(pass_count)}) {pass_label}{Style.RESET_ALL}",
f"{orange_color}{muted_pct}% ({len(muted_count)}) MUTED{Style.RESET_ALL}",
]
]
print(tabulate(overview_table, tablefmt="rounded_grid"))
return True
def _render_grouped(
findings,
bulk_checks_metadata,
compliance_framework_name,
output_filename,
output_directory,
compliance_overview,
framework,
group_by,
labels,
provider=None,
output_formats=None,
):
"""Grouped mode: one row per group with pass/fail counts."""
check_map = _build_requirement_check_map(framework, provider)
groups = {}
pass_count = []
fail_count = []
muted_count = []
for index, finding in enumerate(findings):
check_id = finding.check_metadata.CheckID
if check_id not in check_map:
continue
for req in check_map[check_id]:
for group_key in _get_group_key(req, group_by):
if group_key not in groups:
groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0}
if finding.muted:
if index not in muted_count:
muted_count.append(index)
groups[group_key]["Muted"] += 1
else:
if finding.status == "FAIL" and index not in fail_count:
fail_count.append(index)
groups[group_key]["FAIL"] += 1
elif finding.status == "PASS" and index not in pass_count:
pass_count.append(index)
groups[group_key]["PASS"] += 1
if not _print_overview(
pass_count, fail_count, muted_count, compliance_framework_name, labels
):
return
if not compliance_overview:
provider_header = labels.provider_header
group_header = labels.group_header or group_by
table = {
provider_header: [],
group_header: [],
labels.status_header: [],
"Muted": [],
}
for group_key in sorted(groups):
table[provider_header].append(
framework.provider or (provider.upper() if provider else "")
)
table[group_header].append(group_key)
if groups[group_key]["FAIL"] > 0:
table[labels.status_header].append(
f"{Fore.RED}{labels.fail_label}({groups[group_key]['FAIL']}){Style.RESET_ALL}"
)
else:
table[labels.status_header].append(
f"{Fore.GREEN}{labels.pass_label}({groups[group_key]['PASS']}){Style.RESET_ALL}"
)
table["Muted"].append(
f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}"
)
results_title = (
labels.results_title
or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:"
)
print(f"\n{results_title}")
print(tabulate(table, headers="keys", tablefmt="rounded_grid"))
footer = labels.footer_note or "* Only sections containing results appear."
print(f"{Style.BRIGHT}{footer}{Style.RESET_ALL}")
print(f"\nDetailed results of {compliance_framework_name.upper()} are in:")
print(
f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv"
)
if "json-ocsf" in (output_formats or []):
print(
f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json"
)
print()
def _render_split(
findings,
bulk_checks_metadata,
compliance_framework_name,
output_filename,
output_directory,
compliance_overview,
framework,
group_by,
split_by,
labels,
provider=None,
output_formats=None,
):
"""Split mode: one row per group with columns for each split value (e.g. Level 1/Level 2)."""
check_map = _build_requirement_check_map(framework, provider)
split_field = split_by.field
split_values = split_by.values
groups = {}
pass_count = []
fail_count = []
muted_count = []
for index, finding in enumerate(findings):
check_id = finding.check_metadata.CheckID
if check_id not in check_map:
continue
for req in check_map[check_id]:
for group_key in _get_group_key(req, group_by):
if group_key not in groups:
groups[group_key] = {
sv: {"FAIL": 0, "PASS": 0} for sv in split_values
}
groups[group_key]["Muted"] = 0
split_val = req.attributes.get(split_field, "")
if finding.muted:
if index not in muted_count:
muted_count.append(index)
groups[group_key]["Muted"] += 1
else:
if finding.status == "FAIL" and index not in fail_count:
fail_count.append(index)
elif finding.status == "PASS" and index not in pass_count:
pass_count.append(index)
for sv in split_values:
if sv in str(split_val):
if not finding.muted:
if finding.status == "FAIL":
groups[group_key][sv]["FAIL"] += 1
else:
groups[group_key][sv]["PASS"] += 1
if not _print_overview(
pass_count, fail_count, muted_count, compliance_framework_name, labels
):
return
if not compliance_overview:
provider_header = labels.provider_header
group_header = labels.group_header or group_by
table = {provider_header: [], group_header: []}
for sv in split_values:
table[sv] = []
table["Muted"] = []
for group_key in sorted(groups):
table[provider_header].append(
framework.provider or (provider.upper() if provider else "")
)
table[group_header].append(group_key)
for sv in split_values:
if groups[group_key][sv]["FAIL"] > 0:
table[sv].append(
f"{Fore.RED}{labels.fail_label}({groups[group_key][sv]['FAIL']}){Style.RESET_ALL}"
)
else:
table[sv].append(
f"{Fore.GREEN}{labels.pass_label}({groups[group_key][sv]['PASS']}){Style.RESET_ALL}"
)
table["Muted"].append(
f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}"
)
results_title = (
labels.results_title
or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:"
)
print(f"\n{results_title}")
print(tabulate(table, headers="keys", tablefmt="rounded_grid"))
footer = labels.footer_note or "* Only sections containing results appear."
print(f"{Style.BRIGHT}{footer}{Style.RESET_ALL}")
print(f"\nDetailed results of {compliance_framework_name.upper()} are in:")
print(
f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv"
)
if "json-ocsf" in (output_formats or []):
print(
f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json"
)
print()
def _render_scored(
findings,
bulk_checks_metadata,
compliance_framework_name,
output_filename,
output_directory,
compliance_overview,
framework,
group_by,
scoring,
labels,
provider=None,
output_formats=None,
):
"""Scored mode: weighted risk scoring per group (e.g. ThreatScore)."""
check_map = _build_requirement_check_map(framework, provider)
risk_field = scoring.risk_field
weight_field = scoring.weight_field
groups = {}
pass_count = []
fail_count = []
muted_count = []
score_per_group = {}
max_score_per_group = {}
counted_per_group = {}
generic_score = 0
max_generic_score = 0
counted_generic = []
for index, finding in enumerate(findings):
check_id = finding.check_metadata.CheckID
if check_id not in check_map:
continue
for req in check_map[check_id]:
for group_key in _get_group_key(req, group_by):
attrs = req.attributes
risk = attrs.get(risk_field, 0)
weight = attrs.get(weight_field, 0)
if group_key not in groups:
groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0}
score_per_group[group_key] = 0
max_score_per_group[group_key] = 0
counted_per_group[group_key] = []
if index not in counted_per_group[group_key] and not finding.muted:
if finding.status == "PASS":
score_per_group[group_key] += risk * weight
max_score_per_group[group_key] += risk * weight
counted_per_group[group_key].append(index)
if finding.muted:
if index not in muted_count:
muted_count.append(index)
groups[group_key]["Muted"] += 1
else:
if finding.status == "FAIL" and index not in fail_count:
fail_count.append(index)
groups[group_key]["FAIL"] += 1
elif finding.status == "PASS" and index not in pass_count:
pass_count.append(index)
groups[group_key]["PASS"] += 1
if index not in counted_generic and not finding.muted:
if finding.status == "PASS":
generic_score += risk * weight
max_generic_score += risk * weight
counted_generic.append(index)
if not _print_overview(
pass_count, fail_count, muted_count, compliance_framework_name, labels
):
return
if not compliance_overview:
provider_header = labels.provider_header
group_header = labels.group_header or group_by
table = {
provider_header: [],
group_header: [],
labels.status_header: [],
"Score": [],
"Muted": [],
}
for group_key in sorted(groups):
table[provider_header].append(
framework.provider or (provider.upper() if provider else "")
)
table[group_header].append(group_key)
if max_score_per_group[group_key] == 0:
group_score = 100.0
score_color = Fore.GREEN
else:
group_score = (
score_per_group[group_key] / max_score_per_group[group_key]
) * 100
score_color = Fore.RED
table["Score"].append(
f"{Style.BRIGHT}{score_color}{group_score:.2f}%{Style.RESET_ALL}"
)
if groups[group_key]["FAIL"] > 0:
table[labels.status_header].append(
f"{Fore.RED}{labels.fail_label}({groups[group_key]['FAIL']}){Style.RESET_ALL}"
)
else:
table[labels.status_header].append(
f"{Fore.GREEN}{labels.pass_label}({groups[group_key]['PASS']}){Style.RESET_ALL}"
)
table["Muted"].append(
f"{orange_color}{groups[group_key]['Muted']}{Style.RESET_ALL}"
)
if max_generic_score == 0:
generic_threat_score = 100.0
else:
generic_threat_score = generic_score / max_generic_score * 100
results_title = (
labels.results_title
or f"Framework {Fore.YELLOW}{compliance_framework_name.upper()}{Style.RESET_ALL} Results:"
)
print(f"\n{results_title}")
print(f"\nGeneric Threat Score: {generic_threat_score:.2f}%")
print(tabulate(table, headers="keys", tablefmt="rounded_grid"))
footer = labels.footer_note or (
f"{Style.BRIGHT}\n=== Threat Score Guide ===\n"
f"The lower the score, the higher the risk.{Style.RESET_ALL}\n"
f"{Style.BRIGHT}(Only sections containing results appear, the score is calculated as the sum of the "
f"level of risk * weight of the passed findings divided by the sum of the risk * weight of all the findings){Style.RESET_ALL}"
)
print(footer)
print(f"\nDetailed results of {compliance_framework_name.upper()} are in:")
print(
f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.csv"
)
if "json-ocsf" in (output_formats or []):
print(
f" - OCSF: {output_directory}/compliance/{output_filename}_{compliance_framework_name}.ocsf.json"
)
print()
+3
View File
@@ -354,6 +354,9 @@ class Finding(BaseModel):
check_output, "resource_line_range", ""
)
output_data["framework"] = check_output.check_metadata.ServiceName
output_data["raw"] = {
"resource_line_range": output_data.get("resource_line_range", ""),
}
elif provider.type == "llm":
output_data["auth_method"] = provider.auth_method
+191
View File
@@ -0,0 +1,191 @@
from json import dump
from typing import Optional
from prowler.config.config import prowler_version
from prowler.lib.logger import logger
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.output import Output
SARIF_SCHEMA_URL = "https://json.schemastore.org/sarif-2.1.0.json"
SARIF_VERSION = "2.1.0"
SEVERITY_TO_SARIF_LEVEL = {
"critical": "error",
"high": "error",
"medium": "warning",
"low": "note",
"informational": "note",
}
SEVERITY_TO_SECURITY_SEVERITY = {
"critical": "9.0",
"high": "7.0",
"medium": "4.0",
"low": "2.0",
"informational": "0.0",
}
class SARIF(Output):
"""Generates SARIF 2.1.0 output compatible with GitHub Code Scanning."""
def transform(self, findings: list[Finding]) -> None:
"""Transform findings into a SARIF 2.1.0 document.
Only FAIL findings that are not muted are included. Each unique
check ID produces one rule entry; multiple findings for the same
check share the rule via ruleIndex.
Args:
findings: List of Finding objects to transform.
"""
rules = {}
rule_indices = {}
results = []
for finding in findings:
if finding.status != "FAIL" or finding.muted:
continue
check_id = finding.metadata.CheckID
severity = finding.metadata.Severity.lower()
if check_id not in rules:
rule_indices[check_id] = len(rules)
rule = {
"id": check_id,
"name": finding.metadata.CheckTitle,
"shortDescription": {"text": finding.metadata.CheckTitle},
"fullDescription": {
"text": finding.metadata.Description or check_id
},
"help": {
"text": finding.metadata.Remediation.Recommendation.Text
or finding.metadata.Description
or check_id,
"markdown": self._build_help_markdown(finding, severity),
},
"defaultConfiguration": {
"level": SEVERITY_TO_SARIF_LEVEL.get(severity, "note"),
},
"properties": {
"tags": [
"security",
f"prowler/{finding.metadata.Provider}",
f"severity/{severity}",
],
"security-severity": SEVERITY_TO_SECURITY_SEVERITY.get(
severity, "0.0"
),
},
}
if finding.metadata.RelatedUrl:
rule["helpUri"] = finding.metadata.RelatedUrl
rules[check_id] = rule
rule_index = rule_indices[check_id]
result = {
"ruleId": check_id,
"ruleIndex": rule_index,
"level": SEVERITY_TO_SARIF_LEVEL.get(severity, "note"),
"message": {
"text": finding.status_extended or finding.metadata.CheckTitle
},
}
location = self._build_location(finding)
if location is not None:
result["locations"] = [location]
results.append(result)
sarif_document = {
"$schema": SARIF_SCHEMA_URL,
"version": SARIF_VERSION,
"runs": [
{
"tool": {
"driver": {
"name": "Prowler",
"version": prowler_version,
"informationUri": "https://prowler.com",
"rules": list(rules.values()),
},
},
"results": results,
},
],
}
self._data = [sarif_document]
def batch_write_data_to_file(self) -> None:
"""Write the SARIF document to the output file as JSON."""
try:
if (
getattr(self, "_file_descriptor", None)
and not self._file_descriptor.closed
and self._data
):
dump(self._data[0], self._file_descriptor, indent=2)
if self.close_file or self._from_cli:
self._file_descriptor.close()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@staticmethod
def _build_help_markdown(finding: Finding, severity: str) -> str:
"""Build a markdown help string for a SARIF rule."""
remediation = (
finding.metadata.Remediation.Recommendation.Text
or finding.metadata.Description
or finding.metadata.CheckID
)
lines = [
f"**{finding.metadata.CheckTitle}**\n",
"| Severity | Remediation |",
"| --- | --- |",
f"| {severity.upper()} | {remediation} |",
]
if finding.metadata.RelatedUrl:
lines.append(f"\n[More info]({finding.metadata.RelatedUrl})")
return "\n".join(lines)
@staticmethod
def _build_location(finding: Finding) -> Optional[dict]:
"""Build a SARIF physicalLocation from a Finding.
Uses resource_name as the artifact URI and resource_line_range
(stored in finding.raw for IaC findings) for line range info.
Returns:
A SARIF location dict, or None if resource_name is empty.
"""
if not finding.resource_name:
return None
location = {
"physicalLocation": {
"artifactLocation": {
"uri": finding.resource_name,
},
},
}
line_range = finding.raw.get("resource_line_range", "")
if line_range and ":" in line_range:
parts = line_range.split(":")
try:
start_line = int(parts[0])
end_line = int(parts[1])
if start_line >= 1 and end_line >= 1:
location["physicalLocation"]["region"] = {
"startLine": start_line,
"endLine": end_line,
}
except (ValueError, IndexError):
pass # Malformed line range — skip region, keep location
return location
+5
View File
@@ -9,6 +9,7 @@ from prowler.config.config import (
json_asff_file_suffix,
json_ocsf_file_suffix,
orange_color,
sarif_file_suffix,
)
from prowler.lib.logger import logger
from prowler.providers.github.models import GithubAppIdentityInfo, GithubIdentityInfo
@@ -207,6 +208,10 @@ def display_summary_table(
print(
f" - HTML: {output_directory}/{output_filename}{html_file_suffix}"
)
if "sarif" in output_options.output_modes:
print(
f" - SARIF: {output_directory}/{output_filename}{sarif_file_suffix}"
)
else:
print(
+16
View File
@@ -70,3 +70,19 @@ def validate_asff_usage(
False,
f"json-asff output format is only available for the aws provider, but {provider} was selected",
)
def validate_sarif_usage(
provider: Optional[str], output_formats: Optional[Sequence[str]]
) -> tuple[bool, str]:
"""Ensure sarif output is only requested for the IaC provider."""
if not output_formats or "sarif" not in output_formats:
return (True, "")
if provider == "iac":
return (True, "")
return (
False,
f"sarif output format is only available for the iac provider, but {provider} was selected",
)
@@ -0,0 +1,37 @@
{
"Provider": "github",
"CheckID": "repository_default_branch_dismisses_stale_reviews",
"CheckTitle": "Repository default branch dismisses stale pull request approvals",
"CheckType": [],
"ServiceName": "repository",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "GitHub repository default branch ensures that when new commits are pushed to an open pull request, any previously granted approvals are automatically dismissed, requiring fresh reviews before the pull request can be merged.",
"Risk": "Without this setting, a contributor can receive approvals on a clean version of their code, then push malicious or unauthorized changes afterward. The pull request retains its approvals and can be merged without anyone reviewing the new changes, bypassing the entire code review process.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#dismiss-stale-pull-request-approvals-when-new-commits-are-pushed"
],
"Remediation": {
"Code": {
"CLI": "gh api -X PATCH /repos/<OWNER>/<REPO>/branches/<DEFAULT_BRANCH>/protection/required_pull_request_reviews -f dismiss_stale_reviews=true",
"NativeIaC": "",
"Other": "Using Rulesets (recommended): 1. Go to repository Settings > Rules > Rulesets. 2. Click 'New ruleset' > 'New branch ruleset' (or edit an existing one targeting the default branch). 3. Under 'Require a pull request before merging', enable 'Dismiss stale pull request approvals when new commits are pushed'. 4. Click 'Create' or 'Save changes'. Using classic branch protection: 1. Go to repository Settings > Branches. 2. Edit or add a branch protection rule for the default branch. 3. Under 'Require a pull request before merging', enable 'Dismiss stale pull request approvals when new commits are pushed'. 4. Click 'Save changes'.",
"Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"<example_resource_name>\" {\n repository = \"<example_resource_name>\"\n branch = \"<DEFAULT_BRANCH>\"\n\n required_pull_request_reviews {\n dismiss_stale_reviews = true\n }\n}\n```"
},
"Recommendation": {
"Text": "Enable \"Dismiss stale pull request approvals when new commits are pushed\" in your branch protection rules. This ensures every code change undergoes a fresh review before merging.",
"Url": "https://hub.prowler.com/check/repository_default_branch_dismisses_stale_reviews"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,44 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.repository.repository_client import (
repository_client,
)
class repository_default_branch_dismisses_stale_reviews(Check):
"""Check if a repository dismisses stale pull request approvals when new commits are pushed.
This class verifies whether each repository is configured to automatically
invalidate existing approvals when new commits are pushed to an open pull request.
"""
def execute(self) -> List[CheckReportGithub]:
"""Execute the check.
Returns:
List[CheckReportGithub]: A list of reports for each repository.
"""
findings = []
for repo in repository_client.repositories.values():
if repo.default_branch.dismiss_stale_reviews is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not dismiss stale pull request approvals when new commits are pushed."
if (
repo.default_branch.dismiss_stale_reviews_source
== "ruleset_not_active"
):
report.status_extended = f"Repository {repo.name} has dismiss stale pull request approvals configured in a ruleset, but the ruleset is not active."
if repo.default_branch.dismiss_stale_reviews:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} does dismiss stale pull request approvals when new commits are pushed."
findings.append(report)
return findings
@@ -1,4 +1,5 @@
from datetime import datetime
from fnmatch import fnmatch
from typing import Optional
import github
@@ -100,6 +101,145 @@ class Repository(GithubService):
)
return []
def _default_branch_matches_rule_pattern(
self, pattern: str, default_branch: str
) -> bool:
"""Check whether a ruleset ref pattern applies to the default branch."""
branch_ref = f"refs/heads/{default_branch}"
if pattern in {"~ALL", "~DEFAULT_BRANCH"}:
return True
return fnmatch(branch_ref, pattern)
def _ruleset_targets_default_branch(
self, ruleset: dict, default_branch: str
) -> bool:
"""Check whether a ruleset targets the repository default branch."""
ref_name_conditions = (ruleset.get("conditions") or {}).get("ref_name")
if not ref_name_conditions:
return False
include_patterns = ref_name_conditions.get("include") or []
exclude_patterns = ref_name_conditions.get("exclude") or []
if not include_patterns:
return False
if not any(
self._default_branch_matches_rule_pattern(pattern, default_branch)
for pattern in include_patterns
):
return False
return not any(
self._default_branch_matches_rule_pattern(pattern, default_branch)
for pattern in exclude_patterns
)
def _get_repository_rulesets(self, repo) -> Optional[list[dict]]:
"""Fetch repository and parent branch rulesets with full rule details."""
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
try:
rulesets = []
page = 1
while True:
_, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined]
"GET",
f"/repos/{repo.full_name}/rulesets?includes_parents=true&targets=branch&per_page=100&page={page}",
headers=headers,
)
if not isinstance(response, list):
break
rulesets.extend(response)
if len(response) < 100:
break
page += 1
detailed_rulesets = []
for ruleset in rulesets:
ruleset_id = ruleset.get("id")
if ruleset_id is None:
continue
_, ruleset_details = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined]
"GET",
f"/repos/{repo.full_name}/rulesets/{ruleset_id}?includes_parents=true",
headers=headers,
)
if isinstance(ruleset_details, dict):
detailed_rulesets.append(ruleset_details)
return detailed_rulesets
except github.GithubException as error:
status_code = getattr(error, "status", None)
if status_code == 404:
logger.info(
f"{repo.full_name}: rulesets endpoint not available for this repository."
)
return None
if status_code == 403:
logger.warning(
f"{repo.full_name}: insufficient permissions to query repository rulesets."
)
return None
self._handle_github_api_error(
error, "fetching repository rulesets", repo.full_name
)
except Exception as error:
logger.error(
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
def _get_dismiss_stale_reviews_from_rulesets(
self, repo, default_branch: str
) -> tuple[Optional[bool], Optional[str]]:
"""Evaluate dismiss-stale-review coverage from repository and parent rulesets."""
rulesets = self._get_repository_rulesets(repo)
if rulesets is None:
return None, None
has_inactive_ruleset = False
for ruleset in rulesets:
if ruleset.get("target") != "branch":
continue
if not self._ruleset_targets_default_branch(ruleset, default_branch):
continue
for rule in ruleset.get("rules") or []:
if rule.get("type") != "pull_request":
continue
dismiss_stale_reviews = (rule.get("parameters") or {}).get(
"dismiss_stale_reviews_on_push"
)
if dismiss_stale_reviews is not True:
continue
enforcement = ruleset.get("enforcement")
if enforcement in {"active", "enabled"}:
return True, "ruleset"
if enforcement in {"disabled", "evaluate"}:
has_inactive_ruleset = True
if has_inactive_ruleset:
return False, "ruleset_not_active"
return None, None
def _list_repositories(self):
"""
List repositories based on provider scoping configuration.
@@ -256,6 +396,8 @@ class Repository(GithubService):
status_checks = False
enforce_admins = False
conversation_resolution = False
dismiss_stale_reviews = False
dismiss_stale_reviews_source = None
try:
branch = repo.get_branch(default_branch)
if branch.protected:
@@ -283,6 +425,13 @@ class Repository(GithubService):
if require_pr
else False
)
dismiss_stale_reviews = (
protection.required_pull_request_reviews.dismiss_stale_reviews
if require_pr
else False
)
if dismiss_stale_reviews:
dismiss_stale_reviews_source = "classic"
require_signed_commits = branch.get_required_signatures()
except Exception as error:
# If the branch is not found, it is not protected
@@ -303,10 +452,26 @@ class Repository(GithubService):
status_checks = None
enforce_admins = None
conversation_resolution = None
dismiss_stale_reviews = None
dismiss_stale_reviews_source = None
logger.error(
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if dismiss_stale_reviews is not None:
(
ruleset_dismiss_stale_reviews,
ruleset_source,
) = self._get_dismiss_stale_reviews_from_rulesets(repo, default_branch)
if ruleset_dismiss_stale_reviews:
dismiss_stale_reviews = True
dismiss_stale_reviews_source = "ruleset"
elif (
ruleset_source == "ruleset_not_active" and not dismiss_stale_reviews
):
dismiss_stale_reviews = False
dismiss_stale_reviews_source = "ruleset_not_active"
secret_scanning_enabled = False
dependabot_alerts_enabled = False
try:
@@ -363,6 +528,8 @@ class Repository(GithubService):
conversation_resolution=conversation_resolution,
require_code_owner_reviews=require_code_owner_reviews,
require_signed_commits=require_signed_commits,
dismiss_stale_reviews=dismiss_stale_reviews,
dismiss_stale_reviews_source=dismiss_stale_reviews_source,
),
private=repo.private,
archived=repo.archived,
@@ -443,6 +610,8 @@ class Branch(BaseModel):
require_code_owner_reviews: Optional[bool]
require_signed_commits: Optional[bool]
conversation_resolution: Optional[bool]
dismiss_stale_reviews: Optional[bool]
dismiss_stale_reviews_source: Optional[str] = None
class Repo(BaseModel):
@@ -0,0 +1,475 @@
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from py_ocsf_models.events.findings.compliance_finding import ComplianceFinding
from py_ocsf_models.events.findings.compliance_finding_type_id import (
ComplianceFindingTypeID,
)
from py_ocsf_models.objects.compliance_status import StatusID as ComplianceStatusID
from prowler.lib.check.compliance_models import (
AttributeMetadata,
ComplianceFramework,
OutputFormats,
OutputsConfig,
TableConfig,
UniversalComplianceRequirement,
)
from prowler.lib.outputs.compliance.universal.ocsf_compliance import (
OCSFComplianceOutput,
)
def _make_finding(check_id, status="PASS", provider="aws"):
"""Create a mock Finding with all fields needed by OCSFComplianceOutput."""
finding = SimpleNamespace()
finding.provider = provider
finding.account_uid = "123456789012"
finding.account_name = "test-account"
finding.account_email = ""
finding.account_organization_uid = "org-123"
finding.account_organization_name = "test-org"
finding.account_tags = {"env": "test"}
finding.region = "us-east-1"
finding.status = status
finding.status_extended = f"{check_id} is {status}"
finding.resource_uid = f"arn:aws:iam::123456789012:{check_id}"
finding.resource_name = check_id
finding.resource_details = "some details"
finding.resource_metadata = {}
finding.resource_tags = {"Name": "test"}
finding.partition = "aws"
finding.muted = False
finding.check_id = check_id
finding.uid = "test-finding-uid"
finding.timestamp = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
finding.prowler_version = "5.0.0"
finding.compliance = {}
finding.metadata = SimpleNamespace(
Provider=provider,
CheckID=check_id,
CheckTitle=f"Title for {check_id}",
CheckType=["test-type"],
Description=f"Description for {check_id}",
Severity="medium",
ServiceName="iam",
ResourceType="aws-iam-role",
Risk="test-risk",
RelatedUrl="https://example.com",
Remediation=SimpleNamespace(
Recommendation=SimpleNamespace(Text="Fix it", Url="https://fix.com"),
),
DependsOn=[],
RelatedTo=[],
Categories=["test"],
Notes="",
AdditionalURLs=[],
)
return finding
def _make_framework(requirements, attrs_metadata=None):
return ComplianceFramework(
framework="TestFW",
name="Test Framework",
provider="AWS",
version="1.0",
description="Test framework",
requirements=requirements,
attributes_metadata=attrs_metadata,
outputs=OutputsConfig(table_config=TableConfig(group_by="Section")),
)
def _simple_requirement(req_id="REQ-1", checks=None):
if checks is None:
checks_dict = {"aws": ["check_a"]}
elif isinstance(checks, dict):
checks_dict = checks
else:
checks_dict = {"aws": list(checks)} if checks else {}
return UniversalComplianceRequirement(
id=req_id,
description=f"Description for {req_id}",
attributes={},
checks=checks_dict,
)
class TestOCSFComplianceOutput:
def test_transform_basic(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert len(output.data) == 1
assert isinstance(output.data[0], ComplianceFinding)
def test_class_uid(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert output.data[0].class_uid == 2003
def test_type_uid(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert output.data[0].type_uid == ComplianceFindingTypeID.Create
assert output.data[0].type_uid == 200301
def test_compliance_object_fields(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
compliance = output.data[0].compliance
assert compliance.standards == ["TestFW-1.0"]
assert compliance.requirements == ["REQ-1"]
assert compliance.control == "Description for REQ-1"
assert compliance.status_id == ComplianceStatusID.Pass
def test_check_object_fields(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a", "FAIL")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
checks = output.data[0].compliance.checks
assert len(checks) == 1
assert checks[0].uid == "check_a"
assert checks[0].name == "Title for check_a"
assert checks[0].status == "FAIL"
assert checks[0].status_id == ComplianceStatusID.Fail
def test_finding_info_fields(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
info = output.data[0].finding_info
assert info.uid == "test-finding-uid-REQ-1"
assert info.title == "REQ-1"
assert info.desc == "Description for REQ-1"
def test_metadata_fields(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
metadata = output.data[0].metadata
assert metadata.product.name == "Prowler"
assert metadata.product.uid == "prowler"
assert metadata.event_code == "check_a"
def test_status_mapping_pass(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert output.data[0].compliance.status_id == ComplianceStatusID.Pass
def test_status_mapping_fail(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a", "FAIL")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert output.data[0].compliance.status_id == ComplianceStatusID.Fail
def test_manual_requirement(self):
req = _simple_requirement("MANUAL-1", checks=[])
fw = _make_framework([req])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert len(output.data) == 1
cf = output.data[0]
assert cf.compliance.status_id == ComplianceStatusID.Unknown
assert cf.status_code == "MANUAL"
assert cf.finding_info.uid == "manual-MANUAL-1"
def test_multi_provider_checks_dict(self):
req = UniversalComplianceRequirement(
id="REQ-1",
description="Multi-provider req",
attributes={},
checks={"aws": ["check_a"], "azure": ["check_b"]},
)
fw = _make_framework([req])
findings = [_make_finding("check_a", "PASS", provider="aws")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert len(output.data) == 1
assert output.data[0].compliance.checks[0].uid == "check_a"
def test_empty_findings(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
output = OCSFComplianceOutput(findings=[], framework=fw, provider="aws")
assert output.data == []
def test_cloud_info_in_unmapped(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a", provider="aws")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
cf = output.data[0]
assert cf.unmapped is not None
assert cf.unmapped["cloud"]["provider"] == "aws"
assert cf.unmapped["cloud"]["account"]["uid"] == "123456789012"
assert cf.unmapped["cloud"]["account"]["name"] == "test-account"
def test_resources_populated(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
resources = output.data[0].resources
assert len(resources) == 1
assert resources[0].name == "check_a"
assert resources[0].uid == "arn:aws:iam::123456789012:check_a"
assert resources[0].type == "aws-iam-role"
def test_batch_write_to_file(self, tmp_path):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [_make_finding("check_a", "PASS")]
filepath = str(tmp_path / "compliance.ocsf.json")
output = OCSFComplianceOutput(
findings=findings, framework=fw, file_path=filepath, provider="aws"
)
output.batch_write_data_to_file()
with open(filepath) as f:
data = json.load(f)
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["class_uid"] == 2003
assert data[0]["compliance"]["standards"] == ["TestFW-1.0"]
assert data[0]["compliance"]["requirements"] == ["REQ-1"]
def test_multiple_findings_same_requirement(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
findings = [
_make_finding("check_a", "PASS"),
_make_finding("check_a", "FAIL"),
]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert len(output.data) == 2
statuses = [cf.compliance.status_id for cf in output.data]
assert ComplianceStatusID.Pass in statuses
assert ComplianceStatusID.Fail in statuses
def test_requirement_attributes_in_unmapped(self):
req = UniversalComplianceRequirement(
id="REQ-1",
description="Test requirement",
attributes={"Section": "IAM", "Profile": "Level 1"},
checks={"aws": ["check_a"]},
)
fw = _make_framework([req])
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
cf = output.data[0]
assert cf.unmapped is not None
assert "requirement_attributes" in cf.unmapped
assert cf.unmapped["requirement_attributes"]["section"] == "IAM"
assert cf.unmapped["requirement_attributes"]["profile"] == "Level 1"
def test_requirement_attributes_keys_are_snake_case(self):
req = UniversalComplianceRequirement(
id="REQ-1",
description="Test requirement",
attributes={"Section": "IAM", "CCMLite": "Yes", "SubSection": "1.1"},
checks={"aws": ["check_a"]},
)
fw = _make_framework([req])
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
attrs = output.data[0].unmapped["requirement_attributes"]
assert "section" in attrs
assert "ccm_lite" in attrs
assert "sub_section" in attrs
def test_requirement_attributes_empty_attrs_excluded(self):
req = _simple_requirement("REQ-1", checks=["check_a"])
fw = _make_framework([req])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
cf = output.data[0]
# Cloud info is still present, but no requirement_attributes key
assert cf.unmapped is not None
assert "cloud" in cf.unmapped
assert "requirement_attributes" not in cf.unmapped
def test_manual_requirement_has_attributes_in_unmapped(self):
req = UniversalComplianceRequirement(
id="MANUAL-1",
description="Manual check",
attributes={"Section": "Logging", "Type": "manual"},
checks={},
)
fw = _make_framework([req])
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
assert len(output.data) == 1
cf = output.data[0]
assert cf.unmapped is not None
assert cf.unmapped["requirement_attributes"]["section"] == "Logging"
assert cf.unmapped["requirement_attributes"]["type"] == "manual"
# Manual findings have no cloud info (finding is None)
assert "cloud" not in cf.unmapped
def test_ocsf_metadata_filters_attributes(self):
"""Attributes with output_formats.ocsf=False should be excluded from unmapped."""
metadata = [
AttributeMetadata(
key="Section",
type="str",
output_formats=OutputFormats(ocsf=True),
),
AttributeMetadata(
key="InternalNote",
type="str",
output_formats=OutputFormats(ocsf=False),
),
AttributeMetadata(
key="Profile",
type="str",
output_formats=OutputFormats(ocsf=True),
),
]
req = UniversalComplianceRequirement(
id="REQ-1",
description="Test",
attributes={
"Section": "IAM",
"InternalNote": "skip me",
"Profile": "Level 1",
},
checks={"aws": ["check_a"]},
)
fw = _make_framework([req], attrs_metadata=metadata)
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
attrs = output.data[0].unmapped["requirement_attributes"]
assert "section" in attrs
assert "profile" in attrs
assert "internal_note" not in attrs
def test_ocsf_metadata_all_false_excludes_all(self):
"""When all attributes have output_formats.ocsf=False, requirement_attributes should be empty."""
metadata = [
AttributeMetadata(
key="Section", type="str", output_formats=OutputFormats(ocsf=False)
),
]
req = UniversalComplianceRequirement(
id="REQ-1",
description="Test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"]},
)
fw = _make_framework([req], attrs_metadata=metadata)
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
cf = output.data[0]
assert cf.unmapped is not None
# requirement_attributes should not appear since all attrs are filtered out
assert "requirement_attributes" not in cf.unmapped
def test_ocsf_no_metadata_includes_all(self):
"""Without attributes_metadata, all attributes should be included (backward compat)."""
req = UniversalComplianceRequirement(
id="REQ-1",
description="Test",
attributes={"Section": "IAM", "Custom": "value"},
checks={"aws": ["check_a"]},
)
fw = _make_framework([req], attrs_metadata=None)
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
attrs = output.data[0].unmapped["requirement_attributes"]
assert "section" in attrs
assert "custom" in attrs
def test_ocsf_default_is_true(self):
"""output_formats.ocsf defaults to True — attributes are included unless explicitly excluded."""
metadata = [
AttributeMetadata(key="Section", type="str"),
AttributeMetadata(key="Profile", type="str"),
]
req = UniversalComplianceRequirement(
id="REQ-1",
description="Test",
attributes={"Section": "IAM", "Profile": "Level 1"},
checks={"aws": ["check_a"]},
)
fw = _make_framework([req], attrs_metadata=metadata)
findings = [_make_finding("check_a", "PASS")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
attrs = output.data[0].unmapped["requirement_attributes"]
assert "section" in attrs
assert "profile" in attrs
def test_ocsf_filter_on_manual_requirements(self):
"""OCSF filtering should also apply to manual requirements."""
metadata = [
AttributeMetadata(
key="Section", type="str", output_formats=OutputFormats(ocsf=True)
),
AttributeMetadata(
key="InternalNote",
type="str",
output_formats=OutputFormats(ocsf=False),
),
]
req = UniversalComplianceRequirement(
id="MANUAL-1",
description="Manual",
attributes={"Section": "Logging", "InternalNote": "hidden"},
checks={},
)
fw = _make_framework([req], attrs_metadata=metadata)
findings = [_make_finding("check_a")]
output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws")
cf = output.data[0]
assert cf.unmapped["requirement_attributes"]["section"] == "Logging"
assert "internal_note" not in cf.unmapped["requirement_attributes"]
@@ -0,0 +1,384 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from prowler.lib.check.compliance_models import (
ComplianceFramework,
OutputsConfig,
ScoringConfig,
SplitByConfig,
TableConfig,
TableLabels,
UniversalComplianceRequirement,
)
from prowler.lib.outputs.compliance.universal.universal_table import (
_build_requirement_check_map,
_get_group_key,
get_universal_table,
)
def _make_finding(check_id, status="PASS", muted=False):
"""Create a mock finding for table tests."""
finding = SimpleNamespace()
finding.check_metadata = SimpleNamespace(CheckID=check_id)
finding.status = status
finding.muted = muted
return finding
def _make_framework(requirements, table_config, provider="AWS"):
return ComplianceFramework(
framework="TestFW",
name="Test Framework",
provider=provider,
version="1.0",
description="Test",
requirements=requirements,
outputs=OutputsConfig(table_config=table_config) if table_config else None,
)
class TestBuildRequirementCheckMap:
def test_basic(self):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a", "check_b"]},
),
UniversalComplianceRequirement(
id="1.2",
description="test2",
attributes={"Section": "IAM"},
checks={"aws": ["check_b", "check_c"]},
),
]
fw = _make_framework(reqs, TableConfig(group_by="Section"))
check_map = _build_requirement_check_map(fw)
assert "check_a" in check_map
assert len(check_map["check_b"]) == 2
assert "check_c" in check_map
def test_dict_checks_no_provider_filter(self):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"], "azure": ["check_b"]},
),
]
fw = _make_framework(reqs, TableConfig(group_by="Section"))
check_map = _build_requirement_check_map(fw)
assert "check_a" in check_map
assert "check_b" in check_map
def test_dict_checks_filtered_by_provider(self):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"], "azure": ["check_b"]},
),
]
fw = _make_framework(reqs, TableConfig(group_by="Section"))
check_map = _build_requirement_check_map(fw, provider="aws")
assert "check_a" in check_map
assert "check_b" not in check_map
def test_dict_checks_provider_not_present(self):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"], "azure": ["check_b"]},
),
]
fw = _make_framework(reqs, TableConfig(group_by="Section"))
check_map = _build_requirement_check_map(fw, provider="gcp")
assert len(check_map) == 0
class TestGetGroupKey:
def test_normal_field(self):
req = UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={},
)
assert _get_group_key(req, "Section") == ["IAM"]
def test_tactics(self):
req = UniversalComplianceRequirement(
id="T1190",
description="test",
attributes={},
checks={},
tactics=["Initial Access", "Execution"],
)
assert _get_group_key(req, "_Tactics") == ["Initial Access", "Execution"]
class TestGroupedMode:
def test_grouped_rendering(self, capsys):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"]},
),
UniversalComplianceRequirement(
id="2.1",
description="test2",
attributes={"Section": "Logging"},
checks={"aws": ["check_b"]},
),
]
tc = TableConfig(group_by="Section")
fw = _make_framework(reqs, tc)
findings = [
_make_finding("check_a", "PASS"),
_make_finding("check_b", "FAIL"),
]
bulk_metadata = {
"check_a": MagicMock(Compliance=[]),
"check_b": MagicMock(Compliance=[]),
}
get_universal_table(
findings,
bulk_metadata,
"test_fw",
"output",
"/tmp",
False,
framework=fw,
)
captured = capsys.readouterr()
assert "IAM" in captured.out
assert "Logging" in captured.out
assert "PASS" in captured.out
assert "FAIL" in captured.out
class TestSplitMode:
def test_split_rendering(self, capsys):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "Storage", "Profile": "Level 1"},
checks={"aws": ["check_a"]},
),
UniversalComplianceRequirement(
id="1.2",
description="test2",
attributes={"Section": "Storage", "Profile": "Level 2"},
checks={"aws": ["check_b"]},
),
]
tc = TableConfig(
group_by="Section",
split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]),
)
fw = _make_framework(reqs, tc)
findings = [
_make_finding("check_a", "PASS"),
_make_finding("check_b", "FAIL"),
]
bulk_metadata = {
"check_a": MagicMock(Compliance=[]),
"check_b": MagicMock(Compliance=[]),
}
get_universal_table(
findings,
bulk_metadata,
"test_fw",
"output",
"/tmp",
False,
framework=fw,
)
captured = capsys.readouterr()
assert "Storage" in captured.out
assert "Level 1" in captured.out
assert "Level 2" in captured.out
class TestScoredMode:
def test_scored_rendering(self, capsys):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100},
checks={"aws": ["check_a"]},
),
UniversalComplianceRequirement(
id="1.2",
description="test2",
attributes={"Section": "IAM", "LevelOfRisk": 3, "Weight": 50},
checks={"aws": ["check_b"]},
),
]
tc = TableConfig(
group_by="Section",
scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"),
)
fw = _make_framework(reqs, tc)
findings = [
_make_finding("check_a", "PASS"),
_make_finding("check_b", "FAIL"),
]
bulk_metadata = {
"check_a": MagicMock(Compliance=[]),
"check_b": MagicMock(Compliance=[]),
}
get_universal_table(
findings,
bulk_metadata,
"test_fw",
"output",
"/tmp",
False,
framework=fw,
)
captured = capsys.readouterr()
assert "IAM" in captured.out
assert "Score" in captured.out
assert "Threat Score" in captured.out
class TestCustomLabels:
def test_ens_spanish_labels(self, capsys):
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Marco": "operacional"},
checks={"aws": ["check_a"]},
),
UniversalComplianceRequirement(
id="1.2",
description="test2",
attributes={"Marco": "organizativo"},
checks={"aws": ["check_b"]},
),
]
tc = TableConfig(
group_by="Marco",
labels=TableLabels(
pass_label="CUMPLE",
fail_label="NO CUMPLE",
provider_header="Proveedor",
title="Estado de Cumplimiento",
),
)
fw = _make_framework(reqs, tc)
findings = [_make_finding("check_a", "PASS"), _make_finding("check_b", "FAIL")]
bulk_metadata = {
"check_a": MagicMock(Compliance=[]),
"check_b": MagicMock(Compliance=[]),
}
get_universal_table(
findings,
bulk_metadata,
"test_fw",
"output",
"/tmp",
False,
framework=fw,
)
captured = capsys.readouterr()
assert "CUMPLE" in captured.out
assert "Estado de Cumplimiento" in captured.out
class TestMultiProviderDictChecks:
def test_only_aws_checks_matched(self, capsys):
"""With dict checks and provider='aws', only AWS checks match findings."""
reqs = [
UniversalComplianceRequirement(
id="1.1",
description="test",
attributes={"Section": "IAM"},
checks={"aws": ["check_a"], "azure": ["check_b"]},
),
UniversalComplianceRequirement(
id="2.1",
description="test2",
attributes={"Section": "Logging"},
checks={"aws": ["check_c"], "gcp": ["check_d"]},
),
]
tc = TableConfig(group_by="Section")
fw = ComplianceFramework(
framework="MultiCloud",
name="Multi",
description="Test",
requirements=reqs,
outputs=OutputsConfig(table_config=tc),
)
findings = [
_make_finding("check_a", "PASS"),
_make_finding("check_b", "FAIL"), # Azure check, should be ignored
_make_finding("check_c", "PASS"),
]
bulk_metadata = {
"check_a": MagicMock(Compliance=[]),
"check_b": MagicMock(Compliance=[]),
"check_c": MagicMock(Compliance=[]),
}
get_universal_table(
findings,
bulk_metadata,
"multi_cloud",
"output",
"/tmp",
False,
framework=fw,
provider="aws",
)
captured = capsys.readouterr()
assert "IAM" in captured.out
assert "Logging" in captured.out
# check_b (azure) should not have been counted as FAIL for AWS
assert "PASS" in captured.out
class TestNoTableConfig:
def test_returns_early_without_table_config(self, capsys):
fw = ComplianceFramework(
framework="TestFW",
name="Test",
provider="AWS",
description="Test",
requirements=[],
)
get_universal_table([], {}, "test", "out", "/tmp", False, framework=fw)
captured = capsys.readouterr()
assert captured.out == ""
def test_returns_early_without_framework(self, capsys):
get_universal_table([], {}, "test", "out", "/tmp", False, framework=None)
captured = capsys.readouterr()
assert captured.out == ""
+312
View File
@@ -0,0 +1,312 @@
import json
import os
import tempfile
import pytest
from prowler.lib.outputs.sarif.sarif import SARIF, SARIF_SCHEMA_URL, SARIF_VERSION
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
class TestSARIF:
def test_transform_fail_finding(self):
finding = generate_finding_output(
status="FAIL",
status_extended="S3 bucket is not encrypted",
severity="high",
resource_name="main.tf",
service_name="s3",
check_id="s3_encryption_check",
check_title="S3 Bucket Encryption",
)
sarif = SARIF(findings=[finding], file_path=None)
assert sarif.data[0]["$schema"] == SARIF_SCHEMA_URL
assert sarif.data[0]["version"] == SARIF_VERSION
assert len(sarif.data[0]["runs"]) == 1
run = sarif.data[0]["runs"][0]
assert run["tool"]["driver"]["name"] == "Prowler"
assert len(run["tool"]["driver"]["rules"]) == 1
assert len(run["results"]) == 1
rule = run["tool"]["driver"]["rules"][0]
assert rule["id"] == "s3_encryption_check"
assert rule["shortDescription"]["text"] == "S3 Bucket Encryption"
assert rule["defaultConfiguration"]["level"] == "error"
assert rule["properties"]["security-severity"] == "7.0"
result = run["results"][0]
assert result["ruleId"] == "s3_encryption_check"
assert result["ruleIndex"] == 0
assert result["level"] == "error"
assert result["message"]["text"] == "S3 bucket is not encrypted"
def test_transform_pass_finding_excluded(self):
finding = generate_finding_output(status="PASS", severity="high")
sarif = SARIF(findings=[finding], file_path=None)
run = sarif.data[0]["runs"][0]
assert len(run["results"]) == 0
assert len(run["tool"]["driver"]["rules"]) == 0
def test_transform_muted_finding_excluded(self):
finding = generate_finding_output(status="FAIL", severity="high", muted=True)
sarif = SARIF(findings=[finding], file_path=None)
run = sarif.data[0]["runs"][0]
assert len(run["results"]) == 0
assert len(run["tool"]["driver"]["rules"]) == 0
@pytest.mark.parametrize(
"severity,expected_level,expected_security_severity",
[
("critical", "error", "9.0"),
("high", "error", "7.0"),
("medium", "warning", "4.0"),
("low", "note", "2.0"),
("informational", "note", "0.0"),
],
)
def test_transform_severity_mapping(
self, severity, expected_level, expected_security_severity
):
finding = generate_finding_output(
status="FAIL",
severity=severity,
)
sarif = SARIF(findings=[finding], file_path=None)
run = sarif.data[0]["runs"][0]
result = run["results"][0]
rule = run["tool"]["driver"]["rules"][0]
assert result["level"] == expected_level
assert rule["defaultConfiguration"]["level"] == expected_level
assert rule["properties"]["security-severity"] == expected_security_severity
def test_transform_multiple_findings_dedup_rules(self):
findings = [
generate_finding_output(
status="FAIL",
resource_name="file1.tf",
status_extended="Finding in file1",
),
generate_finding_output(
status="FAIL",
resource_name="file2.tf",
status_extended="Finding in file2",
),
]
sarif = SARIF(findings=findings, file_path=None)
run = sarif.data[0]["runs"][0]
assert len(run["tool"]["driver"]["rules"]) == 1
assert len(run["results"]) == 2
assert run["results"][0]["ruleIndex"] == 0
assert run["results"][1]["ruleIndex"] == 0
def test_transform_multiple_different_rules(self):
findings = [
generate_finding_output(
status="FAIL",
service_name="alpha",
check_id="alpha_check_one",
status_extended="Finding A",
),
generate_finding_output(
status="FAIL",
service_name="beta",
check_id="beta_check_two",
status_extended="Finding B",
),
]
sarif = SARIF(findings=findings, file_path=None)
run = sarif.data[0]["runs"][0]
assert len(run["tool"]["driver"]["rules"]) == 2
assert run["results"][0]["ruleIndex"] == 0
assert run["results"][1]["ruleIndex"] == 1
def test_transform_location_with_line_range(self):
finding = generate_finding_output(
status="FAIL",
resource_name="modules/s3/main.tf",
)
finding.raw = {"resource_line_range": "10:25"}
sarif = SARIF(findings=[finding], file_path=None)
result = sarif.data[0]["runs"][0]["results"][0]
location = result["locations"][0]["physicalLocation"]
assert location["artifactLocation"]["uri"] == "modules/s3/main.tf"
assert location["region"]["startLine"] == 10
assert location["region"]["endLine"] == 25
def test_transform_location_without_line_range(self):
finding = generate_finding_output(
status="FAIL",
resource_name="main.tf",
)
sarif = SARIF(findings=[finding], file_path=None)
result = sarif.data[0]["runs"][0]["results"][0]
location = result["locations"][0]["physicalLocation"]
assert location["artifactLocation"]["uri"] == "main.tf"
assert "region" not in location
def test_transform_no_resource_name(self):
finding = generate_finding_output(
status="FAIL",
resource_name="",
)
sarif = SARIF(findings=[finding], file_path=None)
result = sarif.data[0]["runs"][0]["results"][0]
assert "locations" not in result
def test_batch_write_data_to_file(self):
finding = generate_finding_output(
status="FAIL",
status_extended="test finding",
resource_name="main.tf",
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".sarif", delete=False
) as tmp:
tmp_path = tmp.name
sarif = SARIF(
findings=[finding],
file_path=tmp_path,
)
sarif.batch_write_data_to_file()
with open(tmp_path) as f:
content = json.load(f)
assert content["$schema"] == SARIF_SCHEMA_URL
assert content["version"] == SARIF_VERSION
assert len(content["runs"][0]["results"]) == 1
os.unlink(tmp_path)
def test_sarif_schema_structure(self):
finding = generate_finding_output(
status="FAIL",
severity="critical",
resource_name="infra/main.tf",
service_name="iac",
check_id="iac_misconfig_check",
check_title="IaC Misconfiguration",
description="Checks for misconfigurations",
remediation_recommendation_text="Fix the configuration",
)
finding.raw = {"resource_line_range": "5:15"}
sarif = SARIF(findings=[finding], file_path=None)
doc = sarif.data[0]
assert "$schema" in doc
assert "version" in doc
assert "runs" in doc
run = doc["runs"][0]
assert "tool" in run
assert "driver" in run["tool"]
driver = run["tool"]["driver"]
assert "name" in driver
assert "version" in driver
assert "informationUri" in driver
assert "rules" in driver
rule = driver["rules"][0]
assert "id" in rule
assert "shortDescription" in rule
assert "fullDescription" in rule
assert "help" in rule
assert "defaultConfiguration" in rule
assert "properties" in rule
assert "tags" in rule["properties"]
assert "security-severity" in rule["properties"]
result = run["results"][0]
assert "ruleId" in result
assert "ruleIndex" in result
assert "level" in result
assert "message" in result
assert "locations" in result
loc = result["locations"][0]["physicalLocation"]
assert "artifactLocation" in loc
assert "uri" in loc["artifactLocation"]
assert "region" in loc
assert "startLine" in loc["region"]
assert "endLine" in loc["region"]
def test_transform_helpuri_present_when_related_url_set(self):
finding = generate_finding_output(
status="FAIL",
provider="iac",
related_url="https://docs.example.com/check",
)
sarif = SARIF(findings=[finding], file_path=None)
rule = sarif.data[0]["runs"][0]["tool"]["driver"]["rules"][0]
assert rule["helpUri"] == "https://docs.example.com/check"
def test_transform_helpuri_absent_when_related_url_empty(self):
finding = generate_finding_output(
status="FAIL",
related_url="",
)
sarif = SARIF(findings=[finding], file_path=None)
rule = sarif.data[0]["runs"][0]["tool"]["driver"]["rules"][0]
assert "helpUri" not in rule
def test_location_with_non_numeric_line_range(self):
finding = generate_finding_output(
status="FAIL",
resource_name="main.tf",
)
finding.raw = {"resource_line_range": "abc:def"}
sarif = SARIF(findings=[finding], file_path=None)
location = sarif.data[0]["runs"][0]["results"][0]["locations"][0][
"physicalLocation"
]
assert "region" not in location
def test_location_with_single_value_line_range(self):
finding = generate_finding_output(
status="FAIL",
resource_name="main.tf",
)
finding.raw = {"resource_line_range": "10"}
sarif = SARIF(findings=[finding], file_path=None)
location = sarif.data[0]["runs"][0]["results"][0]["locations"][0][
"physicalLocation"
]
assert "region" not in location
def test_location_with_zero_line_numbers(self):
finding = generate_finding_output(
status="FAIL",
resource_name="main.tf",
)
finding.raw = {"resource_line_range": "0:0"}
sarif = SARIF(findings=[finding], file_path=None)
location = sarif.data[0]["runs"][0]["results"][0]["locations"][0][
"physicalLocation"
]
assert "region" not in location
def test_only_pass_findings(self):
findings = [
generate_finding_output(status="PASS"),
generate_finding_output(status="PASS"),
]
sarif = SARIF(findings=findings, file_path=None)
run = sarif.data[0]["runs"][0]
assert len(run["results"]) == 0
assert len(run["tool"]["driver"]["rules"]) == 0
@@ -0,0 +1,53 @@
from prowler.providers.common.arguments import (
validate_asff_usage,
validate_sarif_usage,
)
class TestValidateAsffUsage:
def test_asff_with_aws_provider(self):
valid, msg = validate_asff_usage("aws", ["json-asff"])
assert valid is True
assert msg == ""
def test_asff_with_non_aws_provider(self):
valid, msg = validate_asff_usage("gcp", ["json-asff"])
assert valid is False
assert "aws" in msg
def test_no_asff_in_formats(self):
valid, msg = validate_asff_usage("gcp", ["csv", "html"])
assert valid is True
def test_no_output_formats(self):
valid, msg = validate_asff_usage("aws", None)
assert valid is True
class TestValidateSarifUsage:
def test_sarif_with_iac_provider(self):
valid, msg = validate_sarif_usage("iac", ["sarif"])
assert valid is True
assert msg == ""
def test_sarif_with_non_iac_provider(self):
valid, msg = validate_sarif_usage("aws", ["sarif"])
assert valid is False
assert "iac" in msg
def test_sarif_with_other_provider(self):
valid, msg = validate_sarif_usage("gcp", ["csv", "sarif"])
assert valid is False
assert "gcp" in msg
def test_no_sarif_in_formats(self):
valid, msg = validate_sarif_usage("aws", ["csv", "html"])
assert valid is True
def test_no_output_formats(self):
valid, msg = validate_sarif_usage("iac", None)
assert valid is True
def test_empty_output_formats(self):
valid, msg = validate_sarif_usage("aws", [])
assert valid is True
@@ -0,0 +1,218 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
class Test_repository_default_branch_dismisses_stale_reviews:
def test_no_repositories(self):
"""Cas limite : aucun repo → aucun résultat attendu."""
repository_client = mock.MagicMock
repository_client.repositories = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import (
repository_default_branch_dismisses_stale_reviews,
)
check = repository_default_branch_dismisses_stale_reviews()
result = check.execute()
assert len(result) == 0
def test_dismiss_stale_reviews_disabled(self):
"""FAIL : le repo ne révoque pas les approbations obsolètes."""
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name="main",
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=False,
allow_force_pushes=False,
branch_deletion=False,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
dismiss_stale_reviews=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import (
repository_default_branch_dismisses_stale_reviews,
)
check = repository_default_branch_dismisses_stale_reviews()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == "repo1"
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} does not dismiss stale pull request approvals when new commits are pushed."
)
def test_dismiss_stale_reviews_enabled(self):
"""PASS : le repo révoque bien les approbations obsolètes."""
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name="main",
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
dismiss_stale_reviews=True,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import (
repository_default_branch_dismisses_stale_reviews,
)
check = repository_default_branch_dismisses_stale_reviews()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == "repo1"
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Repository {repo_name} does dismiss stale pull request approvals when new commits are pushed."
)
def test_dismiss_stale_reviews_ruleset_not_active(self):
"""FAIL : le ruleset existe mais n'est pas actif."""
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=False,
branch_deletion=False,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
dismiss_stale_reviews=False,
dismiss_stale_reviews_source="ruleset_not_active",
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import (
repository_default_branch_dismisses_stale_reviews,
)
check = repository_default_branch_dismisses_stale_reviews()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == "repo1"
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has dismiss stale pull request approvals configured in a ruleset, but the ruleset is not active."
)
@@ -137,7 +137,7 @@ class Test_Repository_GraphQL:
provider.repositories = []
provider.organizations = []
with patch.object(Repository, "__init__", lambda x, y: None):
with patch.object(Repository, "__init__", lambda *_: None):
repository_service = Repository(provider)
mock_client = MagicMock()
repository_service.clients = [mock_client]
@@ -165,7 +165,7 @@ class Test_Repository_GraphQL:
provider.repositories = []
provider.organizations = []
with patch.object(Repository, "__init__", lambda x, y: None):
with patch.object(Repository, "__init__", lambda *_: None):
repository_service = Repository(provider)
repository_service.clients = [MagicMock()]
repository_service.provider = provider
@@ -193,7 +193,7 @@ class Test_Repository_GraphQL:
provider.repositories = []
provider.organizations = []
with patch.object(Repository, "__init__", lambda x, y: None):
with patch.object(Repository, "__init__", lambda *_: None):
repository_service = Repository(provider)
repository_service.clients = [MagicMock()]
repository_service.provider = provider
@@ -462,3 +462,147 @@ class Test_Repository_ErrorHandling:
# Should log rate limit error
mock_logger.error.assert_called()
assert "Rate limit exceeded" in str(mock_logger.error.call_args)
class Test_Repository_DismissStaleReviewsRulesets:
def setup_method(self):
self.repository_service = Repository.__new__(Repository)
self.repository_service.provider = set_mocked_github_provider()
self.repository_service.clients = []
self.repository_service.audit_config = None
self.repository_service.fixer_config = None
def _build_repo(
self,
*,
branch_protected: bool,
dismiss_stale_reviews: bool = False,
ruleset_details: list[dict] | None = None,
):
repo = MagicMock()
repo.id = 1
repo.name = "repo1"
repo.owner.login = "owner1"
repo.full_name = "owner1/repo1"
repo.default_branch = "main"
repo.private = False
repo.archived = False
repo.pushed_at = datetime.now(timezone.utc)
repo.delete_branch_on_merge = False
repo.security_and_analysis = None
repo.get_contents.side_effect = [None, None, None, None]
repo.get_dependabot_alerts.side_effect = Exception(
"403 Forbidden: Dependabot alerts are disabled for this repository."
)
branch = MagicMock()
branch.protected = branch_protected
branch.get_required_signatures.return_value = False
protection = MagicMock()
protection.required_linear_history = False
protection.allow_force_pushes = False
protection.allow_deletions = False
protection.required_status_checks = None
protection.enforce_admins = False
protection.required_conversation_resolution = False
if branch_protected:
protection.required_pull_request_reviews = MagicMock(
required_approving_review_count=1,
require_code_owner_reviews=False,
dismiss_stale_reviews=dismiss_stale_reviews,
)
else:
protection.required_pull_request_reviews = None
branch.get_protection.return_value = protection
repo.get_branch.return_value = branch
ruleset_details = ruleset_details or []
repo._requester.requestJsonAndCheck.side_effect = [
(None, [{"id": ruleset["id"]} for ruleset in ruleset_details]),
*[(None, ruleset) for ruleset in ruleset_details],
]
return repo
def _build_pull_request_ruleset(self, *, enforcement: str, include: list[str]):
return {
"id": 101,
"name": "Dismiss stale reviews",
"target": "branch",
"source_type": "Repository",
"source": "owner1/repo1",
"enforcement": enforcement,
"conditions": {"ref_name": {"include": include, "exclude": []}},
"rules": [
{
"type": "pull_request",
"parameters": {
"dismiss_stale_reviews_on_push": True,
"require_code_owner_review": False,
"require_last_push_approval": False,
"required_approving_review_count": 1,
"required_review_thread_resolution": False,
},
}
],
}
def test_process_repository_uses_classic_branch_protection(self):
repo = self._build_repo(branch_protected=True, dismiss_stale_reviews=True)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.dismiss_stale_reviews is True
assert repos[1].default_branch.dismiss_stale_reviews_source == "classic"
def test_process_repository_uses_active_ruleset_for_default_branch(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_pull_request_ruleset(
enforcement="active", include=["~DEFAULT_BRANCH"]
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.dismiss_stale_reviews is True
assert repos[1].default_branch.dismiss_stale_reviews_source == "ruleset"
def test_process_repository_treats_all_branches_ruleset_as_default_branch(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_pull_request_ruleset(enforcement="active", include=["~ALL"])
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.dismiss_stale_reviews is True
assert repos[1].default_branch.dismiss_stale_reviews_source == "ruleset"
def test_process_repository_marks_inactive_ruleset_as_fail_signal(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_pull_request_ruleset(
enforcement="disabled", include=["~DEFAULT_BRANCH"]
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.dismiss_stale_reviews is False
assert (
repos[1].default_branch.dismiss_stale_reviews_source == "ruleset_not_active"
)
+11 -1
View File
@@ -7,14 +7,24 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Sign-in and sign-up redesigned with an animated background and a live release highlights panel from GitHub, with adaptive summaries for patch releases [(#10774)](https://github.com/prowler-cloud/prowler/pull/10774)
- Download PDF button for CIS Benchmark compliance cards, surfaced only on the latest CIS variant per provider to match the backend's latest-only PDF generation [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650)
### 🔄 Changed
- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
- Redesign compliance page, client-side search for compliance frameworks, compact scan selector trigger, enhanced compliance cards [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
---
## [1.24.4] (Prowler UNRELEASED)
### 🐞 Fixed
- Provider wizard no longer advances to the Launch Scan step when rotating credentials [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851)
---
## [1.24.2] (Prowler v5.24.2)
### 🐞 Fixed
@@ -12,9 +12,31 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
}),
);
// Real helpers/constants pulled from submodules that don't import server-only
// code, so the mock factory stays free of top-level variable hoisting issues
// and the vitest runtime doesn't choke on next-auth's `next/server` import.
import {
includesMutedFindings,
splitCsvFilterValues,
} from "@/lib/findings-filters";
import {
composeSort,
FG_FAIL_FIRST,
FG_RECENT_LAST_SEEN,
FG_SEVERITY_HIGH_FIRST,
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
} from "@/lib/findings-sort";
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
composeSort,
FG_FAIL_FIRST,
FG_RECENT_LAST_SEEN,
FG_SEVERITY_HIGH_FIRST,
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
includesMutedFindings,
splitCsvFilterValues,
}));
vi.mock("@/lib/provider-filters", () => ({
+36 -36
View File
@@ -2,7 +2,17 @@
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import {
apiBaseUrl,
composeSort,
FG_FAIL_FIRST,
FG_RECENT_LAST_SEEN,
FG_SEVERITY_HIGH_FIRST,
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
getAuthHeaders,
includesMutedFindings,
splitCsvFilterValues,
} from "@/lib";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { FilterParam } from "@/types/filters";
@@ -24,24 +34,6 @@ function mapSearchFilter(
return mapped;
}
function splitCsvFilterValues(value: string | string[] | undefined): string[] {
if (Array.isArray(value)) {
return value
.flatMap((item) => item.split(","))
.map((item) => item.trim())
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
/**
* Filters that belong to finding-groups but are NOT valid for the
* finding-group resources sub-endpoint. These must be stripped before
@@ -82,14 +74,34 @@ function normalizeFindingGroupResourceFilters(
return normalized;
}
const DEFAULT_FINDING_GROUPS_SORT =
"-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at";
// Composite sorts for finding-groups (Family B in lib/findings-sort.ts).
// The `-status,-severity,...,-last_seen_at` shape is required by the API:
// these endpoints map status/severity to weighted integer columns where
// DESC = FAIL/critical first. The intermediate `*_count` tokens are
// finding-group-specific impact tiebreakers and have no Family A analogue.
const DEFAULT_FINDING_GROUPS_SORT = composeSort(
FG_FAIL_FIRST,
FG_SEVERITY_HIGH_FIRST,
"-new_fail_count",
"-changed_fail_count",
"-fail_count",
FG_RECENT_LAST_SEEN,
);
const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED =
"-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at";
const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED = composeSort(
FG_FAIL_FIRST,
FG_SEVERITY_HIGH_FIRST,
"-new_fail_count",
"-changed_fail_count",
"-new_fail_muted_count",
"-changed_fail_muted_count",
"-fail_count",
"-fail_muted_count",
FG_RECENT_LAST_SEEN,
);
const DEFAULT_FINDING_GROUP_RESOURCES_SORT =
"-status,-severity,-delta,-last_seen_at";
FINDING_GROUP_RESOURCES_DEFAULT_SORT;
interface FetchFindingGroupsParams {
page?: number;
@@ -98,18 +110,6 @@ interface FetchFindingGroupsParams {
filters?: Record<string, string | string[] | undefined>;
}
function includesMutedFindings(
filters: Record<string, string | string[] | undefined>,
): boolean {
const mutedFilter = filters["filter[muted]"];
if (Array.isArray(mutedFilter)) {
return mutedFilter.includes("include");
}
return mutedFilter === "include";
}
function getDefaultFindingGroupsSort(
filters: Record<string, string | string[] | undefined>,
): string {
@@ -24,9 +24,15 @@ const {
getLatestFindingGroupResourcesMock: vi.fn(),
}));
// Import the real sort constant directly from its submodule. Going via the
// `@/lib` barrel would pull in server-only code (next-auth) that does not
// resolve in the vitest runtime.
import { RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib/findings-sort";
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
}));
vi.mock("@/lib/provider-filters", () => ({
+6 -2
View File
@@ -4,7 +4,11 @@ import {
getFindingGroupResources,
getLatestFindingGroupResources,
} from "@/actions/finding-groups";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import {
apiBaseUrl,
getAuthHeaders,
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
} from "@/lib";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -266,7 +270,7 @@ export const getLatestFindingsByResourceUid = async ({
url.searchParams.append("filter[resource_uid]", resourceUid);
url.searchParams.append("filter[status]", "FAIL");
url.searchParams.append("filter[muted]", includeMuted ? "include" : "false");
url.searchParams.append("sort", "severity,-updated_at");
url.searchParams.append("sort", RESOURCE_DRAWER_OTHER_FINDINGS_SORT);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
+26
View File
@@ -8,9 +8,35 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
}),
);
// Pull every constant transitively required by the modules under test
// (resources.ts → findings action → finding-groups action) so the `@/lib`
// mock is a complete surface. Going via the barrel would drag in next-auth.
import {
includesMutedFindings,
splitCsvFilterValues,
} from "@/lib/findings-filters";
import {
composeSort,
FG_FAIL_FIRST,
FG_RECENT_LAST_SEEN,
FG_SEVERITY_HIGH_FIRST,
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
FINDINGS_FILTERED_SORT,
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
} from "@/lib/findings-sort";
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
composeSort,
FG_FAIL_FIRST,
FG_RECENT_LAST_SEEN,
FG_SEVERITY_HIGH_FIRST,
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
FINDINGS_FILTERED_SORT,
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
includesMutedFindings,
splitCsvFilterValues,
}));
vi.mock("@/lib/server-actions-helper", () => ({
+2 -2
View File
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { getLatestFindings } from "@/actions/findings";
import { listOrganizationsSafe } from "@/actions/organizations/organizations";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { OrganizationResource } from "@/types/organizations";
@@ -285,7 +285,7 @@ export const getResourceDrawerData = async ({
page,
pageSize,
query,
sort: "severity,-inserted_at",
sort: FINDINGS_FILTERED_SORT,
filters: {
"filter[resource_uid]": resourceUid,
"filter[status]": "FAIL",
+62 -63
View File
@@ -318,55 +318,87 @@ export const getExportsZip = async (scanId: string) => {
}
};
export const getComplianceCsv = async (
scanId: string,
complianceId: string,
) => {
const headers = await getAuthHeaders({ contentType: false });
/**
* Discriminated union returned by {@link _fetchScanBinary}.
*
* Exported so `ui/lib/helper.ts::downloadFile` can type-narrow on the
* `success` / `pending` / `error` tags without resorting to `any`.
*/
export type ScanBinaryResult =
| { success: true; data: string; filename: string }
| { pending: true; state: string | undefined; taskId: string | undefined }
| { error: string };
const url = new URL(
`${apiBaseUrl}/scans/${scanId}/compliance/${complianceId}`,
);
/**
* Shared binary-report fetcher used by CSV and PDF report downloads.
*
* All report endpoints (`/scans/{id}/compliance/{name}`,
* `/scans/{id}/{reportType}`) speak the same protocol: Bearer auth, 202
* ACCEPTED while the generation task is still running, 2xx with a binary
* body when the artifact is ready, JSON error body otherwise. This helper
* encapsulates all of that so the public wrappers only have to build the
* URL and pick a filename.
*
* @param urlPath Path segment under `{apiBaseUrl}/scans/{scanId}/`.
* @param filename Download filename to surface to the user.
* @param errorLabel Friendly label used when the backend error body is empty.
* @returns A ``{ success, data, filename }`` object on 2xx, a
* ``{ pending, state, taskId }`` object on 202, or
* ``{ error }`` on any failure.
*/
const _fetchScanBinary = async (
scanId: string,
urlPath: string,
filename: string,
errorLabel: string,
): Promise<ScanBinaryResult> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scans/${scanId}/${urlPath}`);
try {
const response = await fetch(url.toString(), { headers });
if (response.status === 202) {
const json = await response.json();
const taskId = json?.data?.id;
const state = json?.data?.attributes?.state;
return {
pending: true,
state,
taskId,
state: json?.data?.attributes?.state,
taskId: json?.data?.id,
};
}
if (!response.ok) {
const errorData = await response.json();
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData?.errors?.detail ||
"Unable to retrieve compliance report. Contact support if the issue continues.",
`Unable to retrieve ${errorLabel}. Contact support if the issue continues.`,
);
}
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString("base64");
return {
success: true,
data: base64,
filename: `scan-${scanId}-compliance-${complianceId}.csv`,
};
return { success: true, data: base64, filename };
} catch (error) {
return {
error: getErrorMessage(error),
};
return { error: getErrorMessage(error) };
}
};
export const getComplianceCsv = async (scanId: string, complianceId: string) =>
_fetchScanBinary(
scanId,
`compliance/${complianceId}`,
`scan-${scanId}-compliance-${complianceId}.csv`,
"compliance report",
);
/**
* Generic function to get a compliance PDF report (ThreatScore, ENS, etc.)
* Get a compliance PDF report for any supported framework.
*
* For frameworks with multiple variants per provider (currently CIS) the
* backend generates a single PDF for the highest available version, so
* callers only need to pass the generic report type.
*
* @param scanId - The scan ID
* @param reportType - Type of report (from COMPLIANCE_REPORT_TYPES)
* @returns Promise with the PDF data or error
@@ -375,44 +407,11 @@ export const getCompliancePdfReport = async (
scanId: string,
reportType: ComplianceReportType,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scans/${scanId}/${reportType}`);
try {
const response = await fetch(url.toString(), { headers });
if (response.status === 202) {
const json = await response.json();
const taskId = json?.data?.id;
const state = json?.data?.attributes?.state;
return {
pending: true,
state,
taskId,
};
}
if (!response.ok) {
const errorData = await response.json();
const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType];
throw new Error(
errorData?.errors?.detail ||
`Unable to retrieve ${reportName} PDF report. Contact support if the issue continues.`,
);
}
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString("base64");
return {
success: true,
data: base64,
filename: `scan-${scanId}-${reportType}.pdf`,
};
} catch (error) {
return {
error: getErrorMessage(error),
};
}
const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType];
return _fetchScanBinary(
scanId,
reportType,
`scan-${scanId}-${reportType}.pdf`,
`${reportName} PDF report`,
);
};
+230 -25
View File
@@ -2,17 +2,64 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth.config";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
TENANT_MEMBERSHIP_ROLE,
type TenantMembershipRole,
} from "@/types/users";
const getUsersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
query: z.string().default(""),
sort: z.string().optional().default(""),
filters: z
.record(
z.string(),
z.union([z.string(), z.array(z.string()), z.number()]).optional(),
)
.default({}),
pageSize: z.coerce.number().int().min(1).default(10),
});
const updateUserSchema = z.object({
userId: z.uuid(),
name: z.string().min(1).optional(),
email: z.email().optional(),
company_name: z.string().optional(),
password: z.string().min(1).optional(),
});
const deleteUserSchema = z.object({
userId: z.uuid(),
});
const removeUserFromTenantSchema = z.object({
userId: z.uuid(),
tenantId: z.uuid(),
});
const updateUserRoleSchema = z.object({
userId: z.uuid(),
roleId: z.uuid(),
});
type GetUsersInput = z.input<typeof getUsersSchema>;
type UpdateUserData = z.infer<typeof updateUserSchema>;
type UserAttributes = Omit<UpdateUserData, "userId">;
type MembershipResource = { id: string };
export const getUsers = async (rawParams: Partial<GetUsersInput> = {}) => {
const parsed = getUsersSchema.safeParse(rawParams);
if (!parsed.success) {
console.error("Invalid getUsers params:", parsed.error.flatten());
return undefined;
}
const { page, query, sort, filters, pageSize } = parsed.data;
export const getUsers = async ({
page = 1,
query = "",
sort = "",
filters = {},
pageSize = 10,
}) => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/users?include=roles");
@@ -46,22 +93,29 @@ export const getUsers = async ({
export const updateUser = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const userId = formData.get("userId") as string; // Ensure userId is a string
const userName = formData.get("name") as string | null;
const userPassword = formData.get("password") as string | null;
const userEmail = formData.get("email") as string | null;
const userCompanyName = formData.get("company_name") as string | null;
const rawData = {
userId: formData.get("userId"),
name: formData.get("name") ?? undefined,
email: formData.get("email") ?? undefined,
company_name: formData.get("company_name") ?? undefined,
password: formData.get("password") ?? undefined,
};
const parsed = updateUserSchema.safeParse(rawData);
if (!parsed.success) {
return { error: "Invalid user data" };
}
const { userId, name, email, company_name, password } = parsed.data;
const url = new URL(`${apiBaseUrl}/users/${userId}`);
// Prepare attributes to send based on changes
const attributes: Record<string, any> = {};
const attributes: UserAttributes = {};
// Add only changed fields
if (userName !== null) attributes.name = userName;
if (userEmail !== null) attributes.email = userEmail;
if (userCompanyName !== null) attributes.company_name = userCompanyName;
if (userPassword !== null) attributes.password = userPassword;
if (name !== undefined) attributes.name = name;
if (email !== undefined) attributes.email = email;
if (company_name !== undefined) attributes.company_name = company_name;
if (password !== undefined) attributes.password = password;
// If no fields have changed, don't send the request
if (Object.keys(attributes).length === 0) {
@@ -90,13 +144,14 @@ export const updateUser = async (formData: FormData) => {
export const updateUserRole = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const userId = formData.get("userId") as string;
const roleId = formData.get("roleId") as string;
// Validate required fields
if (!userId || !roleId) {
const parsed = updateUserRoleSchema.safeParse({
userId: formData.get("userId"),
roleId: formData.get("roleId"),
});
if (!parsed.success) {
return { error: "userId and roleId are required" };
}
const { userId, roleId } = parsed.data;
const url = new URL(`${apiBaseUrl}/users/${userId}/relationships/roles`);
@@ -124,11 +179,11 @@ export const updateUserRole = async (formData: FormData) => {
export const deleteUser = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: false });
const userId = formData.get("userId");
if (!userId) {
const parsed = deleteUserSchema.safeParse({ userId: formData.get("userId") });
if (!parsed.success) {
return { error: "User ID is required" };
}
const { userId } = parsed.data;
const url = new URL(`${apiBaseUrl}/users/${userId}`);
@@ -158,6 +213,156 @@ export const deleteUser = async (formData: FormData) => {
}
};
interface ServerActionErrorDetail {
detail: string;
code?: string;
}
interface ServerActionErrorResponse {
errors: ServerActionErrorDetail[];
}
interface RemoveUserFromTenantSuccess {
success: true;
}
type RemoveUserFromTenantResult =
| RemoveUserFromTenantSuccess
| ServerActionErrorResponse;
const toErrorResponse = (detail: string): ServerActionErrorResponse => ({
errors: [{ detail }],
});
export const removeUserFromTenant = async (
formData: FormData,
): Promise<RemoveUserFromTenantResult> => {
const headers = await getAuthHeaders({ contentType: false });
const parsed = removeUserFromTenantSchema.safeParse({
userId: formData.get("userId"),
tenantId: formData.get("tenantId"),
});
if (!parsed.success) {
return toErrorResponse("userId and tenantId are required");
}
const { userId, tenantId } = parsed.data;
// Resolve the target user's membership id for the current tenant on the
// server so the client form can open instantly without a prefetch.
//
// We cannot use `/users/{userId}/memberships` here: that endpoint ignores
// the path user id and always returns the authenticated user's memberships,
// which would make us try to delete the caller's own membership.
const listUrl = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`);
listUrl.searchParams.append("filter[user]", userId);
listUrl.searchParams.append("page[size]", "1");
let targetMembershipId: string | null = null;
try {
const listResponse = await fetch(listUrl.toString(), { headers });
if (!listResponse.ok) {
const errorData = await listResponse.json().catch(() => ({}));
return {
errors: errorData.errors ?? [
{ detail: "Failed to resolve the user's membership" },
],
};
}
const listData = (await listResponse.json()) as {
data?: MembershipResource[];
};
targetMembershipId = listData?.data?.[0]?.id ?? null;
} catch (error) {
const handled = handleApiError(error);
return toErrorResponse(
handled?.error ?? "Failed to resolve the user's membership",
);
}
if (!targetMembershipId) {
return toErrorResponse(
"This user is not a member of the current organization.",
);
}
const url = new URL(
`${apiBaseUrl}/tenants/${tenantId}/memberships/${targetMembershipId}`,
);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
errors: errorData.errors ?? [
{ detail: "Failed to expel the user from the organization" },
],
};
}
revalidatePath("/users");
return { success: true };
} catch (error) {
const handled = handleApiError(error);
return toErrorResponse(
handled?.error ?? "Failed to expel the user from the organization",
);
}
};
interface MembershipAttributesResource {
id: string;
attributes?: {
role?: string;
};
}
/**
* Resolve the active user's role inside the current tenant by querying the
* tenant memberships list with `filter[user]`. Returns `null` if the role
* cannot be determined (missing session, API error, or no match), so the
* caller can default-deny the destructive UI action.
*/
export const getCurrentUserTenantRole =
async (): Promise<TenantMembershipRole | null> => {
const session = await auth();
const userId = session?.userId;
const tenantId = session?.tenantId;
if (!userId || !tenantId) {
return null;
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`);
url.searchParams.append("filter[user]", userId);
url.searchParams.append("page[size]", "1");
try {
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: MembershipAttributesResource[];
};
const role = body?.data?.[0]?.attributes?.role;
if (
role === TENANT_MEMBERSHIP_ROLE.Owner ||
role === TENANT_MEMBERSHIP_ROLE.Member
) {
return role;
}
return null;
} catch (error) {
console.error("Error resolving current user's tenant role:", error);
return null;
}
};
export const getUserInfo = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
@@ -2,6 +2,7 @@ import Link from "next/link";
import { AttackSurfaceItem } from "@/actions/overview";
import { Card, CardContent } from "@/components/shadcn";
import { applyFailNonMutedFilters } from "@/lib";
interface AttackSurfaceCardItemProps {
item: AttackSurfaceItem;
@@ -18,8 +19,7 @@ export function AttackSurfaceCardItem({
// Add attack surface category filter
params.set("filter[category__in]", item.id);
params.set("filter[status__in]", "FAIL");
params.set("filter[muted]", "false");
applyFailNonMutedFilters(params);
// Add current page filters (provider, account, etc.)
Object.entries(filters).forEach(([key, value]) => {
@@ -6,6 +6,7 @@ import { LinkToFindings } from "@/components/overview";
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { CardTitle } from "@/components/shadcn";
import { DataTable } from "@/components/ui/table";
import { FINDINGS_FILTERED_SORT } from "@/lib";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
@@ -17,7 +18,7 @@ interface FindingsViewSSRProps {
export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
const page = 1;
const sort = "severity,-inserted_at";
const sort = FINDINGS_FILTERED_SORT;
const defaultFilters = {
"filter[status]": "FAIL",
@@ -8,6 +8,7 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { ScatterPlot } from "@/components/graphs/scatter-plot";
import { AlertPill } from "@/components/graphs/shared/alert-pill";
import type { BarDataPoint } from "@/components/graphs/types";
import { applyFailNonMutedFilters } from "@/lib";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
// Score color thresholds (0-100 scale, higher = better)
@@ -50,11 +51,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
// Add provider filter for the selected point
params.set("filter[provider_id__in]", selectedPoint.providerId);
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Filter by FAIL findings
params.set("filter[status__in]", "FAIL");
applyFailNonMutedFilters(params);
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
@@ -7,6 +7,7 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { RadarChart } from "@/components/graphs/radar-chart";
import type { BarDataPoint, RadarDataPoint } from "@/components/graphs/types";
import { Card } from "@/components/shadcn/card/card";
import { applyFailNonMutedFilters } from "@/lib";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
import { CategorySelector } from "./category-selector";
@@ -50,11 +51,7 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
// Add category filter for the selected point
params.set("filter[category__in]", selectedPoint.categoryId);
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Filter by FAIL findings
params.set("filter[status__in]", "FAIL");
applyFailNonMutedFilters(params);
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
@@ -0,0 +1,120 @@
import { render, screen } from "@testing-library/react";
import { Shield } from "lucide-react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { ResourceInventoryItem } from "@/actions/overview";
import { ResourcesInventoryCardItem } from "./resources-inventory-card-item";
vi.mock("next/link", () => ({
default: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
const baseItem: ResourceInventoryItem = {
id: "security",
label: "Security",
icon: Shield,
totalResources: 616,
totalFindings: 319,
failedFindings: 319,
newFailedFindings: 64,
severity: {
critical: 12,
high: 44,
medium: 108,
low: 155,
informational: 0,
},
};
describe("ResourcesInventoryCardItem", () => {
describe("when the group has resources and failed findings", () => {
it("builds a resources link that forwards current page filters", () => {
render(
<ResourcesInventoryCardItem
item={baseItem}
filters={{
"filter[provider_id__in]": "aws-provider",
"filter[account_id__in]": "account-1",
}}
/>,
);
const link = screen.getByRole("link");
expect(link).toHaveAttribute(
"href",
expect.stringContaining("/resources?"),
);
expect(link).toHaveAttribute(
"href",
expect.stringContaining("filter%5Bgroups__in%5D=security"),
);
expect(link).toHaveAttribute(
"href",
expect.stringContaining("filter%5Bprovider__in%5D=aws-provider"),
);
expect(link).toHaveAttribute(
"href",
expect.stringContaining("filter%5Baccount_id__in%5D=account-1"),
);
});
it("renders a fail accent bar so the card is theme-agnostic", () => {
render(<ResourcesInventoryCardItem item={baseItem} />);
const card = screen.getByText("Security").closest("[data-slot='card']");
const accent = card?.querySelector(
"[data-slot='resource-stats-card-accent']",
);
expect(card).not.toBeNull();
expect(accent).not.toBeNull();
});
});
describe("when the group has resources but no failed findings", () => {
it("renders a pass accent bar and the ShieldCheck badge", () => {
render(
<ResourcesInventoryCardItem
item={{
...baseItem,
totalFindings: 0,
failedFindings: 0,
newFailedFindings: 0,
}}
/>,
);
const card = screen.getByText("Security").closest("[data-slot='card']");
const accent = card?.querySelector(
"[data-slot='resource-stats-card-accent']",
);
expect(accent).not.toBeNull();
expect(screen.getByRole("link")).toBeInTheDocument();
});
});
describe("when the group has no resources", () => {
it("renders the empty state without a link", () => {
render(
<ResourcesInventoryCardItem
item={{
...baseItem,
totalResources: 0,
totalFindings: 0,
failedFindings: 0,
newFailedFindings: 0,
}}
/>,
);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(screen.getByText("No Findings to display")).toBeInTheDocument();
});
});
});
@@ -1,8 +1,9 @@
import { Bell, TriangleAlert } from "lucide-react";
import { Bell, ShieldCheck, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { ResourceInventoryItem } from "@/actions/overview";
import { CardVariant, ResourceStatsCard, StatItem } from "@/components/shadcn";
import { cn } from "@/lib/utils";
interface ResourcesInventoryCardItemProps {
item: ResourceInventoryItem;
@@ -15,6 +16,7 @@ export function ResourcesInventoryCardItem({
}: ResourcesInventoryCardItemProps) {
const hasFailedFindings = item.failedFindings > 0;
const hasResources = item.totalResources > 0;
const accent = hasFailedFindings ? CardVariant.fail : CardVariant.pass;
// Build URL with current filters + resource group specific filters
const buildResourcesUrl = () => {
@@ -49,52 +51,49 @@ export function ResourcesInventoryCardItem({
});
}
// Empty state when no resources
const header = {
icon: item.icon,
title: item.label,
resourceCount: `${item.totalResources.toLocaleString()} Resources`,
};
if (!hasResources) {
const cardContent = (
return (
<ResourceStatsCard
header={{
icon: item.icon,
title: item.label,
resourceCount: item.totalResources,
}}
emptyState={{
message: "No Findings to display",
}}
header={header}
emptyState={{ message: "No Findings to display" }}
className="flex-1"
/>
);
return cardContent;
}
// Card with findings data
const cardContent = (
<ResourceStatsCard
header={{
icon: item.icon,
title: item.label,
resourceCount: item.totalResources,
}}
header={header}
badge={{
icon: TriangleAlert,
icon: hasFailedFindings ? TriangleAlert : ShieldCheck,
count: item.failedFindings,
variant: CardVariant.fail,
variant: hasFailedFindings ? CardVariant.fail : CardVariant.pass,
}}
label="Fail Findings"
stats={stats}
variant={hasFailedFindings ? CardVariant.fail : CardVariant.default}
className={
accent={accent}
className={cn(
"flex-1 cursor-pointer shadow-sm transition-[transform,border-color,box-shadow] duration-200",
"hover:-translate-y-0.5 hover:shadow-md",
hasFailedFindings
? "hover:border-bg-fail/60 flex-1 cursor-pointer transition-all"
: "flex-1"
}
? "hover:border-border-error-primary"
: "hover:border-border-neutral-primary",
)}
/>
);
if (resourcesUrl) {
return (
<Link href={resourcesUrl} className="flex flex-1">
<Link
href={resourcesUrl}
className="focus-visible:ring-border-neutral-primary/40 flex flex-1 rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
{cardContent}
</Link>
);
@@ -3,7 +3,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
function ResourceCardSkeleton() {
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex flex-1 flex-col gap-2 rounded-xl border px-3 py-2">
<div className="border-border-neutral-secondary bg-bg-neutral-secondary relative flex flex-1 flex-col gap-2 overflow-hidden rounded-xl border px-3 py-2 shadow-sm">
<Skeleton className="absolute inset-x-0 top-0 h-1 rounded-none" />
{/* Header */}
<div className="flex w-full items-center gap-1">
<div className="flex flex-1 items-center gap-1">
@@ -6,6 +6,7 @@ import { useState } from "react";
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
import { applyFailNonMutedFilters } from "@/lib";
import {
SEVERITY_LEVELS,
SEVERITY_LINE_CONFIGS,
@@ -57,11 +58,8 @@ export const FindingSeverityOverTime = ({
}) => {
const params = new URLSearchParams();
// Always filter by FAIL status since this chart shows failed findings
params.set("filter[status__in]", "FAIL");
// Exclude muted findings
params.set("filter[muted]", "false");
// Show active failing findings only for this chart's drill-down.
applyFailNonMutedFilters(params);
// Add scan_ids filter
if (
@@ -5,6 +5,7 @@ import {
getComplianceAttributes,
getComplianceOverviewMetadataInfo,
getComplianceRequirements,
getCompliancesOverview,
} from "@/actions/compliances";
import { getThreatScore } from "@/actions/overview";
import { getScan } from "@/actions/scans";
@@ -25,7 +26,10 @@ import {
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ContentLayout } from "@/components/ui";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types";
import {
getReportTypeForCompliance,
pickLatestCisPerProvider,
} from "@/lib/compliance/compliance-report-types";
import { cn } from "@/lib/utils";
import {
AttributesData,
@@ -113,6 +117,27 @@ export default async function ComplianceDetail({
}
}
// Only CIS variants need the "is this the latest version per provider?"
// check to gate the PDF download button. Every other framework either
// always has a PDF (ENS/NIS2/CSA/ThreatScore) or none at all, so we skip
// the extra compliance-overview roundtrip for non-CIS detail pages.
const needsCisLatestCheck =
typeof complianceId === "string" && complianceId.startsWith("cis_");
let latestCisIds: Set<string> = new Set<string>();
if (needsCisLatestCheck && selectedScanId) {
const scanCompliancesData = await getCompliancesOverview({
scanId: selectedScanId,
});
const scanComplianceIds: string[] = Array.isArray(scanCompliancesData?.data)
? scanCompliancesData.data
.map((c: { id?: string }) => c?.id)
.filter(
(id: string | undefined): id is string => typeof id === "string",
)
: [];
latestCisIds = pickLatestCisPerProvider(scanComplianceIds);
}
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
// Detect if this is a ThreatScore compliance view
@@ -158,8 +183,10 @@ export default async function ComplianceDetail({
<ComplianceDownloadContainer
scanId={selectedScanId}
complianceId={complianceId}
reportType={getReportTypeForFramework(
reportType={getReportTypeForCompliance(
attributesData?.data?.[0]?.attributes?.framework,
complianceId,
latestCisIds.has(complianceId),
)}
/>
</div>
+12
View File
@@ -17,6 +17,7 @@ import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overv
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/ui";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import {
ExpandedScanData,
ScanEntity,
@@ -232,12 +233,23 @@ const SSRComplianceGrid = async ({
);
}
// Compute the set of latest CIS variants per provider once, so each card
// can gate its PDF button without re-parsing on every render. The backend
// only generates a CIS PDF for the latest version per provider, so any
// other CIS card must not expose the PDF download button.
const latestCisIds = pickLatestCisPerProvider(
compliancesData.data.map(
(compliance: ComplianceOverviewData) => compliance.id,
),
);
return (
<ComplianceOverviewPanel>
<ComplianceOverviewGrid
frameworks={frameworks}
scanId={scanId}
selectedScan={selectedScan}
latestCisIds={latestCisIds}
/>
</ComplianceOverviewPanel>
);
+4
View File
@@ -33,6 +33,10 @@ describe("findings page", () => {
expect(source).toContain("getLatestFindingGroups");
});
it("defaults filter[muted]=false through the shared muted filter helper", () => {
expect(source).toContain("applyDefaultMutedFilter(filtersWithScanDates)");
});
it("guards errors array access with a length check", () => {
expect(source).toContain("errors?.length > 0");
});
+11 -14
View File
@@ -40,25 +40,22 @@ export default async function Findings({
getScans({ pageSize: 50 }),
]);
const filtersWithScanDates = applyDefaultMutedFilter(
await resolveFindingScanDateFilters({
filters,
scans: scansData?.data || [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
}),
);
const filtersWithScanDates = await resolveFindingScanDateFilters({
filters,
scans: scansData?.data || [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
});
const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates);
const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates);
const metadataInfoData = await (
hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo
)({
query,
sort: encodedSort,
filters: filtersWithScanDates,
filters: resolvedFilters,
});
// Extract unique regions, services, categories, groups from the new endpoint
@@ -102,7 +99,7 @@ export default async function Findings({
<Suspense fallback={<SkeletonTableFindings />}>
<SSRDataTable
searchParams={resolvedSearchParams}
filters={filtersWithScanDates}
filters={resolvedFilters}
/>
</Suspense>
</FilterTransitionWrapper>
+26 -12
View File
@@ -2,7 +2,8 @@ import Link from "next/link";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles/roles";
import { getUsers } from "@/actions/users/users";
import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users";
import { auth } from "@/auth.config";
import { FilterControls } from "@/components/filters";
import { AddIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
@@ -10,6 +11,7 @@ import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
import { Role, SearchParamsProps, UserProps } from "@/types";
import { TENANT_MEMBERSHIP_ROLE } from "@/types/users";
export default async function Users({
searchParams,
@@ -58,19 +60,24 @@ const SSRDataTable = async ({
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const usersData = await getUsers({ query, page, sort, filters, pageSize });
const rolesData = await getRoles({});
const [usersData, rolesData, currentTenantRole, session] = await Promise.all([
getUsers({ query, page, sort, filters, pageSize }),
getRoles({}),
getCurrentUserTenantRole(),
auth(),
]);
const currentUserId = session?.userId;
const currentTenantId = session?.tenantId;
const isCurrentUserOwner = currentTenantRole === TENANT_MEMBERSHIP_ROLE.Owner;
// Create a dictionary for roles by user ID
const roleDict = (usersData?.included || []).reduce(
(acc: Record<string, any>, item: Role) => {
if (item.type === "roles") {
acc[item.id] = item.attributes;
}
return acc;
},
{} as Record<string, Role>,
);
const roleDict: Record<string, Role["attributes"]> = {};
for (const item of (usersData?.included || []) as Role[]) {
if (item.type === "roles") {
roleDict[item.id] = item.attributes;
}
}
// Generate the array of roles with all the roles available
const roles = Array.from(
@@ -88,6 +95,11 @@ const SSRDataTable = async ({
const roleId = user?.relationships?.roles?.data?.[0]?.id;
const role = roleDict?.[roleId] || null;
// Gate the "Expel" action server-side: only tenant owners may expel,
// and never against themselves (self-leave lives elsewhere).
const canBeExpelled =
isCurrentUserOwner && !!currentTenantId && user.id !== currentUserId;
return {
...user,
attributes: {
@@ -95,6 +107,8 @@ const SSRDataTable = async ({
role,
},
roles,
canBeExpelled,
currentTenantId: canBeExpelled ? currentTenantId : undefined,
};
});
+4 -2
View File
@@ -15,7 +15,8 @@ import { apiBaseUrl } from "./lib";
import type { RolePermissionAttributes } from "./types/users";
interface CustomJwtPayload extends JwtPayload {
user_id: string;
user_id?: string; // Optional - doesn't actually exist in JWT tokens
sub: string; // Standard JWT subject field - contains the actual user ID
tenant_id: string;
}
@@ -90,7 +91,8 @@ const applyDecodedClaims = (
target.accessTokenExpires = decodedToken.exp
? decodedToken.exp * 1000
: target.accessTokenExpires;
target.user_id = decodedToken.user_id ?? target.user_id;
// Map standard JWT "sub" field to user_id
target.user_id = decodedToken.sub ?? target.user_id;
target.tenant_id = decodedToken.tenant_id ?? target.tenant_id;
} catch (decodeError) {
// eslint-disable-next-line no-console
@@ -10,7 +10,7 @@ import {
} from "@/components/findings/table";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib";
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { Requirement } from "@/types/compliance";
import { FindingProps, FindingsResponse } from "@/types/components";
@@ -34,12 +34,16 @@ export const ClientAccordionContent = ({
const pageNumber = searchParams.get("page") || "1";
const complianceId = searchParams.get("complianceId");
const openFindingId = searchParams.get("id");
const defaultSort = "severity,status,-inserted_at";
const sort = searchParams.get("sort") || defaultSort;
const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT;
const loadedPageRef = useRef<string | null>(null);
const loadedSortRef = useRef<string | null>(null);
const loadedMutedRef = useRef<string | null>(null);
const isExpandedRef = useRef(false);
const region = searchParams.get("filter[region__in]") || "";
// Respect the user's muted preference from the URL; default to EXCLUDE
// so the requirement view stays consistent with every other findings
// surface in the app (findings page, resource drawer, overview widgets).
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
useEffect(() => {
async function loadFindings() {
@@ -49,10 +53,12 @@ export const ClientAccordionContent = ({
requirement.status !== "No findings" &&
(loadedPageRef.current !== pageNumber ||
loadedSortRef.current !== sort ||
loadedMutedRef.current !== mutedFilter ||
!isExpandedRef.current)
) {
loadedPageRef.current = pageNumber;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
isExpandedRef.current = true;
try {
@@ -62,7 +68,7 @@ export const ClientAccordionContent = ({
filters: {
"filter[check_id__in]": checkIds.join(","),
"filter[scan]": scanId,
"filter[muted]": "false",
"filter[muted]": mutedFilter,
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
@@ -101,7 +107,15 @@ export const ClientAccordionContent = ({
}
loadFindings();
}, [requirement, scanId, pageNumber, sort, region, disableFindings]);
}, [
requirement,
scanId,
pageNumber,
sort,
region,
mutedFilter,
disableFindings,
]);
const renderDetails = () => {
if (!complianceId) {
+14 -2
View File
@@ -10,7 +10,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types";
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
import {
getScoreIndicatorClass,
type ScoreColorVariant,
@@ -31,6 +31,13 @@ interface ComplianceCardProps {
complianceId: string;
id: string;
selectedScan?: ScanEntity;
/**
* True when this compliance_id is the highest CIS version for its provider.
* Only the latest CIS variant per provider has a PDF generated by the
* backend, so older variants must not expose the PDF download button.
* Ignored for non-CIS frameworks.
*/
isLatestCisForProvider?: boolean;
}
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
@@ -41,6 +48,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
scanId,
complianceId,
id,
isLatestCisForProvider = false,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -112,7 +120,11 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForFramework(title)}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
/>
</div>
@@ -6,15 +6,16 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { downloadComplianceCsvMock, downloadComplianceReportPdfMock } =
vi.hoisted(() => ({
const { downloadComplianceCsvMock, downloadCompliancePdfMock } = vi.hoisted(
() => ({
downloadComplianceCsvMock: vi.fn(),
downloadComplianceReportPdfMock: vi.fn(),
}));
downloadCompliancePdfMock: vi.fn(),
}),
);
vi.mock("@/lib/helper", () => ({
downloadComplianceCsv: downloadComplianceCsvMock,
downloadComplianceReportPdf: downloadComplianceReportPdfMock,
downloadCompliancePdf: downloadCompliancePdfMock,
}));
vi.mock("@/components/ui", () => ({
@@ -124,7 +125,7 @@ describe("ComplianceDownloadContainer", () => {
"compliance-1",
{},
);
expect(downloadComplianceReportPdfMock).toHaveBeenCalledWith(
expect(downloadCompliancePdfMock).toHaveBeenCalledWith(
"scan-1",
"threatscore",
{},
@@ -15,10 +15,7 @@ import {
} from "@/components/shadcn/tooltip";
import { toast } from "@/components/ui";
import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types";
import {
downloadComplianceCsv,
downloadComplianceReportPdf,
} from "@/lib/helper";
import { downloadComplianceCsv, downloadCompliancePdf } from "@/lib/helper";
import { cn } from "@/lib/utils";
interface ComplianceDownloadContainerProps {
@@ -61,7 +58,7 @@ export const ComplianceDownloadContainer = ({
if (!reportType || isDownloadingPdf) return;
setIsDownloadingPdf(true);
try {
await downloadComplianceReportPdf(scanId, reportType, toast);
await downloadCompliancePdf(scanId, reportType, toast);
} finally {
setIsDownloadingPdf(false);
}
@@ -11,12 +11,19 @@ interface ComplianceOverviewGridProps {
frameworks: ComplianceOverviewData[];
scanId: string;
selectedScan?: ScanEntity;
/**
* Subset of compliance_ids that represent the latest CIS variant per
* provider. Only those cards expose the PDF download button, matching
* the backend's latest-only CIS PDF generation.
*/
latestCisIds?: ReadonlySet<string>;
}
export const ComplianceOverviewGrid = ({
frameworks,
scanId,
selectedScan,
latestCisIds,
}: ComplianceOverviewGridProps) => {
const [searchTerm, setSearchTerm] = useState("");
@@ -61,6 +68,7 @@ export const ComplianceOverviewGrid = ({
complianceId={id}
id={id}
selectedScan={selectedScan}
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
/>
);
})}
@@ -4,12 +4,7 @@ import { useSearchParams } from "next/navigation";
import { Checkbox } from "@/components/shadcn";
import { useUrlFilters } from "@/hooks/use-url-filters";
// Constants for muted filter URL values
const MUTED_FILTER_VALUES = {
EXCLUDE: "false",
INCLUDE: "include",
} as const;
import { MUTED_FILTER } from "@/lib";
/** Batch mode: caller controls both the checked state and the notification callback (all-or-nothing). */
interface CustomCheckboxMutedFindingsBatchProps {
@@ -53,7 +48,7 @@ export const CustomCheckboxMutedFindings = ({
const includeMuted =
checkedProp !== undefined
? checkedProp
: mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
: mutedFilterValue === MUTED_FILTER.INCLUDE;
const handleMutedChange = (checked: boolean | "indeterminate") => {
const isChecked = checked === true;
@@ -62,7 +57,7 @@ export const CustomCheckboxMutedFindings = ({
// Batch mode: notify caller instead of navigating
onBatchChange(
"muted",
isChecked ? MUTED_FILTER_VALUES.INCLUDE : MUTED_FILTER_VALUES.EXCLUDE,
isChecked ? MUTED_FILTER.INCLUDE : MUTED_FILTER.EXCLUDE,
);
return;
}
@@ -71,10 +66,10 @@ export const CustomCheckboxMutedFindings = ({
navigateWithParams((params) => {
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
params.set("filter[muted]", MUTED_FILTER.INCLUDE);
} else {
// Exclude muted: apply filter to show only non-muted
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
params.set("filter[muted]", MUTED_FILTER.EXCLUDE);
}
});
};
@@ -1,48 +1,26 @@
import {
FAIL_FILTER_VALUE,
includesMutedFindings,
splitCsvFilterValues,
} from "@/lib/findings-filters";
import { FindingGroupRow } from "@/types";
function parseStatusFilterValue(statusFilterValue?: string): string[] {
if (!statusFilterValue) {
return [];
}
return statusFilterValue
.split(",")
.map((status) => status.trim().toUpperCase())
.filter(Boolean);
}
export function isFailOnlyStatusFilter(
filters: Record<string, string | string[] | undefined>,
): boolean {
const directStatusValues = parseStatusFilterValue(
typeof filters["filter[status]"] === "string"
? filters["filter[status]"]
: undefined,
// Normalise both `filter[status]` and `filter[status__in]` CSV forms
// and uppercase so "fail", "Fail" etc. still match the wire value.
const direct = splitCsvFilterValues(filters["filter[status]"]).map((s) =>
s.toUpperCase(),
);
if (directStatusValues.length > 0) {
return directStatusValues.length === 1 && directStatusValues[0] === "FAIL";
if (direct.length > 0) {
return direct.length === 1 && direct[0] === FAIL_FILTER_VALUE;
}
const multiStatusValues = parseStatusFilterValue(
typeof filters["filter[status__in]"] === "string"
? filters["filter[status__in]"]
: undefined,
const multi = splitCsvFilterValues(filters["filter[status__in]"]).map((s) =>
s.toUpperCase(),
);
return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL";
}
function includesMutedFindings(
filters: Record<string, string | string[] | undefined>,
): boolean {
const mutedFilter = filters["filter[muted]"];
if (Array.isArray(mutedFilter)) {
return mutedFilter.includes("include");
}
return mutedFilter === "include";
return multi.length === 1 && multi[0] === FAIL_FILTER_VALUE;
}
export function getFilteredFindingGroupResourceCount(
+3 -4
View File
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts";
import { PROVIDER_BADGE_BY_NAME } from "@/components/icons/providers-badge";
import { applyFailNonMutedFilters } from "@/lib";
import { initializeChartColors } from "@/lib/charts/colors";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
@@ -463,8 +464,7 @@ export function SankeyChart({
if (severityFilter) {
const params = new URLSearchParams(searchParams.toString());
params.set("filter[severity__in]", severityFilter);
params.set("filter[status__in]", "FAIL");
params.set("filter[muted]", "false");
applyFailNonMutedFilters(params);
router.push(`/findings?${params.toString()}`);
}
};
@@ -484,8 +484,7 @@ export function SankeyChart({
}
params.set("filter[severity__in]", severityFilter);
params.set("filter[status__in]", "FAIL");
params.set("filter[muted]", "false");
applyFailNonMutedFilters(params);
router.push(`/findings?${params.toString()}`);
}
};
@@ -1,9 +1,17 @@
import Link from "next/link";
import {
FAIL_FILTER_VALUE,
NEW_DELTA_FILTER_VALUE,
} from "@/lib/findings-filters";
import { FINDING_GROUPS_FILTERED_SORT } from "@/lib/findings-sort";
const FINDINGS_LINK_HREF = `/findings?sort=${FINDING_GROUPS_FILTERED_SORT}&filter[status__in]=${FAIL_FILTER_VALUE}&filter[delta]=${NEW_DELTA_FILTER_VALUE}`;
export const LinkToFindings = () => {
return (
<Link
href="/findings?sort=-severity,-last_seen_at&filter[status__in]=FAIL&filter[delta]=new"
href={FINDINGS_LINK_HREF}
aria-label="Go to Findings page"
className="text-button-tertiary hover:text-button-tertiary-hover text-sm font-medium transition-colors"
>
@@ -153,7 +153,7 @@ describe("useProviderWizardController", () => {
expect(onOpenChange).not.toHaveBeenCalled();
});
it("moves to launch step after a successful connection test in update mode", async () => {
it("closes the wizard after a successful connection test in update mode", async () => {
// Given
const onOpenChange = vi.fn();
const { result } = renderHook(() =>
@@ -181,9 +181,10 @@ describe("useProviderWizardController", () => {
result.current.handleTestSuccess();
});
// Then
expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH);
expect(onOpenChange).not.toHaveBeenCalled();
// Then: credential rotation never surfaces the launch/schedule step
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH);
});
it("does not override launch footer config in the controller", () => {
@@ -197,6 +197,12 @@ export function useProviderWizardController({
};
const handleTestSuccess = () => {
if (
useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE
) {
handleClose();
return;
}
setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH);
};
@@ -234,6 +240,7 @@ export function useProviderWizardController({
handleTestSuccess,
isOrgDirectEntry,
isProviderFlow,
mode,
modalTitle,
openOrganizationsFlow,
orgCurrentStep,
@@ -10,7 +10,10 @@ import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal";
import { useScrollHint } from "@/hooks/use-scroll-hint";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
import {
PROVIDER_WIZARD_MODE,
PROVIDER_WIZARD_STEP,
} from "@/types/provider-wizard";
import { useProviderWizardController } from "./hooks/use-provider-wizard-controller";
import {
@@ -23,7 +26,12 @@ import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
import { LaunchStep } from "./steps/launch-step";
import { TestConnectionStep } from "./steps/test-connection-step";
import type { OrgWizardInitialData, ProviderWizardInitialData } from "./types";
import { WizardStepper } from "./wizard-stepper";
import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper";
const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
0,
PROVIDER_WIZARD_STEP.LAUNCH,
);
interface ProviderWizardModalProps {
open: boolean;
@@ -47,6 +55,7 @@ export function ProviderWizardModal({
handleTestSuccess,
isOrgDirectEntry,
isProviderFlow,
mode,
modalTitle,
openOrganizationsFlow,
orgCurrentStep,
@@ -97,7 +106,14 @@ export function ProviderWizardModal({
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8 lg:flex-row">
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
{isProviderFlow ? (
<WizardStepper currentStep={currentStep} />
<WizardStepper
currentStep={currentStep}
steps={
mode === PROVIDER_WIZARD_MODE.UPDATE
? UPDATE_MODE_WIZARD_STEPS
: undefined
}
/>
) : (
<WizardStepper
currentStep={orgCurrentStep}
@@ -7,17 +7,18 @@ import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
import { IconComponent, IconSvgProps } from "@/types/components";
interface WizardStepperProps {
currentStep: number;
stepOffset?: number;
}
interface StepConfig {
label: string;
description: string;
icon: IconComponent;
}
interface WizardStepperProps {
currentStep: number;
stepOffset?: number;
steps?: StepConfig[];
}
const STEPS: StepConfig[] = [
{
label: "Link a Cloud Provider",
@@ -43,18 +44,21 @@ const STEPS: StepConfig[] = [
},
];
export const PROVIDER_WIZARD_STEPS = STEPS;
export function WizardStepper({
currentStep,
stepOffset = 0,
steps = STEPS,
}: WizardStepperProps) {
const activeVisualStep = Math.max(
0,
Math.min(currentStep + stepOffset, STEPS.length - 1),
Math.min(currentStep + stepOffset, steps.length - 1),
);
return (
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
{STEPS.map((step, index) => {
{steps.map((step, index) => {
const isComplete = index < activeVisualStep;
const isActive = index === activeVisualStep;
const isInactive = index > activeVisualStep;
@@ -67,7 +71,7 @@ export function WizardStepper({
isActive={isActive}
icon={step.icon}
/>
{index < STEPS.length - 1 && (
{index < steps.length - 1 && (
<StepConnector isComplete={isComplete} />
)}
</div>

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