Skip to content

Commit 2304bf0

Browse files
authored
feat(compliance): add CIS pdf reporting (#10650)
1 parent 2ca7410 commit 2304bf0

23 files changed

Lines changed: 2411 additions & 104 deletions

api/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to the **Prowler API** are documented in this file.
44

55
## [1.26.0] (Prowler UNRELEASED)
66

7+
### 🚀 Added
8+
9+
- 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)
10+
711
### 🔄 Changed
812

913
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
@@ -736,4 +740,4 @@ All notable changes to the **Prowler API** are documented in this file.
736740
- Findings metadata endpoint received a performance improvement [(#6863)](https://github.com/prowler-cloud/prowler/pull/6863)
737741
- Increased the allowed length of the provider UID for Kubernetes providers [(#6869)](https://github.com/prowler-cloud/prowler/pull/6869)
738742

739-
---
743+
---

api/src/backend/api/tests/test_views.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4113,6 +4113,51 @@ def test_compliance_local_file(
41134113
assert cd.startswith('attachment; filename="')
41144114
assert cd.endswith(f'filename="{fname.name}"')
41154115

4116+
def test_cis_no_output(self, authenticated_client, scans_fixture):
4117+
"""CIS PDF endpoint must 404 when the scan has no output_location."""
4118+
scan = scans_fixture[0]
4119+
scan.state = StateChoices.COMPLETED
4120+
scan.output_location = ""
4121+
scan.save()
4122+
4123+
url = reverse("scan-cis", kwargs={"pk": scan.id})
4124+
resp = authenticated_client.get(url)
4125+
assert resp.status_code == status.HTTP_404_NOT_FOUND
4126+
assert (
4127+
resp.json()["errors"]["detail"]
4128+
== "The scan has no reports, or the CIS report generation task has not started yet."
4129+
)
4130+
4131+
def test_cis_local_file(self, authenticated_client, scans_fixture, monkeypatch):
4132+
"""CIS PDF endpoint must serve the latest generated PDF."""
4133+
scan = scans_fixture[0]
4134+
scan.state = StateChoices.COMPLETED
4135+
4136+
with tempfile.TemporaryDirectory() as tmp:
4137+
tmp_path = Path(tmp)
4138+
base = tmp_path / "reports"
4139+
cis_dir = base / "cis"
4140+
cis_dir.mkdir(parents=True, exist_ok=True)
4141+
fname = cis_dir / "prowler-output-aws-20260101000000_cis_report.pdf"
4142+
fname.write_bytes(b"%PDF-1.4 fake pdf")
4143+
4144+
scan.output_location = str(base / "scan.zip")
4145+
scan.save()
4146+
4147+
monkeypatch.setattr(
4148+
glob,
4149+
"glob",
4150+
lambda p: [str(fname)] if p.endswith("*_cis_report.pdf") else [],
4151+
)
4152+
4153+
url = reverse("scan-cis", kwargs={"pk": scan.id})
4154+
resp = authenticated_client.get(url)
4155+
assert resp.status_code == status.HTTP_200_OK
4156+
assert resp["Content-Type"] == "application/pdf"
4157+
cd = resp["Content-Disposition"]
4158+
assert cd.startswith('attachment; filename="')
4159+
assert cd.endswith(f'filename="{fname.name}"')
4160+
41164161
@patch("api.v1.views.Task.objects.get")
41174162
@patch("api.v1.views.TaskSerializer")
41184163
def test__get_task_status_returns_none_if_task_not_executing(

api/src/backend/api/v1/views.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1926,6 +1926,27 @@ def destroy(self, request, *args, pk=None, **kwargs):
19261926
),
19271927
},
19281928
),
1929+
cis=extend_schema(
1930+
tags=["Scan"],
1931+
summary="Retrieve CIS Benchmark compliance report",
1932+
description="Download the CIS Benchmark compliance report as a PDF file. "
1933+
"When a provider ships multiple CIS versions, the report is generated "
1934+
"for the highest available version.",
1935+
request=None,
1936+
responses={
1937+
200: OpenApiResponse(
1938+
description="PDF file containing the CIS compliance report"
1939+
),
1940+
202: OpenApiResponse(description="The task is in progress"),
1941+
401: OpenApiResponse(
1942+
description="API key missing or user not Authenticated"
1943+
),
1944+
403: OpenApiResponse(description="There is a problem with credentials"),
1945+
404: OpenApiResponse(
1946+
description="The scan has no CIS reports, or the CIS report generation task has not started yet"
1947+
),
1948+
},
1949+
),
19291950
)
19301951
@method_decorator(CACHE_DECORATOR, name="list")
19311952
@method_decorator(CACHE_DECORATOR, name="retrieve")
@@ -1994,6 +2015,9 @@ def get_serializer_class(self):
19942015
elif self.action == "csa":
19952016
if hasattr(self, "response_serializer_class"):
19962017
return self.response_serializer_class
2018+
elif self.action == "cis":
2019+
if hasattr(self, "response_serializer_class"):
2020+
return self.response_serializer_class
19972021
return super().get_serializer_class()
19982022

19992023
def partial_update(self, request, *args, **kwargs):
@@ -2236,6 +2260,45 @@ def compliance(self, request, pk=None, name=None):
22362260
content, filename = loader
22372261
return self._serve_file(content, filename, "text/csv")
22382262

2263+
@action(
2264+
detail=True,
2265+
methods=["get"],
2266+
url_name="cis",
2267+
)
2268+
def cis(self, request, pk=None):
2269+
scan = self.get_object()
2270+
running_resp = self._get_task_status(scan)
2271+
if running_resp:
2272+
return running_resp
2273+
2274+
if not scan.output_location:
2275+
return Response(
2276+
{
2277+
"detail": "The scan has no reports, or the CIS report generation task has not started yet."
2278+
},
2279+
status=status.HTTP_404_NOT_FOUND,
2280+
)
2281+
2282+
if scan.output_location.startswith("s3://"):
2283+
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
2284+
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
2285+
prefix = os.path.join(
2286+
os.path.dirname(key_prefix),
2287+
"cis",
2288+
"*_cis_report.pdf",
2289+
)
2290+
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
2291+
else:
2292+
base = os.path.dirname(scan.output_location)
2293+
pattern = os.path.join(base, "cis", "*_cis_report.pdf")
2294+
loader = self._load_file(pattern, s3=False)
2295+
2296+
if isinstance(loader, Response):
2297+
return loader
2298+
2299+
content, filename = loader
2300+
return self._serve_file(content, filename, "application/pdf")
2301+
22392302
@action(
22402303
detail=True,
22412304
methods=["get"],
131 KB
Loading

0 commit comments

Comments
 (0)