mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-21 05:13:00 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bebe8333f |
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.1
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -2,15 +2,6 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.30.1] (Prowler v5.29.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420)
|
||||
- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
|
||||
|
||||
---
|
||||
|
||||
## [1.30.0] (Prowler v5.29.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ dependencies = [
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.29",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -68,7 +68,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.30.1"
|
||||
version = "1.30.0"
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.30.1
|
||||
version: 1.30.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -24,11 +24,9 @@ from conftest import (
|
||||
today_after_n_days,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db.models import Count
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
@@ -66,7 +64,6 @@ from api.models import (
|
||||
ProviderSecret,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceTag,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
@@ -3859,20 +3856,16 @@ class TestScanViewSet:
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=task_result,
|
||||
)
|
||||
dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}
|
||||
dummy_task = Task.objects.create(tenant_id=scan.tenant_id)
|
||||
dummy_task.id = "dummy-task-id"
|
||||
dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING}
|
||||
|
||||
with patch(
|
||||
"api.v1.views.TaskSerializer",
|
||||
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
|
||||
with (
|
||||
patch("api.v1.views.Task.objects.get", return_value=dummy_task),
|
||||
patch(
|
||||
"api.v1.views.TaskSerializer",
|
||||
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
|
||||
),
|
||||
):
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
@@ -4193,88 +4186,6 @@ class TestScanViewSet:
|
||||
assert resp.status_code == status.HTTP_302_FOUND
|
||||
assert resp["Location"] == presigned_url
|
||||
|
||||
def test_compliance_s3_returns_latest_match(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
"""When several files match, the most recently modified one is served."""
|
||||
scan = scans_fixture[0]
|
||||
bucket = "bucket"
|
||||
scan.output_location = f"s3://{bucket}/path/scan.zip"
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
|
||||
latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv"
|
||||
|
||||
class FakeS3Client:
|
||||
def list_objects_v2(self, Bucket, Prefix):
|
||||
return {
|
||||
"Contents": [
|
||||
{
|
||||
"Key": old_key,
|
||||
"LastModified": datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
},
|
||||
{
|
||||
"Key": latest_key,
|
||||
"LastModified": datetime(2024, 2, 2, tzinfo=timezone.utc),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
|
||||
assert Params["Key"] == latest_key
|
||||
return "https://test-bucket.s3.amazonaws.com/latest"
|
||||
|
||||
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
|
||||
|
||||
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_302_FOUND
|
||||
assert resp["Location"].endswith("/latest")
|
||||
|
||||
def test_compliance_local_returns_latest_match(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
"""The local branch serves the most recently modified matching file."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
comp_dir = Path(tmp) / "reports" / "compliance"
|
||||
comp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv"
|
||||
old_file.write_bytes(b"old")
|
||||
latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv"
|
||||
latest_file.write_bytes(b"latest")
|
||||
# Make `latest_file` newer regardless of creation order.
|
||||
os.utime(old_file, (1_700_000_000, 1_700_000_000))
|
||||
os.utime(latest_file, (1_700_000_100, 1_700_000_100))
|
||||
|
||||
scan.output_location = str(Path(tmp) / "reports" / "scan.zip")
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda p: [str(old_file), str(latest_file)],
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
"scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
|
||||
)
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.content == b"latest"
|
||||
assert resp["Content-Disposition"].endswith(
|
||||
f'filename="{latest_file.name}"'
|
||||
)
|
||||
|
||||
def test_compliance_s3_not_found(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
@@ -4383,24 +4294,18 @@ class TestScanViewSet:
|
||||
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(
|
||||
self, mock_task_serializer, authenticated_client, scans_fixture
|
||||
self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=task_result,
|
||||
)
|
||||
task = Task.objects.create(tenant_id=scan.tenant_id)
|
||||
mock_task_get.return_value = task
|
||||
mock_task_serializer.return_value.data = {
|
||||
"id": str(task.id),
|
||||
"state": StateChoices.COMPLETED,
|
||||
@@ -4421,7 +4326,6 @@ class TestScanViewSet:
|
||||
scan.save()
|
||||
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
@@ -4442,51 +4346,6 @@ class TestScanViewSet:
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert response.data["id"] == str(task.id)
|
||||
|
||||
@patch("api.v1.views.TaskSerializer")
|
||||
def test__get_task_status_returns_latest_task(
|
||||
self, mock_task_serializer, authenticated_client, scans_fixture
|
||||
):
|
||||
"""With several scan-report tasks for the scan, the most recent is used."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
old_task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
),
|
||||
)
|
||||
new_task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
),
|
||||
)
|
||||
# `inserted_at` is `auto_now_add`, and within the test transaction the DB
|
||||
# `now()` is constant, so force distinct timestamps to make order_by stable.
|
||||
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
Task.objects.filter(pk=old_task.pk).update(inserted_at=base)
|
||||
Task.objects.filter(pk=new_task.pk).update(
|
||||
inserted_at=base + timedelta(hours=1)
|
||||
)
|
||||
|
||||
mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace(
|
||||
data={"id": str(instance.id), "state": StateChoices.EXECUTING}
|
||||
)
|
||||
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert str(new_task.id) in response["Content-Location"]
|
||||
assert str(old_task.id) not in response["Content-Location"]
|
||||
|
||||
@patch("api.v1.views.get_s3_client")
|
||||
@patch("api.v1.views.sentry_sdk.capture_exception")
|
||||
def test_compliance_list_objects_client_error(
|
||||
@@ -7057,80 +6916,6 @@ class TestFindingViewSet:
|
||||
== findings_fixture[0].status
|
||||
)
|
||||
|
||||
def test_findings_list_resource_tags_no_n_plus_one(
|
||||
self, authenticated_client, findings_fixture
|
||||
):
|
||||
"""Listing findings must load every resource's tags in a constant
|
||||
number of queries, no matter how many findings/resources are returned.
|
||||
|
||||
This guards ``FindingViewSet._optimize_tags_loading`` against
|
||||
regressions that would reintroduce one extra query per resource (the
|
||||
N+1 the prefetch was added to remove).
|
||||
"""
|
||||
scan = findings_fixture[0].scan
|
||||
tenant_id = findings_fixture[0].tenant_id
|
||||
provider = scan.provider
|
||||
|
||||
def _create_finding_with_tagged_resource(index):
|
||||
resource = Resource.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}",
|
||||
name=f"N+1 Instance {index}",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
type="prowler-test",
|
||||
)
|
||||
resource.upsert_or_delete_tags(
|
||||
[
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
key=f"key-{index}",
|
||||
value=f"value-{index}",
|
||||
)
|
||||
]
|
||||
)
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"n_plus_one_finding_{index}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended="n+1 status",
|
||||
impact=Severity.medium,
|
||||
severity=Severity.medium,
|
||||
check_id="test_check_id",
|
||||
check_metadata={"CheckId": "test_check_id", "servicename": "ec2"},
|
||||
first_seen_at="2024-01-02T00:00:00Z",
|
||||
)
|
||||
finding.add_resources([resource])
|
||||
return finding
|
||||
|
||||
params = {"filter[inserted_at]": TODAY, "include": "resources"}
|
||||
|
||||
# Baseline: the two findings provided by the fixture.
|
||||
with CaptureQueriesContext(connection) as baseline:
|
||||
response = authenticated_client.get(reverse("finding-list"), params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Add more findings, each with its own resource carrying tags.
|
||||
extra_findings = 5
|
||||
for index in range(extra_findings):
|
||||
_create_finding_with_tagged_resource(index)
|
||||
|
||||
with CaptureQueriesContext(connection) as scaled:
|
||||
response = authenticated_client.get(reverse("finding-list"), params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(findings_fixture) + extra_findings
|
||||
|
||||
# The query count must not grow with the number of findings/resources.
|
||||
assert len(scaled.captured_queries) == len(baseline.captured_queries), (
|
||||
"Resource tags are not being prefetched: "
|
||||
f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} "
|
||||
f"findings vs {len(scaled.captured_queries)} for "
|
||||
f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression "
|
||||
"in FindingViewSet._optimize_tags_loading."
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"include_values, expected_resources",
|
||||
[
|
||||
|
||||
@@ -2059,17 +2059,12 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
|
||||
task = scan_instance.task
|
||||
else:
|
||||
# A scan can have several `scan-report` tasks (e.g. re-runs); take the
|
||||
# most recent one. `.first()` also avoids `MultipleObjectsReturned`.
|
||||
task = (
|
||||
Task.objects.filter(
|
||||
try:
|
||||
task = Task.objects.get(
|
||||
task_runner_task__task_name="scan-report",
|
||||
task_runner_task__task_kwargs__contains=str(scan_instance.id),
|
||||
)
|
||||
.order_by("-inserted_at")
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
except Task.DoesNotExist:
|
||||
return None
|
||||
|
||||
self.response_serializer_class = TaskSerializer
|
||||
@@ -2144,32 +2139,27 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
contents = resp.get("Contents", [])
|
||||
matches = []
|
||||
keys = []
|
||||
for obj in contents:
|
||||
key = obj["Key"]
|
||||
key_basename = os.path.basename(key)
|
||||
if any(ch in suffix for ch in ("*", "?", "[")):
|
||||
if fnmatch.fnmatch(key_basename, suffix):
|
||||
matches.append(obj)
|
||||
keys.append(key)
|
||||
elif key_basename == suffix:
|
||||
matches.append(obj)
|
||||
keys.append(key)
|
||||
elif key.endswith(suffix):
|
||||
# Backward compatibility if suffix already includes directories
|
||||
matches.append(obj)
|
||||
if not matches:
|
||||
keys.append(key)
|
||||
if not keys:
|
||||
return Response(
|
||||
{
|
||||
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
# Return the most recently modified match (latest report) when
|
||||
# several files share the prefix/suffix. `list_objects_v2` always
|
||||
# returns `LastModified`; the fallback keeps ordering deterministic
|
||||
# if it is ever absent.
|
||||
key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[
|
||||
"Key"
|
||||
]
|
||||
# path_pattern here is prefix, but in compliance we build correct suffix check before
|
||||
key = keys[0]
|
||||
else:
|
||||
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
|
||||
key = path_pattern
|
||||
@@ -2219,9 +2209,7 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
# Return the most recently modified match (latest report) when the
|
||||
# pattern resolves to several files.
|
||||
filepath = max(files, key=os.path.getmtime)
|
||||
filepath = files[0]
|
||||
with open(filepath, "rb") as f:
|
||||
content = f.read()
|
||||
filename = os.path.basename(filepath)
|
||||
@@ -3761,16 +3749,6 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
return queryset
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
def _optimize_tags_loading(self, queryset):
|
||||
"""Prefetch resource tags to avoid N+1 queries when serializing findings"""
|
||||
return queryset.prefetch_related(
|
||||
Prefetch(
|
||||
"resources__tags",
|
||||
queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id),
|
||||
to_attr="prefetched_tags",
|
||||
)
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
return self.paginate_by_pk(
|
||||
|
||||
@@ -467,31 +467,8 @@ def delete_tenant_task(tenant_id: str):
|
||||
return delete_tenant(pk=tenant_id)
|
||||
|
||||
|
||||
def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path:
|
||||
"""Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id})."""
|
||||
return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id)
|
||||
|
||||
|
||||
class ScanReportRLSTask(RLSTask):
|
||||
"""
|
||||
RLS task that removes the scan's tmp output directory when the task fails.
|
||||
|
||||
Covers failures both inside and outside the task body (e.g. ENOSPC mid-write,
|
||||
or setup errors) so partial artifacts do not accumulate on the worker disk.
|
||||
"""
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002
|
||||
del args # Required by Celery's Task.on_failure signature; not used.
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
scan_id = kwargs.get("scan_id")
|
||||
|
||||
if tenant_id and scan_id:
|
||||
logger.error(f"Scan report task {task_id} failed: {exc}")
|
||||
rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=ScanReportRLSTask,
|
||||
base=RLSTask,
|
||||
name="scan-report",
|
||||
queue="scan-reports",
|
||||
)
|
||||
@@ -541,9 +518,6 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
out_dir, comp_dir = _generate_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
|
||||
)
|
||||
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
|
||||
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
|
||||
def get_writer(writer_map, name, factory, is_last):
|
||||
"""
|
||||
@@ -692,7 +666,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
# TODO: We need to create a new periodic task to delete the output files
|
||||
# This task shouldn't be responsible for deleting the output files
|
||||
try:
|
||||
rmtree(scan_tmp_dir, ignore_errors=True)
|
||||
rmtree(Path(compressed).parent, ignore_errors=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting output files: {e}")
|
||||
final_location, did_upload = upload_uri, True
|
||||
|
||||
@@ -15,10 +15,8 @@ from tasks.jobs.lighthouse_providers import (
|
||||
from tasks.tasks import (
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
ScanReportRLSTask,
|
||||
_cleanup_orphan_scheduled_scans,
|
||||
_perform_scan_complete_tasks,
|
||||
_scan_tmp_output_directory,
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
generate_outputs_task,
|
||||
@@ -773,38 +771,6 @@ class TestGenerateOutputs:
|
||||
mock_s3_task.assert_called_once()
|
||||
|
||||
|
||||
class TestScanReportRLSTaskOnFailure:
|
||||
def test_on_failure_removes_scan_tmp_directory(self):
|
||||
task = ScanReportRLSTask()
|
||||
|
||||
with patch("tasks.tasks.rmtree") as mock_rmtree:
|
||||
task.on_failure(
|
||||
exc=OSError("No space left on device"),
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_rmtree.assert_called_once_with(
|
||||
_scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True
|
||||
)
|
||||
|
||||
def test_on_failure_skips_when_missing_kwargs(self):
|
||||
task = ScanReportRLSTask()
|
||||
|
||||
with patch("tasks.tasks.rmtree") as mock_rmtree:
|
||||
task.on_failure(
|
||||
exc=OSError("No space left on device"),
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_rmtree.assert_not_called()
|
||||
|
||||
|
||||
class TestScanCompleteTasks:
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
|
||||
@patch("tasks.tasks.chain")
|
||||
|
||||
Generated
+4
-54
@@ -4410,8 +4410,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.29.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29#a769e3761532d9332cb64078ef09ebf7ffb15292" }
|
||||
version = "5.27.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4484,13 +4484,9 @@ dependencies = [
|
||||
{ name = "pygithub" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "scaleway" },
|
||||
{ name = "schema" },
|
||||
{ name = "shodan" },
|
||||
{ name = "slack-sdk" },
|
||||
{ name = "stackit-core" },
|
||||
{ name = "stackit-iaas" },
|
||||
{ name = "stackit-resourcemanager" },
|
||||
{ name = "tabulate" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uuid6" },
|
||||
@@ -4498,7 +4494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.30.1"
|
||||
version = "1.30.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -4594,7 +4590,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -5530,52 +5526,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-core"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-iaas"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-resourcemanager"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statsd"
|
||||
version = "4.0.1"
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.29.1] (Prowler v5.29.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
|
||||
|
||||
---
|
||||
|
||||
## [5.29.0] (Prowler v5.29.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -48,7 +48,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.29.1"
|
||||
prowler_version = "5.29.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -227,10 +227,6 @@ class OCSF(Output):
|
||||
json_output = finding.json(exclude_none=True, indent=4)
|
||||
self._file_descriptor.write(json_output)
|
||||
self._file_descriptor.write(",")
|
||||
except OSError:
|
||||
# I/O errors (e.g. ENOSPC) are not recoverable per finding:
|
||||
# fail fast instead of logging once per finding.
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -243,10 +239,6 @@ class OCSF(Output):
|
||||
self._file_descriptor.truncate()
|
||||
self._file_descriptor.write("]")
|
||||
self._file_descriptor.close()
|
||||
except OSError:
|
||||
# Propagate unrecoverable I/O errors (e.g. ENOSPC) so the caller can
|
||||
# fail fast instead of producing a corrupt output file.
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
|
||||
@@ -2582,6 +2582,7 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -2591,6 +2592,9 @@
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
@@ -2604,6 +2608,7 @@
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
@@ -9220,6 +9225,7 @@
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2"
|
||||
@@ -9986,6 +9992,8 @@
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-south-2",
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
version = "5.29.1"
|
||||
version = "5.29.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -2,10 +2,8 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from freezegun import freeze_time
|
||||
from mock import patch
|
||||
@@ -302,36 +300,6 @@ class TestOCSF:
|
||||
def test_batch_write_data_to_file_without_findings(self):
|
||||
assert not OCSF([])._file_descriptor
|
||||
|
||||
def test_batch_write_data_to_file_propagates_oserror(self):
|
||||
"""An I/O error (e.g. ENOSPC) while writing a finding must propagate
|
||||
instead of being swallowed, so the caller can fail fast."""
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
status="FAIL",
|
||||
severity="low",
|
||||
muted=False,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
timestamp=datetime.now(),
|
||||
resource_details="resource_details",
|
||||
resource_name="resource_name",
|
||||
resource_uid="resource-id",
|
||||
status_extended="status extended",
|
||||
)
|
||||
]
|
||||
|
||||
output = OCSF(findings)
|
||||
mock_file = MagicMock()
|
||||
mock_file.closed = False
|
||||
# Non-zero so the "[" prelude is skipped and the failure happens on the
|
||||
# per-finding write, the exact path that hit ENOSPC in production.
|
||||
mock_file.tell.return_value = 1
|
||||
mock_file.write.side_effect = OSError(28, "No space left on device")
|
||||
output._file_descriptor = mock_file
|
||||
|
||||
with pytest.raises(OSError) as excinfo:
|
||||
output.batch_write_data_to_file()
|
||||
assert excinfo.value.errno == 28
|
||||
|
||||
def test_finding_output_cloud_pass_low_muted(self):
|
||||
finding_output = generate_finding_output(
|
||||
status="PASS",
|
||||
|
||||
@@ -34,13 +34,4 @@ describe("providers page", () => {
|
||||
expect(source).toContain("size: 160");
|
||||
expect(source).toContain("size: 140");
|
||||
});
|
||||
|
||||
it("keeps the CLI import banner gated by the Cloud environment", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pagePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(pagePath, "utf8");
|
||||
|
||||
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
|
||||
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Suspense } from "react";
|
||||
|
||||
import { ProvidersAccountsView } from "@/components/providers";
|
||||
import { SkeletonTableProviders } from "@/components/providers/table";
|
||||
import { CliImportBanner } from "@/components/scans";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
@@ -20,7 +19,6 @@ export default async function Providers({
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const activeTab = getProviderTab(resolvedSearchParams.tab);
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
// Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend
|
||||
const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {};
|
||||
@@ -28,7 +26,6 @@ export default async function Providers({
|
||||
|
||||
return (
|
||||
<ContentLayout title="Providers" icon="lucide:cloud-cog">
|
||||
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
|
||||
<FilterTransitionWrapper>
|
||||
<ProviderPageTabs
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
|
||||
import { CliImportBanner } from "./cli-import-banner";
|
||||
|
||||
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("CliImportBanner", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the banner when not dismissed", () => {
|
||||
render(<CliImportBanner />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Import findings from Prowler CLI/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a link to the documentation", () => {
|
||||
render(<CliImportBanner />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Learn more" });
|
||||
|
||||
expect(link).toHaveAttribute("href", DOCS_URLS.FINDINGS_INGESTION);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("does not render when previously dismissed", () => {
|
||||
localStorageMock.setItem(STORAGE_KEY, "true");
|
||||
|
||||
const { container } = render(<CliImportBanner />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("dismisses the banner and persists to localStorage on close", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CliImportBanner />);
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/Import findings from Prowler CLI/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
|
||||
});
|
||||
|
||||
it("renders with role='alert'", () => {
|
||||
render(<CliImportBanner />);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Upload } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Alert, AlertTitle } from "@/components/shadcn";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
|
||||
|
||||
export const CliImportBanner = ({ className }: { className?: string }) => {
|
||||
const [isVisible, setIsVisible] = useState<boolean | null>(null);
|
||||
|
||||
useMountEffect(() => {
|
||||
const isDismissed = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
setIsVisible(!isDismissed);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (isVisible === null || !isVisible) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="info"
|
||||
onClose={handleClose}
|
||||
className={cn("animate-fade-in", className)}
|
||||
>
|
||||
<Upload />
|
||||
<AlertTitle>
|
||||
Import findings from Prowler CLI —{" "}
|
||||
<Link
|
||||
href={DOCS_URLS.FINDINGS_INGESTION}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-normal underline underline-offset-2"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./auto-refresh";
|
||||
export * from "./cli-import-banner";
|
||||
|
||||
@@ -16,32 +16,6 @@ const { scansFilterBarSpy } = vi.hoisted(() => ({
|
||||
scansFilterBarSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/scans",
|
||||
useRouter: () => ({
|
||||
@@ -146,7 +120,6 @@ describe("ScansPageShell", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
searchParamsValue.current = "";
|
||||
useScansStore.getState().closeLaunchScanModal();
|
||||
});
|
||||
@@ -218,36 +191,6 @@ describe("ScansPageShell", () => {
|
||||
expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the CLI import banner in Cloud", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/import findings from prowler cli/i,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the CLI import banner outside Cloud", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps launch scan with filters and mutelist with tabs", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useScansStore } from "@/store";
|
||||
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { CliImportBanner } from "./cli-import-banner";
|
||||
import { LaunchScanModal } from "./launch-scan-modal";
|
||||
import { ScansFilterBar } from "./scans-filter-bar";
|
||||
import { useScansFilters } from "./use-scans-filters";
|
||||
@@ -54,7 +53,6 @@ export function ScansPageShell({
|
||||
const hasConnectedProviders = providers.some(
|
||||
(provider) => provider.attributes.connection.connected === true,
|
||||
);
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
|
||||
const launchOpen = isLaunchScanModalOpen || urlLaunchOpen;
|
||||
|
||||
@@ -106,8 +104,6 @@ export function ScansPageShell({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isCloudEnvironment && <CliImportBanner />}
|
||||
|
||||
<Tabs
|
||||
value={filters.activeTab}
|
||||
onValueChange={filters.setTab}
|
||||
|
||||
@@ -81,17 +81,6 @@ const makeCompletedScan = (): ScanProps => ({
|
||||
},
|
||||
});
|
||||
|
||||
const makeScheduledScan = (): ScanProps => ({
|
||||
...makeCompletedScan(),
|
||||
attributes: {
|
||||
...makeCompletedScan().attributes,
|
||||
trigger: "scheduled",
|
||||
state: "scheduled",
|
||||
scheduled_at: "2026-01-01T10:00:00Z",
|
||||
next_scan_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const renderCell = (
|
||||
columnId: string,
|
||||
scan: ScanProps,
|
||||
@@ -147,7 +136,6 @@ describe("getScanJobsColumns", () => {
|
||||
expect(getColumnIds(SCAN_JOBS_TAB.SCHEDULED)).toEqual([
|
||||
"account",
|
||||
"scanInfo",
|
||||
"scanSchedule",
|
||||
"actions",
|
||||
]);
|
||||
});
|
||||
@@ -180,12 +168,4 @@ describe("getScanJobsColumns", () => {
|
||||
expect(screen.getByText("Type")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Schedule")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the scheduled column without repeating the scheduled label in each row", () => {
|
||||
renderHeader(SCAN_JOBS_TAB.SCHEDULED, "scanSchedule");
|
||||
renderCell("scanSchedule", makeScheduledScan(), SCAN_JOBS_TAB.SCHEDULED);
|
||||
|
||||
expect(screen.getByText("Schedule")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Scheduled")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,18 +48,6 @@ const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
|
||||
cell: ({ row }) => <ScheduleCell scan={row.original} />,
|
||||
});
|
||||
|
||||
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
|
||||
id: "scanSchedule",
|
||||
accessorFn: (row) => row.attributes.scheduled_at,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Schedule" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
|
||||
),
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const resourcesColumn: ColumnDef<ScanProps> = {
|
||||
id: "resources",
|
||||
header: ({ column }) => (
|
||||
@@ -151,7 +139,6 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
scheduledScanScheduleColumn,
|
||||
/*
|
||||
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
|
||||
* {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MainLayout from "./main-layout";
|
||||
|
||||
vi.mock("@/hooks/use-sidebar", () => ({
|
||||
useSidebar: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-store", () => ({
|
||||
useStore: () => ({
|
||||
getOpenState: () => true,
|
||||
settings: { disabled: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../sidebar/sidebar", () => ({
|
||||
Sidebar: () => <aside data-testid="sidebar" />,
|
||||
}));
|
||||
|
||||
describe("MainLayout", () => {
|
||||
it("renders subdued background glows for side-nav contrast", () => {
|
||||
render(
|
||||
<MainLayout>
|
||||
<div>Page content</div>
|
||||
</MainLayout>,
|
||||
);
|
||||
|
||||
const topGlow =
|
||||
screen.getByTestId("sidebar").previousElementSibling
|
||||
?.previousElementSibling;
|
||||
const bottomGlow = screen.getByTestId("sidebar").previousElementSibling;
|
||||
|
||||
expect(topGlow).toHaveClass("h-[120%]", "w-[160%]", "opacity-[7%]");
|
||||
expect(bottomGlow).toHaveClass("h-[50%]", "w-[50%]", "opacity-[7%]");
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export default function MainLayout({
|
||||
<div className="relative flex h-dvh items-center justify-center overflow-hidden">
|
||||
{/* Top-left gradient halo */}
|
||||
<div
|
||||
className="pointer-events-none fixed top-0 left-0 z-0 h-[120%] w-[160%] opacity-[7%] blur-3xl"
|
||||
className="pointer-events-none fixed top-0 left-0 z-0 size-[600px] opacity-20 blur-3xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
|
||||
transform: "translate(-50%, -50%)",
|
||||
@@ -26,7 +26,7 @@ export default function MainLayout({
|
||||
|
||||
{/* Bottom-right gradient halo */}
|
||||
<div
|
||||
className="pointer-events-none fixed right-0 bottom-0 z-0 h-[50%] w-[50%] opacity-[7%] blur-3xl"
|
||||
className="pointer-events-none fixed right-0 bottom-0 z-0 size-[600px] opacity-20 blur-3xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
|
||||
transform: "translate(50%, 50%)",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { IntegrationType } from "../types/integrations";
|
||||
export const DOCS_URLS = {
|
||||
FINDINGS_ANALYSIS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
|
||||
FINDINGS_INGESTION:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
|
||||
AWS_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||
|
||||
Reference in New Issue
Block a user