mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-10 13:32:44 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21e7f29153 | |||
| de51eed96c | |||
| 835dbddc6a | |||
| 103761f146 | |||
| 0e6268e159 | |||
| f48984e6a1 | |||
| 6df80a4890 | |||
| a769e37615 | |||
| 9d2a8d9108 | |||
| e05519ff9f | |||
| 67b26072f8 | |||
| 2222082631 | |||
| 8b0cb4b981 | |||
| 9422eff8ab | |||
| e3c4368d32 | |||
| 2a641b39c8 | |||
| 02b713572b | |||
| 74251350bc |
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.2
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17@sha256:0027bef26712baaee437a4ea48fdf3d2d2e2bc5f0d81615374408ca320f3c7e3
|
||||
image: postgres:17@sha256:2cd82735a36356842d5eb1ef80db3ae8f1154172f0f653db48fde079b2a0b7f7
|
||||
env:
|
||||
POSTGRES_HOST: ${{ env.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ env.POSTGRES_PORT }}
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
|
||||
image: valkey/valkey:7-alpine3.19
|
||||
env:
|
||||
VALKEY_HOST: ${{ env.VALKEY_HOST }}
|
||||
VALKEY_PORT: ${{ env.VALKEY_PORT }}
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12.13'
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
+11
-1
@@ -2,11 +2,21 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.30.0] (Prowler UNRELEASED)
|
||||
## [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
|
||||
|
||||
- Scan finding ingestion: bulk-resolve `Resource`/`ResourceTag` rows, replace per-mapping `SELECT FOR UPDATE` with deferred `ResourceTagMapping.bulk_create(ignore_conflicts=True)`, wrap each micro-batch in a single `rls_transaction`, and raise `SCAN_DB_BATCH_SIZE` to 1000 [(#11249)](https://github.com/prowler-cloud/prowler/pull/11249)
|
||||
- Faster `GET /api/v1/finding-groups/latest` aggregation on tenants where one recent scan holds most findings [(#11380)](https://github.com/prowler-cloud/prowler/pull/11380)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.29",
|
||||
"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.0"
|
||||
version = "1.30.2"
|
||||
|
||||
[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.0
|
||||
version: 1.30.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ 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
|
||||
@@ -64,6 +66,7 @@ from api.models import (
|
||||
ProviderSecret,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceTag,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
@@ -3856,16 +3859,20 @@ class TestScanViewSet:
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
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}
|
||||
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}
|
||||
|
||||
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}),
|
||||
),
|
||||
with 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)
|
||||
@@ -4186,6 +4193,88 @@ 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
|
||||
):
|
||||
@@ -4294,18 +4383,24 @@ 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, mock_task_get, authenticated_client, scans_fixture
|
||||
self, mock_task_serializer, authenticated_client, scans_fixture
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
task = Task.objects.create(tenant_id=scan.tenant_id)
|
||||
mock_task_get.return_value = task
|
||||
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,
|
||||
)
|
||||
mock_task_serializer.return_value.data = {
|
||||
"id": str(task.id),
|
||||
"state": StateChoices.COMPLETED,
|
||||
@@ -4326,6 +4421,7 @@ class TestScanViewSet:
|
||||
scan.save()
|
||||
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
@@ -4346,6 +4442,51 @@ 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(
|
||||
@@ -6916,6 +7057,80 @@ 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,12 +2059,17 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
|
||||
task = scan_instance.task
|
||||
else:
|
||||
try:
|
||||
task = Task.objects.get(
|
||||
# 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(
|
||||
task_runner_task__task_name="scan-report",
|
||||
task_runner_task__task_kwargs__contains=str(scan_instance.id),
|
||||
)
|
||||
except Task.DoesNotExist:
|
||||
.order_by("-inserted_at")
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
self.response_serializer_class = TaskSerializer
|
||||
@@ -2139,27 +2144,32 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
contents = resp.get("Contents", [])
|
||||
keys = []
|
||||
matches = []
|
||||
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):
|
||||
keys.append(key)
|
||||
matches.append(obj)
|
||||
elif key_basename == suffix:
|
||||
keys.append(key)
|
||||
matches.append(obj)
|
||||
elif key.endswith(suffix):
|
||||
# Backward compatibility if suffix already includes directories
|
||||
keys.append(key)
|
||||
if not keys:
|
||||
matches.append(obj)
|
||||
if not matches:
|
||||
return Response(
|
||||
{
|
||||
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
# path_pattern here is prefix, but in compliance we build correct suffix check before
|
||||
key = keys[0]
|
||||
# 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"
|
||||
]
|
||||
else:
|
||||
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
|
||||
key = path_pattern
|
||||
@@ -2209,7 +2219,9 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
filepath = files[0]
|
||||
# Return the most recently modified match (latest report) when the
|
||||
# pattern resolves to several files.
|
||||
filepath = max(files, key=os.path.getmtime)
|
||||
with open(filepath, "rb") as f:
|
||||
content = f.read()
|
||||
filename = os.path.basename(filepath)
|
||||
@@ -3749,6 +3761,16 @@ 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,8 +467,31 @@ 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=RLSTask,
|
||||
base=ScanReportRLSTask,
|
||||
name="scan-report",
|
||||
queue="scan-reports",
|
||||
)
|
||||
@@ -518,6 +541,9 @@ 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):
|
||||
"""
|
||||
@@ -666,7 +692,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(Path(compressed).parent, ignore_errors=True)
|
||||
rmtree(scan_tmp_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting output files: {e}")
|
||||
final_location, did_upload = upload_uri, True
|
||||
|
||||
@@ -15,8 +15,10 @@ 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,
|
||||
@@ -771,6 +773,38 @@ 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
+54
-4
@@ -4410,8 +4410,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.27.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
|
||||
version = "5.29.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29#a769e3761532d9332cb64078ef09ebf7ffb15292" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4484,9 +4484,13 @@ 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" },
|
||||
@@ -4494,7 +4498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.30.0"
|
||||
version = "1.30.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -4590,7 +4594,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=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -5526,6 +5530,52 @@ 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"
|
||||
|
||||
@@ -40,12 +40,6 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
|
||||
pip install prowler
|
||||
prowler -v
|
||||
```
|
||||
|
||||
To upgrade Prowler to the latest version:
|
||||
|
||||
``` bash
|
||||
pip install --upgrade prowler
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Docker">
|
||||
_Requirements_:
|
||||
@@ -170,6 +164,68 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Updating Prowler CLI
|
||||
|
||||
Upgrade Prowler CLI to the latest release using the same method chosen for installation:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="pipx">
|
||||
```bash
|
||||
pipx upgrade prowler
|
||||
prowler -v
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="pip">
|
||||
```bash
|
||||
pip install --upgrade prowler
|
||||
prowler -v
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Docker">
|
||||
Pull the desired image tag to fetch the latest version:
|
||||
|
||||
```bash
|
||||
docker pull toniblyx/prowler:latest
|
||||
```
|
||||
|
||||
<Note>
|
||||
Replace `latest` with a specific release tag (for example, `stable` or `<x.y.z>`) to pin a version. Refer to the [Container Versions](#container-versions) section for the full list of available tags.
|
||||
</Note>
|
||||
</Tab>
|
||||
<Tab title="GitHub">
|
||||
Pull the latest changes and sync the environment:
|
||||
|
||||
```bash
|
||||
cd prowler
|
||||
git pull
|
||||
uv sync
|
||||
uv run python prowler-cli.py -v
|
||||
```
|
||||
|
||||
<Note>
|
||||
To upgrade to a specific release, check out the corresponding tag before syncing: `git checkout <x.y.z>`.
|
||||
</Note>
|
||||
</Tab>
|
||||
<Tab title="Brew">
|
||||
```bash
|
||||
brew upgrade prowler
|
||||
prowler -v
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="CloudShell">
|
||||
Both AWS CloudShell and Azure CloudShell install Prowler with `pipx`, so the upgrade command is the same:
|
||||
|
||||
```bash
|
||||
pipx upgrade prowler
|
||||
prowler -v
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Note>
|
||||
To install a specific version instead of the latest release, pin it explicitly. For example, with `pipx`: `pipx install prowler==<x.y.z>`, or with `pip`: `pip install prowler==<x.y.z>`. The available releases are listed in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases).
|
||||
</Note>
|
||||
|
||||
## Container Versions
|
||||
|
||||
The available versions of Prowler CLI are the following:
|
||||
|
||||
@@ -141,6 +141,45 @@ Choose one of the following installation methods:
|
||||
|
||||
---
|
||||
|
||||
## Updating Prowler MCP Server
|
||||
|
||||
When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker">
|
||||
Pull the latest image and restart the container:
|
||||
|
||||
```bash
|
||||
docker pull prowlercloud/prowler-mcp
|
||||
```
|
||||
|
||||
<Note>
|
||||
Recreate any running container after pulling the new image so the updated version takes effect.
|
||||
</Note>
|
||||
</Tab>
|
||||
<Tab title="From Source">
|
||||
Pull the latest changes and sync the dependencies:
|
||||
|
||||
```bash
|
||||
cd prowler/mcp_server
|
||||
git pull
|
||||
uv sync
|
||||
uv run prowler-mcp --help
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Build Docker Image">
|
||||
Pull the latest source and rebuild the image:
|
||||
|
||||
```bash
|
||||
cd prowler/mcp_server
|
||||
git pull
|
||||
docker build -t prowler-mcp .
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## Command Line Options
|
||||
|
||||
The Prowler MCP Server supports the following command-line arguments:
|
||||
|
||||
@@ -47,7 +47,11 @@ Follow these steps to remove a user of your account:
|
||||
1. Navigate to **Users** from the side menu.
|
||||
2. Click the delete button of your current user.
|
||||
|
||||
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
|
||||
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
|
||||
|
||||
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
|
||||
|
||||
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
|
||||
|
||||
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
|
||||
|
||||
|
||||
+17
-3
@@ -2,26 +2,40 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.29.0] (Prowler UNRELEASED)
|
||||
## [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
|
||||
|
||||
- `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358)
|
||||
- AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353)
|
||||
- `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334)
|
||||
- StackIT provider now authenticates with a service account key, either as a file path (`--stackit-service-account-key-path` / `STACKIT_SERVICE_ACCOUNT_KEY_PATH`) or as inline JSON content (`--stackit-service-account-key` / `STACKIT_SERVICE_ACCOUNT_KEY`, intended for CI/CD with a secret manager); the StackIT SDK refreshes access tokens internally, replacing the short-lived `STACKIT_API_TOKEN` flow [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
|
||||
- StackIT provider with service account key authentication [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
|
||||
- 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379)
|
||||
- 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356)
|
||||
|
||||
### ⚠️ Deprecated
|
||||
|
||||
- `s3_bucket_default_encryption` check for AWS provider since SSE-S3 is automatically applied to all S3 buckets by AWS as of January 5, 2023 and can no longer be disabled [(#11230)](https://github.com/prowler-cloud/prowler/pull/11230)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Broken documentation URLs in Google Workspace check metadata [(#11405)](https://github.com/prowler-cloud/prowler/pull/11405)
|
||||
- ENS RD 311/2022 (AWS) compliance mapping: `vpc_different_regions` was uncorrectly mapped under the `mp.com.4` family (Network segregation). That check is now mapped to a new `op.cont.2.aws.vpc.1` requirement under the Continuity of Service control [(#11372)](https://github.com/prowler-cloud/prowler/pull/11372)
|
||||
- Compliance CSV row count now matches the UI per requirement by sourcing rows from the framework JSON's `requirement.Checks` instead of the stale `finding.compliance` snapshot [(#11370)](https://github.com/prowler-cloud/prowler/pull/11370)
|
||||
- OpenStack provider exception codes moved from the `10000-10999` range, shared with the AlibabaCloud provider, to the free `17000-17999` range to keep error codes unambiguous [(#11382)](https://github.com/prowler-cloud/prowler/pull/11382)
|
||||
- Azure provider authentication against sovereign clouds (`AzureChinaCloud`, `AzureUSGovernment`) [(#10284)](https://github.com/prowler-cloud/prowler/pull/10284)
|
||||
|
||||
---
|
||||
|
||||
## [5.28.1] (Prowler 5.28.1)
|
||||
## [5.28.1] (Prowler v5.28.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.29.0"
|
||||
prowler_version = "5.29.2"
|
||||
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,6 +227,10 @@ 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}"
|
||||
@@ -239,6 +243,10 @@ 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}"
|
||||
|
||||
+4
-6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "s3_bucket_default_encryption",
|
||||
"CheckTitle": "S3 bucket has default server-side encryption (SSE) enabled",
|
||||
"CheckTitle": "[DEPRECATED] S3 bucket has default server-side encryption (SSE) enabled",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
|
||||
@@ -14,13 +14,11 @@
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsS3Bucket",
|
||||
"ResourceGroup": "storage",
|
||||
"Description": "**Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.",
|
||||
"Description": "[DEPRECATED] **Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.",
|
||||
"Risk": "Without default encryption, older objects may remain unencrypted and new uploads won't be forced to use `SSE-KMS`. This reduces confidentiality and governance by limiting key audit logs, rotation, and cross-account controls, and increases exposure if data is copied, replicated, or accessed outside intended paths.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.amazonaws.cn/en_us/AmazonS3/latest/userguide/bucket-encryption.html",
|
||||
"https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/",
|
||||
"https://docs.aws.amazon.com/us_en/AmazonS3/latest/userguide/default-encryption-faq.html"
|
||||
"https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
@@ -39,5 +37,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
"Notes": "This check is being deprecated since AWS automatically applies SSE-S3 to every S3 bucket (both new buckets and previously-unencrypted existing buckets) as of January 5, 2023, and encryption can no longer be disabled. For SSE-KMS validation, use `s3_bucket_kms_encryption` instead."
|
||||
}
|
||||
|
||||
@@ -241,7 +241,10 @@ class AzureProvider(Provider):
|
||||
azure_credentials = None
|
||||
if tenant_id and client_id and client_secret:
|
||||
azure_credentials = self.validate_static_credentials(
|
||||
tenant_id=tenant_id, client_id=client_id, client_secret=client_secret
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
region_config=self._region_config,
|
||||
)
|
||||
|
||||
# Set up the Azure session
|
||||
@@ -410,6 +413,9 @@ class AzureProvider(Provider):
|
||||
authority=config["authority"],
|
||||
base_url=config["base_url"],
|
||||
credential_scopes=config["credential_scopes"],
|
||||
graph_host=config["graph_host"],
|
||||
graph_scope=config["graph_scope"],
|
||||
logs_endpoint=config["logs_endpoint"],
|
||||
)
|
||||
except ArgumentTypeError as validation_error:
|
||||
logger.error(
|
||||
@@ -507,6 +513,7 @@ class AzureProvider(Provider):
|
||||
tenant_id=azure_credentials["tenant_id"],
|
||||
client_id=azure_credentials["client_id"],
|
||||
client_secret=azure_credentials["client_secret"],
|
||||
authority=region_config.authority,
|
||||
)
|
||||
return credentials
|
||||
except ClientAuthenticationError as error:
|
||||
@@ -579,7 +586,10 @@ class AzureProvider(Provider):
|
||||
)
|
||||
else:
|
||||
try:
|
||||
credentials = InteractiveBrowserCredential(tenant_id=tenant_id)
|
||||
credentials = InteractiveBrowserCredential(
|
||||
tenant_id=tenant_id,
|
||||
authority=region_config.authority,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
"Failed to retrieve azure credentials using browser authentication"
|
||||
@@ -662,6 +672,7 @@ class AzureProvider(Provider):
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
region_config=region_config,
|
||||
)
|
||||
|
||||
# Set up the Azure session
|
||||
@@ -675,7 +686,11 @@ class AzureProvider(Provider):
|
||||
region_config,
|
||||
)
|
||||
# Create a SubscriptionClient
|
||||
subscription_client = SubscriptionClient(credentials)
|
||||
subscription_client = SubscriptionClient(
|
||||
credentials,
|
||||
base_url=region_config.base_url,
|
||||
credential_scopes=region_config.credential_scopes,
|
||||
)
|
||||
|
||||
# Get info from the subscriptions
|
||||
available_subscriptions = []
|
||||
@@ -1039,7 +1054,11 @@ class AzureProvider(Provider):
|
||||
}
|
||||
"""
|
||||
credentials = self.session
|
||||
subscription_client = SubscriptionClient(credentials)
|
||||
subscription_client = SubscriptionClient(
|
||||
credentials,
|
||||
base_url=self.region_config.base_url,
|
||||
credential_scopes=self.region_config.credential_scopes,
|
||||
)
|
||||
locations = {}
|
||||
|
||||
for subscription_id, display_name in self._identity.subscriptions.items():
|
||||
@@ -1084,7 +1103,10 @@ class AzureProvider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def validate_static_credentials(
|
||||
tenant_id: str = None, client_id: str = None, client_secret: str = None
|
||||
tenant_id: str = None,
|
||||
client_id: str = None,
|
||||
client_secret: str = None,
|
||||
region_config: AzureRegionConfig = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Validates the static credentials for the Azure provider.
|
||||
@@ -1093,6 +1115,9 @@ class AzureProvider(Provider):
|
||||
tenant_id (str): The Azure Active Directory tenant ID.
|
||||
client_id (str): The Azure client ID.
|
||||
client_secret (str): The Azure client secret.
|
||||
region_config (AzureRegionConfig): The region configuration used to
|
||||
build the per-cloud login endpoint and Graph scope. Defaults to
|
||||
the public-cloud configuration when not provided.
|
||||
|
||||
Raises:
|
||||
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
|
||||
@@ -1129,8 +1154,13 @@ class AzureProvider(Provider):
|
||||
message="The provided Azure Client Secret is not valid.",
|
||||
)
|
||||
|
||||
if region_config is None:
|
||||
region_config = AzureProvider.setup_region_config("AzureCloud")
|
||||
|
||||
try:
|
||||
AzureProvider.verify_client(tenant_id, client_id, client_secret)
|
||||
AzureProvider.verify_client(
|
||||
tenant_id, client_id, client_secret, region_config
|
||||
)
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"client_id": client_id,
|
||||
@@ -1162,7 +1192,9 @@ class AzureProvider(Provider):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_client(tenant_id, client_id, client_secret) -> None:
|
||||
def verify_client(
|
||||
tenant_id, client_id, client_secret, region_config: AzureRegionConfig = None
|
||||
) -> None:
|
||||
"""
|
||||
Verifies the Azure client credentials using the specified tenant ID, client ID, and client secret.
|
||||
|
||||
@@ -1170,6 +1202,9 @@ class AzureProvider(Provider):
|
||||
tenant_id (str): The Azure Active Directory tenant ID.
|
||||
client_id (str): The Azure client ID.
|
||||
client_secret (str): The Azure client secret.
|
||||
region_config (AzureRegionConfig): The region configuration used to
|
||||
build the per-cloud login endpoint and Graph scope. Defaults to
|
||||
the public-cloud configuration when not provided.
|
||||
|
||||
Raises:
|
||||
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
|
||||
@@ -1179,7 +1214,13 @@ class AzureProvider(Provider):
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
if region_config is None:
|
||||
region_config = AzureProvider.setup_region_config("AzureCloud")
|
||||
# `authority` is None for the public cloud and a bare host (e.g.
|
||||
# `login.chinacloudapi.cn`) for sovereign clouds, mirroring the
|
||||
# `AzureAuthorityHosts` constants used by azure-identity.
|
||||
login_endpoint = region_config.authority or "login.microsoftonline.com"
|
||||
url = f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
@@ -1188,7 +1229,7 @@ class AzureProvider(Provider):
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": "https://graph.microsoft.com/.default",
|
||||
"scope": region_config.graph_scope,
|
||||
}
|
||||
response = requests.post(url, headers=headers, data=data).json()
|
||||
if "access_token" not in response.keys() and "error_codes" in response.keys():
|
||||
|
||||
@@ -4,6 +4,18 @@ AZURE_CHINA_CLOUD = "https://management.chinacloudapi.cn"
|
||||
AZURE_US_GOV_CLOUD = "https://management.usgovcloudapi.net"
|
||||
AZURE_GENERIC_CLOUD = "https://management.azure.com"
|
||||
|
||||
AZURE_GENERIC_GRAPH_HOST = "https://graph.microsoft.com"
|
||||
AZURE_CHINA_GRAPH_HOST = "https://microsoftgraph.chinacloudapi.cn"
|
||||
AZURE_US_GOV_GRAPH_HOST = "https://graph.microsoft.us"
|
||||
|
||||
AZURE_GENERIC_GRAPH_SCOPE = f"{AZURE_GENERIC_GRAPH_HOST}/.default"
|
||||
AZURE_CHINA_GRAPH_SCOPE = f"{AZURE_CHINA_GRAPH_HOST}/.default"
|
||||
AZURE_US_GOV_GRAPH_SCOPE = f"{AZURE_US_GOV_GRAPH_HOST}/.default"
|
||||
|
||||
AZURE_GENERIC_LOGS_ENDPOINT = "https://api.loganalytics.io"
|
||||
AZURE_CHINA_LOGS_ENDPOINT = "https://api.loganalytics.azure.cn"
|
||||
AZURE_US_GOV_LOGS_ENDPOINT = "https://api.loganalytics.us"
|
||||
|
||||
|
||||
def get_regions_config(region):
|
||||
allowed_regions = {
|
||||
@@ -11,16 +23,25 @@ def get_regions_config(region):
|
||||
"authority": None,
|
||||
"base_url": AZURE_GENERIC_CLOUD,
|
||||
"credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"],
|
||||
"graph_host": AZURE_GENERIC_GRAPH_HOST,
|
||||
"graph_scope": AZURE_GENERIC_GRAPH_SCOPE,
|
||||
"logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT,
|
||||
},
|
||||
"AzureChinaCloud": {
|
||||
"authority": AzureAuthorityHosts.AZURE_CHINA,
|
||||
"base_url": AZURE_CHINA_CLOUD,
|
||||
"credential_scopes": [AZURE_CHINA_CLOUD + "/.default"],
|
||||
"graph_host": AZURE_CHINA_GRAPH_HOST,
|
||||
"graph_scope": AZURE_CHINA_GRAPH_SCOPE,
|
||||
"logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT,
|
||||
},
|
||||
"AzureUSGovernment": {
|
||||
"authority": AzureAuthorityHosts.AZURE_GOVERNMENT,
|
||||
"base_url": AZURE_US_GOV_CLOUD,
|
||||
"credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"],
|
||||
"graph_host": AZURE_US_GOV_GRAPH_HOST,
|
||||
"graph_scope": AZURE_US_GOV_GRAPH_SCOPE,
|
||||
"logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT,
|
||||
},
|
||||
}
|
||||
return allowed_regions[region]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from kiota_authentication_azure.azure_identity_authentication_provider import (
|
||||
AzureIdentityAuthenticationProvider,
|
||||
)
|
||||
from msgraph.graph_request_adapter import GraphRequestAdapter
|
||||
from msgraph_core import GraphClientFactory
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.azure.azure_provider import AzureProvider
|
||||
|
||||
@@ -47,10 +53,32 @@ class AzureService:
|
||||
clients = {}
|
||||
try:
|
||||
if "GraphServiceClient" in str(service):
|
||||
clients.update({identity.tenant_domain: service(credentials=session)})
|
||||
# GraphServiceClient(credentials, scopes=...) only customises the
|
||||
# OAuth scope; the underlying httpx client's base URL stays at
|
||||
# graph.microsoft.com. For sovereign clouds we must also point
|
||||
# the HTTP transport at the per-cloud host, which is done by
|
||||
# building a custom GraphRequestAdapter with a NationalClouds
|
||||
# base URL.
|
||||
auth_provider = AzureIdentityAuthenticationProvider(
|
||||
session, scopes=[region_config.graph_scope]
|
||||
)
|
||||
http_client = GraphClientFactory.create_with_default_middleware(
|
||||
host=region_config.graph_host
|
||||
)
|
||||
request_adapter = GraphRequestAdapter(auth_provider, client=http_client)
|
||||
clients.update(
|
||||
{identity.tenant_domain: service(request_adapter=request_adapter)}
|
||||
)
|
||||
elif "LogsQueryClient" in str(service):
|
||||
for subscription_id, display_name in identity.subscriptions.items():
|
||||
clients.update({subscription_id: service(credential=session)})
|
||||
clients.update(
|
||||
{
|
||||
subscription_id: service(
|
||||
credential=session,
|
||||
endpoint=region_config.logs_endpoint,
|
||||
)
|
||||
}
|
||||
)
|
||||
else:
|
||||
for subscription_id, display_name in identity.subscriptions.items():
|
||||
clients.update(
|
||||
|
||||
@@ -20,6 +20,9 @@ class AzureRegionConfig(BaseModel):
|
||||
authority: Optional[str] = None
|
||||
base_url: str = ""
|
||||
credential_scopes: list = []
|
||||
graph_host: str = "https://graph.microsoft.com"
|
||||
graph_scope: str = "https://graph.microsoft.com/.default"
|
||||
logs_endpoint: str = "https://api.loganalytics.io"
|
||||
|
||||
|
||||
class AzureSubscription(BaseModel):
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "When external Google Groups access is enabled, users can access and participate in groups created **outside the organization**, potentially exposing them to **phishing, social engineering, or data leakage** through unmanaged external group communications.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/181865",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/users/advanced/turn-on-or-off-additional-google-services",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-3
@@ -13,9 +13,8 @@
|
||||
"Risk": "Without external invitation warnings, users may unintentionally include **external guests** in internal meetings, exposing **confidential meeting details**, agendas, and internal attendee lists to unauthorized parties. This is a common vector for inadvertent data leakage through everyday calendar actions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/6329284",
|
||||
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/calendar/allow-external-invitations-in-google-calendar-events",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-2
@@ -13,9 +13,8 @@
|
||||
"Risk": "Overly permissive external sharing of primary calendars exposes **sensitive meeting metadata** — titles, attendees, locations, and descriptions — to users outside the organization. This increases the risk of **information disclosure**, **social engineering**, and **targeted phishing** based on insights into organizational activities.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60765",
|
||||
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-2
@@ -13,9 +13,8 @@
|
||||
"Risk": "Overly permissive external sharing of secondary calendars exposes **project-specific or team-specific event details** to users outside the organization. Because secondary calendars often hold more targeted activities (e.g., product launches, internal reviews), unrestricted external sharing increases the risk of **information disclosure** and **competitive intelligence leakage**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60765",
|
||||
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Unrestricted Chat app installation allows **unvetted third-party applications** to access user data including conversation content and organizational information. An attacker could distribute a malicious Chat app to **exfiltrate confidential data** or establish **persistent access** to internal communications.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/6089179",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Enabled external file sharing allows users to send files containing **confidential information** to external parties through Chat. This creates a **data leakage** channel that bypasses DLP controls, particularly dangerous for organizations handling **regulated data** such as PII, PHI, or financial records.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9540647",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Unrestricted external messaging allows users to communicate freely with **any external party**, increasing the risk of **data exfiltration** through conversation content and **social engineering attacks** from untrusted domains targeting internal users.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9540647",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Unrestricted external spaces allow users to add **anyone from any domain** to persistent group conversations. This increases the risk of **confidential information exposure** in shared spaces and enables **unauthorized external access** to ongoing organizational discussions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9540647",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Exposed webhook URLs allow **unauthorized content injection** into Chat spaces. Attackers can send **fraudulent or misleading messages** that appear to come from trusted services, creating a vector for **social engineering** and **phishing** within internal communications.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/6089179",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Unrestricted internal file sharing in Chat allows files with **sensitive information** to be distributed freely without passing through approved channels. This undermines **data governance** and **audit trail** requirements, making it harder to track data movement within the organization.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9540647",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
|
||||
"https://support.google.com/a/answer/9011373"
|
||||
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
|
||||
"https://support.google.com/a/answer/9011373"
|
||||
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "If Access Checker suggests broader audiences or public visibility, users may **inadvertently widen access** to a file beyond the people they intended to share with. This is a common cause of unintentional internal or external over-sharing.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60781",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "When Drive for desktop is enabled, organizational files are **synchronized to local devices** and remain accessible if the device is lost, stolen, or compromised. Because Drive for desktop bypasses the central offline-access controls, this channel is a frequently overlooked path for sensitive data to leave organization-managed environments.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7491144",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/set-up-drive-for-desktop-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without external sharing warnings, users may unintentionally share **sensitive documents** with external recipients who are not entitled to the data. This is a common vector for inadvertent leakage of intellectual property, personally identifiable information, and confidential business data through routine Drive sharing.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60781",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "If external users can move files from internal shared drives into shared drives owned by another organization, the organization **loses authoritative control** over its own data. This is a frequently overlooked path for unintentional or malicious data exfiltration through shared drive collaboration.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60781",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Allowing users to publish Drive files to the web creates a path for **unbounded data exposure**. Sensitive documents, intellectual property, customer data, or internal communications can be made publicly accessible — and indexed by search engines — with a single click, often unintentionally.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60781",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "When users cannot create shared drives, they store collaborative content in their personal **My Drive** instead. When that user account is deleted, the data is also deleted, leading to **unintentional data loss** of organizationally significant information. Allowing shared drive creation makes data survivable across account lifecycle events.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7212025",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://support.google.com/a/users/answer/7212025",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "When viewers and commenters can download, print, or copy shared drive files, they can **bulk-extract sensitive content** — including intellectual property, personally identifiable information, and confidential business documents — using nothing more than read access. This is one of the most direct paths to data exfiltration through Drive.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7662202",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "If shared drive managers can override organizational defaults, **unauthorized data exposure** can occur when a manager intentionally or accidentally weakens a shared drive's security posture (for example, allowing external members or enabling download for viewers).",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7662202",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "If non-members can be added to files inside a shared drive, the **drive's membership becomes meaningless** as a security control. Sensitive content scoped to a specific team can be silently extended to users who were never granted access to the drive itself, leading to unintended information disclosure.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7662202",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "When external sharing is unrestricted, users can share organizational content with **any external Google account**, including untrusted or unknown parties. Restricting sharing to allowlisted domains drastically reduces the surface area for accidental and malicious data exfiltration through Drive.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60781",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Allowlisted domains are still external. Users may not realize that even an allowlisted recipient is outside the organization, leading to **unintentional disclosure of sensitive content** to legitimate but external collaborators. A warning prompt at share time mitigates that without preventing the sharing itself.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/60781",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection against anomalous attachment types, users may receive **emails with unusual file formats** that are designed to bypass standard security filters. Attackers may use **uncommon file extensions or MIME types** to deliver malware that evades signature-based detection.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "With auto-forwarding enabled, an attacker who gains control of a user account can create **forwarding rules to exfiltrate** all incoming email to an external address. This can persist undetected and provide the attacker with continuous access to sensitive communications even after the account is recovered.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/2491924",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/let-users-automatically-forward-their-own-gmail-emails",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without comprehensive mail storage, messages sent through other Google services (Calendar, Drive, etc.) may not be stored in Gmail and therefore **not subject to Vault retention policies**. This creates gaps in **compliance coverage**, **eDiscovery**, and **audit trails** that could violate regulatory requirements.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/3547347",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-comprehensive-mail-storage",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection against encrypted attachments from untrusted senders, users may receive **password-protected archives containing malware** that bypass standard content scanning. Attackers commonly use encrypted attachments to evade detection and deliver **ransomware, trojans, or other malicious payloads**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without enhanced pre-delivery scanning, some **sophisticated phishing and malware** messages may pass through standard filters and be delivered to users. The additional scanning layer catches threats that the first-pass filters miss, reducing the organization's exposure to **zero-day phishing campaigns** and **targeted attacks**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7380368",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/security/help-prevent-phishing-with-pre-delivery-message-scanning",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without external image scanning, attackers can use **linked images to track email opens**, deliver **exploit payloads via image rendering vulnerabilities**, or use images as part of sophisticated **phishing schemes** that mimic legitimate communications.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "If users can delegate access to their mailbox, an attacker who compromises one account could silently delegate access to maintain persistent email surveillance. This also increases the risk of **insider threats** and **data exfiltration** through shared mailbox access.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7223765",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/let-users-delegate-access-to-a-gmail-account",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "With per-user outbound gateways enabled, users can route outbound email through **external SMTP servers**, bypassing organizational **email security controls**, **DLP policies**, and **audit logging**. This creates an unmonitored channel for data exfiltration and policy circumvention.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/176652",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/allow-per-user-outbound-gateways",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "With POP and IMAP enabled, users can access email through **legacy clients** that rely on simple password authentication, bypassing **multifactor authentication** and other modern security controls. This significantly increases the risk of **credential-based account compromise**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/105694",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/sync/turn-pop-and-imap-on-or-off-for-users",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection against script-bearing attachments from untrusted senders, users may receive **files containing malicious scripts** that can execute harmful code when opened. Attackers commonly use script attachments to deliver **malware, backdoors, or credential stealers**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without shortened URL scanning, attackers can use **URL shortening services** to hide malicious destinations in phishing emails. Users cannot visually verify where the link leads, increasing the success rate of **phishing and credential harvesting** attacks.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without protection against unauthenticated emails, users may receive **spoofed or forged messages** that fail SPF and DKIM checks but are still delivered normally. This enables **phishing**, **spam**, and **impersonation attacks** that exploit the lack of sender verification.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/9157861",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Without untrusted link warnings, users may click on **phishing links** or links to **malware distribution sites** without any warning. This significantly increases the success rate of **social engineering attacks** targeting the organization.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/7676854",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Allowing any user to create groups with external members or incoming email from outside increases the risk of **unauthorized data sharing**, **spam delivery**, and **shadow IT** groups that bypass organizational controls.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/10308022",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Allowing external access to groups exposes **group names, descriptions, and membership** to anyone outside the organization, increasing the risk of **information disclosure** and enabling external parties to identify targets for **social engineering attacks**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/10308022",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Allowing all organization users or anyone to view group conversations can lead to **information disclosure** of sensitive discussions, internal decisions, and confidential data shared within groups.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/10308022",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "Allowing unrestricted Marketplace app installation exposes the organization to **unvetted third-party applications** that may request broad OAuth scopes, potentially gaining access to **sensitive organizational data** including emails, documents, and calendar events without proper security review.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/6089179",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/security/protect-users-with-the-advanced-protection-program",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/security/about-dlp",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/apps/control-access-to-less-secure-apps",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/security/protect-google-workspace-accounts-with-security-challenges",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/users/enforce-and-monitor-password-requirements-for-users",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/security/set-session-length-for-google-services",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/users/allow-super-administrators-to-recover-their-password",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/users/set-up-password-recovery-for-users",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"Risk": "When Google Sites is enabled, users can create websites that may **inadvertently expose internal information** to external parties. These sites can be difficult to track and manage, creating potential **data leakage vectors** outside the organization's standard content management controls.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.google.com/a/answer/182442",
|
||||
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
"https://knowledge.workspace.google.com/admin/users/advanced/turn-a-service-on-or-off-for-google-workspace-users",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
|
||||
+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.0"
|
||||
version = "5.29.2"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -2,8 +2,10 @@ 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
|
||||
@@ -300,6 +302,36 @@ 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",
|
||||
|
||||
@@ -725,6 +725,300 @@ class TestAzureProviderSetupIdentitySubscriptions:
|
||||
}
|
||||
|
||||
|
||||
class TestAzureProviderSovereignCloudSupport:
|
||||
"""Sovereign-cloud authentication coverage across AzureCloud,
|
||||
AzureChinaCloud and AzureUSGovernment for every authentication code path
|
||||
Prowler exposes. Pinned to issue #8425."""
|
||||
|
||||
REGION_CASES = [
|
||||
(
|
||||
"AzureCloud",
|
||||
None,
|
||||
"https://management.azure.com",
|
||||
["https://management.azure.com/.default"],
|
||||
"https://graph.microsoft.com/.default",
|
||||
"https://api.loganalytics.io",
|
||||
"login.microsoftonline.com",
|
||||
),
|
||||
(
|
||||
"AzureChinaCloud",
|
||||
"login.chinacloudapi.cn",
|
||||
"https://management.chinacloudapi.cn",
|
||||
["https://management.chinacloudapi.cn/.default"],
|
||||
"https://microsoftgraph.chinacloudapi.cn/.default",
|
||||
"https://api.loganalytics.azure.cn",
|
||||
"login.chinacloudapi.cn",
|
||||
),
|
||||
(
|
||||
"AzureUSGovernment",
|
||||
"login.microsoftonline.us",
|
||||
"https://management.usgovcloudapi.net",
|
||||
["https://management.usgovcloudapi.net/.default"],
|
||||
"https://graph.microsoft.us/.default",
|
||||
"https://api.loganalytics.us",
|
||||
"login.microsoftonline.us",
|
||||
),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,authority,base_url,credential_scopes,graph_scope,logs_endpoint,_login_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_setup_region_config_per_cloud(
|
||||
self,
|
||||
region,
|
||||
authority,
|
||||
base_url,
|
||||
credential_scopes,
|
||||
graph_scope,
|
||||
logs_endpoint,
|
||||
_login_endpoint,
|
||||
):
|
||||
config = AzureProvider.setup_region_config(region)
|
||||
|
||||
# graph_host mirrors graph_scope without the `/.default` suffix; we
|
||||
# derive it here to avoid threading a separate parameter through every
|
||||
# parametrized test in this class.
|
||||
expected_graph_host = graph_scope.removesuffix("/.default")
|
||||
assert config == AzureRegionConfig(
|
||||
name=region,
|
||||
authority=authority,
|
||||
base_url=base_url,
|
||||
credential_scopes=credential_scopes,
|
||||
graph_host=expected_graph_host,
|
||||
graph_scope=graph_scope,
|
||||
logs_endpoint=logs_endpoint,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_setup_session_static_credentials_passes_authority(
|
||||
self,
|
||||
region,
|
||||
authority,
|
||||
_base_url,
|
||||
_credential_scopes,
|
||||
_graph_scope,
|
||||
_logs_endpoint,
|
||||
_login_endpoint,
|
||||
):
|
||||
with patch(
|
||||
"prowler.providers.azure.azure_provider.ClientSecretCredential"
|
||||
) as mock_client_secret_credential:
|
||||
azure_credentials = {
|
||||
"tenant_id": str(uuid4()),
|
||||
"client_id": str(uuid4()),
|
||||
"client_secret": "fake-secret-value",
|
||||
}
|
||||
region_config = AzureProvider.setup_region_config(region)
|
||||
|
||||
AzureProvider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
tenant_id=azure_credentials["tenant_id"],
|
||||
azure_credentials=azure_credentials,
|
||||
region_config=region_config,
|
||||
)
|
||||
|
||||
mock_client_secret_credential.assert_called_once_with(
|
||||
tenant_id=azure_credentials["tenant_id"],
|
||||
client_id=azure_credentials["client_id"],
|
||||
client_secret=azure_credentials["client_secret"],
|
||||
authority=authority,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_setup_session_browser_auth_passes_authority(
|
||||
self,
|
||||
region,
|
||||
authority,
|
||||
_base_url,
|
||||
_credential_scopes,
|
||||
_graph_scope,
|
||||
_logs_endpoint,
|
||||
_login_endpoint,
|
||||
):
|
||||
with patch(
|
||||
"prowler.providers.azure.azure_provider.InteractiveBrowserCredential"
|
||||
) as mock_interactive_browser_credential:
|
||||
tenant_id = str(uuid4())
|
||||
region_config = AzureProvider.setup_region_config(region)
|
||||
|
||||
AzureProvider.setup_session(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=False,
|
||||
browser_auth=True,
|
||||
managed_identity_auth=False,
|
||||
tenant_id=tenant_id,
|
||||
azure_credentials=None,
|
||||
region_config=region_config,
|
||||
)
|
||||
|
||||
mock_interactive_browser_credential.assert_called_once_with(
|
||||
tenant_id=tenant_id,
|
||||
authority=authority,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_setup_session_default_credential_passes_authority(
|
||||
self,
|
||||
region,
|
||||
authority,
|
||||
_base_url,
|
||||
_credential_scopes,
|
||||
_graph_scope,
|
||||
_logs_endpoint,
|
||||
_login_endpoint,
|
||||
):
|
||||
with patch(
|
||||
"prowler.providers.azure.azure_provider.DefaultAzureCredential"
|
||||
) as mock_default_credential:
|
||||
region_config = AzureProvider.setup_region_config(region)
|
||||
|
||||
AzureProvider.setup_session(
|
||||
az_cli_auth=True,
|
||||
sp_env_auth=False,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
tenant_id=None,
|
||||
azure_credentials=None,
|
||||
region_config=region_config,
|
||||
)
|
||||
|
||||
_, called_kwargs = mock_default_credential.call_args
|
||||
assert called_kwargs["authority"] == authority
|
||||
assert called_kwargs["exclude_cli_credential"] is False
|
||||
assert called_kwargs["exclude_environment_credential"] is True
|
||||
assert called_kwargs["exclude_managed_identity_credential"] is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,_authority,_base_url,_credential_scopes,graph_scope,_logs_endpoint,login_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_verify_client_uses_per_cloud_endpoints(
|
||||
self,
|
||||
region,
|
||||
_authority,
|
||||
_base_url,
|
||||
_credential_scopes,
|
||||
graph_scope,
|
||||
_logs_endpoint,
|
||||
login_endpoint,
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
client_id = str(uuid4())
|
||||
client_secret = "fake-secret"
|
||||
region_config = AzureProvider.setup_region_config(region)
|
||||
|
||||
with patch("prowler.providers.azure.azure_provider.requests.post") as mock_post:
|
||||
mock_post.return_value = MagicMock()
|
||||
mock_post.return_value.json.return_value = {"access_token": "fake-token"}
|
||||
|
||||
AzureProvider.verify_client(
|
||||
tenant_id, client_id, client_secret, region_config
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
assert args[0] == (
|
||||
f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
|
||||
)
|
||||
assert kwargs["data"]["scope"] == graph_scope
|
||||
assert kwargs["data"]["client_id"] == client_id
|
||||
assert kwargs["data"]["client_secret"] == client_secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_test_connection_passes_base_url_to_subscription_client(
|
||||
self,
|
||||
region,
|
||||
_authority,
|
||||
base_url,
|
||||
credential_scopes,
|
||||
_graph_scope,
|
||||
_logs_endpoint,
|
||||
_login_endpoint,
|
||||
):
|
||||
subscription_client_instance = MagicMock()
|
||||
subscription_client_instance.subscriptions = MagicMock()
|
||||
subscription_client_instance.subscriptions.list = MagicMock(return_value=[])
|
||||
subscription_client_class = MagicMock(return_value=subscription_client_instance)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.azure.azure_provider.AzureProvider.setup_session"
|
||||
) as mock_setup_session,
|
||||
patch(
|
||||
"prowler.providers.azure.azure_provider.SubscriptionClient",
|
||||
subscription_client_class,
|
||||
),
|
||||
):
|
||||
mock_setup_session.return_value = MagicMock()
|
||||
|
||||
AzureProvider.test_connection(
|
||||
az_cli_auth=True,
|
||||
region=region,
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
subscription_client_class.assert_called_once()
|
||||
_, kwargs = subscription_client_class.call_args
|
||||
assert kwargs["base_url"] == base_url
|
||||
assert kwargs["credential_scopes"] == credential_scopes
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_get_locations_passes_base_url_to_subscription_client(
|
||||
self,
|
||||
region,
|
||||
_authority,
|
||||
base_url,
|
||||
credential_scopes,
|
||||
_graph_scope,
|
||||
_logs_endpoint,
|
||||
_login_endpoint,
|
||||
):
|
||||
subscription_client_instance = MagicMock()
|
||||
subscription_client_instance.subscriptions = MagicMock()
|
||||
subscription_client_instance.subscriptions.list_locations = MagicMock(
|
||||
return_value=[]
|
||||
)
|
||||
subscription_client_class = MagicMock(return_value=subscription_client_instance)
|
||||
|
||||
with (
|
||||
patch.object(AzureProvider, "__init__", return_value=None),
|
||||
patch(
|
||||
"prowler.providers.azure.azure_provider.SubscriptionClient",
|
||||
subscription_client_class,
|
||||
),
|
||||
):
|
||||
azure_provider = AzureProvider()
|
||||
azure_provider._session = MagicMock()
|
||||
azure_provider._region_config = AzureProvider.setup_region_config(region)
|
||||
azure_provider._identity = AzureIdentityInfo(subscriptions={})
|
||||
|
||||
azure_provider.get_locations()
|
||||
|
||||
subscription_client_class.assert_called_once()
|
||||
_, kwargs = subscription_client_class.call_args
|
||||
assert kwargs["base_url"] == base_url
|
||||
assert kwargs["credential_scopes"] == credential_scopes
|
||||
|
||||
|
||||
class TestAzureProviderSetupIdentityEventLoop:
|
||||
"""Regression for the Celery worker scenario where
|
||||
asyncio.get_event_loop() raised "There is no current event loop in
|
||||
|
||||
@@ -2,8 +2,17 @@ from azure.identity import AzureAuthorityHosts
|
||||
|
||||
from prowler.providers.azure.lib.regions.regions import (
|
||||
AZURE_CHINA_CLOUD,
|
||||
AZURE_CHINA_GRAPH_HOST,
|
||||
AZURE_CHINA_GRAPH_SCOPE,
|
||||
AZURE_CHINA_LOGS_ENDPOINT,
|
||||
AZURE_GENERIC_CLOUD,
|
||||
AZURE_GENERIC_GRAPH_HOST,
|
||||
AZURE_GENERIC_GRAPH_SCOPE,
|
||||
AZURE_GENERIC_LOGS_ENDPOINT,
|
||||
AZURE_US_GOV_CLOUD,
|
||||
AZURE_US_GOV_GRAPH_HOST,
|
||||
AZURE_US_GOV_GRAPH_SCOPE,
|
||||
AZURE_US_GOV_LOGS_ENDPOINT,
|
||||
get_regions_config,
|
||||
)
|
||||
|
||||
@@ -20,16 +29,25 @@ class Test_azure_regions:
|
||||
"authority": None,
|
||||
"base_url": AZURE_GENERIC_CLOUD,
|
||||
"credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"],
|
||||
"graph_host": AZURE_GENERIC_GRAPH_HOST,
|
||||
"graph_scope": AZURE_GENERIC_GRAPH_SCOPE,
|
||||
"logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT,
|
||||
},
|
||||
"AzureChinaCloud": {
|
||||
"authority": AzureAuthorityHosts.AZURE_CHINA,
|
||||
"base_url": AZURE_CHINA_CLOUD,
|
||||
"credential_scopes": [AZURE_CHINA_CLOUD + "/.default"],
|
||||
"graph_host": AZURE_CHINA_GRAPH_HOST,
|
||||
"graph_scope": AZURE_CHINA_GRAPH_SCOPE,
|
||||
"logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT,
|
||||
},
|
||||
"AzureUSGovernment": {
|
||||
"authority": AzureAuthorityHosts.AZURE_GOVERNMENT,
|
||||
"base_url": AZURE_US_GOV_CLOUD,
|
||||
"credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"],
|
||||
"graph_host": AZURE_US_GOV_GRAPH_HOST,
|
||||
"graph_scope": AZURE_US_GOV_GRAPH_SCOPE,
|
||||
"logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.azure.lib.service.service import AzureService
|
||||
from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig
|
||||
|
||||
REGION_CASES = [
|
||||
(
|
||||
"AzureCloud",
|
||||
"https://graph.microsoft.com",
|
||||
"https://graph.microsoft.com/.default",
|
||||
"https://api.loganalytics.io",
|
||||
),
|
||||
(
|
||||
"AzureChinaCloud",
|
||||
"https://microsoftgraph.chinacloudapi.cn",
|
||||
"https://microsoftgraph.chinacloudapi.cn/.default",
|
||||
"https://api.loganalytics.azure.cn",
|
||||
),
|
||||
(
|
||||
"AzureUSGovernment",
|
||||
"https://graph.microsoft.us",
|
||||
"https://graph.microsoft.us/.default",
|
||||
"https://api.loganalytics.us",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _identity_and_session():
|
||||
identity = AzureIdentityInfo(
|
||||
tenant_domain="tenant.onmicrosoft.com",
|
||||
subscriptions={"sub-1": "Subscription 1"},
|
||||
)
|
||||
session = MagicMock()
|
||||
return identity, session
|
||||
|
||||
|
||||
class TestAzureServiceSovereignClouds:
|
||||
"""Cover __set_clients__ kwargs for the Graph and Logs clients across the
|
||||
three sovereign clouds — these are the two service slots in service.py
|
||||
that historically defaulted to public-cloud endpoints."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"_region,graph_host,graph_scope,_logs_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_set_clients_graph_uses_per_cloud_host_scope_and_adapter(
|
||||
self, _region, graph_host, graph_scope, _logs_endpoint
|
||||
):
|
||||
graph_service = MagicMock()
|
||||
graph_service.__str__ = MagicMock(return_value="GraphServiceClient")
|
||||
region_config = AzureRegionConfig(
|
||||
graph_host=graph_host,
|
||||
graph_scope=graph_scope,
|
||||
logs_endpoint=_logs_endpoint,
|
||||
)
|
||||
identity, session = _identity_and_session()
|
||||
|
||||
with (
|
||||
patch.object(AzureService, "__init__", return_value=None),
|
||||
patch(
|
||||
"prowler.providers.azure.lib.service.service.AzureIdentityAuthenticationProvider"
|
||||
) as mock_auth_provider_cls,
|
||||
patch(
|
||||
"prowler.providers.azure.lib.service.service.GraphClientFactory"
|
||||
) as mock_factory,
|
||||
patch(
|
||||
"prowler.providers.azure.lib.service.service.GraphRequestAdapter"
|
||||
) as mock_adapter_cls,
|
||||
):
|
||||
service = AzureService.__new__(AzureService)
|
||||
service.__set_clients__(identity, session, graph_service, region_config)
|
||||
|
||||
mock_auth_provider_cls.assert_called_once_with(session, scopes=[graph_scope])
|
||||
mock_factory.create_with_default_middleware.assert_called_once_with(
|
||||
host=graph_host
|
||||
)
|
||||
mock_adapter_cls.assert_called_once_with(
|
||||
mock_auth_provider_cls.return_value,
|
||||
client=mock_factory.create_with_default_middleware.return_value,
|
||||
)
|
||||
graph_service.assert_called_once_with(
|
||||
request_adapter=mock_adapter_cls.return_value
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"_region,_graph_host,_graph_scope,logs_endpoint",
|
||||
REGION_CASES,
|
||||
)
|
||||
def test_set_clients_logs_passes_per_cloud_endpoint(
|
||||
self, _region, _graph_host, _graph_scope, logs_endpoint
|
||||
):
|
||||
logs_service = MagicMock()
|
||||
logs_service.__str__ = MagicMock(return_value="LogsQueryClient")
|
||||
region_config = AzureRegionConfig(
|
||||
graph_host=_graph_host,
|
||||
graph_scope=_graph_scope,
|
||||
logs_endpoint=logs_endpoint,
|
||||
)
|
||||
identity, session = _identity_and_session()
|
||||
|
||||
with patch.object(AzureService, "__init__", return_value=None):
|
||||
service = AzureService.__new__(AzureService)
|
||||
|
||||
service.__set_clients__(identity, session, logs_service, region_config)
|
||||
|
||||
logs_service.assert_called_once_with(credential=session, endpoint=logs_endpoint)
|
||||
+22
-1
@@ -2,7 +2,28 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.29.0] (Prowler UNRELEASED)
|
||||
## [1.29.2] (Prowler v5.29.2)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
---
|
||||
|
||||
## [1.29.0] (Prowler v5.29.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Restyle `Scan Jobs` view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
|
||||
@@ -140,6 +141,7 @@ export const scanOnDemand = async (formData: FormData) => {
|
||||
const result = await handleApiResponse(response, "/scans");
|
||||
if (result?.data?.id) {
|
||||
addScanOperation("start", result.data.id);
|
||||
revalidatePath("/scans");
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./poll";
|
||||
export * from "./task.adapter";
|
||||
export * from "./tasks";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getScanErrorDetails } from "./task.adapter";
|
||||
|
||||
describe("getScanErrorDetails", () => {
|
||||
it("returns null when response is not a record", () => {
|
||||
expect(getScanErrorDetails(null)).toBeNull();
|
||||
expect(getScanErrorDetails("oops")).toBeNull();
|
||||
expect(getScanErrorDetails(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when data is missing", () => {
|
||||
expect(getScanErrorDetails({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when attributes.result is missing", () => {
|
||||
expect(getScanErrorDetails({ data: { attributes: {} } })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when result has no recognizable fields", () => {
|
||||
expect(
|
||||
getScanErrorDetails({ data: { attributes: { result: {} } } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses an error with only an exc_type", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: { attributes: { result: { exc_type: "BotoCoreError" } } },
|
||||
});
|
||||
|
||||
expect(details).toEqual({
|
||||
type: "BotoCoreError",
|
||||
messages: ["-"],
|
||||
module: undefined,
|
||||
copyValue: "ErrorType: BotoCoreError\nError: -",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins multiple exc_message entries in copyValue", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: {
|
||||
attributes: {
|
||||
result: {
|
||||
exc_type: "ScanError",
|
||||
exc_message: ["Failed to connect", "Retry exhausted"],
|
||||
exc_module: "scan.runner",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(details).toEqual({
|
||||
type: "ScanError",
|
||||
messages: ["Failed to connect", "Retry exhausted"],
|
||||
module: "scan.runner",
|
||||
copyValue:
|
||||
"ErrorType: ScanError\nError: Failed to connect\nRetry exhausted",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters non-string entries out of exc_message", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: {
|
||||
attributes: {
|
||||
result: {
|
||||
exc_type: "ScanError",
|
||||
exc_message: ["valid", 42, null, " ", " trimmed "],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(details?.messages).toEqual(["valid", "trimmed"]);
|
||||
});
|
||||
|
||||
it("returns null when only whitespace fields are present", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: {
|
||||
attributes: {
|
||||
result: {
|
||||
exc_type: " ",
|
||||
exc_message: [""],
|
||||
exc_module: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(details).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface ScanErrorDetails {
|
||||
type: string;
|
||||
messages: string[];
|
||||
module?: string;
|
||||
copyValue: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() !== ""
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== "");
|
||||
}
|
||||
|
||||
export function buildScanErrorDetails(
|
||||
result: unknown,
|
||||
): ScanErrorDetails | null {
|
||||
if (!isRecord(result)) return null;
|
||||
|
||||
const type = getString(result.exc_type) ?? "-";
|
||||
const messages = getStringList(result.exc_message);
|
||||
const module = getString(result.exc_module);
|
||||
|
||||
if (type === "-" && messages.length === 0 && !module) return null;
|
||||
|
||||
const errorText = messages.length > 0 ? messages.join("\n") : "-";
|
||||
|
||||
return {
|
||||
type,
|
||||
messages: messages.length > 0 ? messages : ["-"],
|
||||
module,
|
||||
copyValue: `ErrorType: ${type}\nError: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getScanErrorDetails(
|
||||
taskResponse: unknown,
|
||||
): ScanErrorDetails | null {
|
||||
if (!isRecord(taskResponse) || !isRecord(taskResponse.data)) return null;
|
||||
if (!isRecord(taskResponse.data.attributes)) return null;
|
||||
|
||||
return buildScanErrorDetails(taskResponse.data.attributes.result);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AccountsSelector } from "./accounts-selector";
|
||||
|
||||
const multiSelectContentSpy = vi.fn();
|
||||
const multiSelectSpy = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
@@ -35,11 +37,27 @@ vi.mock("@/components/icons/providers-badge", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelect: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
multiSelectSpy({ open });
|
||||
return (
|
||||
<div data-open={String(open)}>
|
||||
<button type="button" onClick={() => onOpenChange?.(true)}>
|
||||
Open selector
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -56,16 +74,26 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
},
|
||||
MultiSelectItem: ({
|
||||
children,
|
||||
disabled,
|
||||
value,
|
||||
keywords,
|
||||
onSelect,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
keywords?: string[];
|
||||
onSelect?: (value: string) => void;
|
||||
}) => (
|
||||
<div data-value={value} data-keywords={keywords?.join("|")}>
|
||||
<button
|
||||
type="button"
|
||||
data-value={value}
|
||||
data-keywords={keywords?.join("|")}
|
||||
data-disabled={disabled ? "true" : "false"}
|
||||
onClick={() => onSelect?.(value)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -114,8 +142,8 @@ describe("AccountsSelector", () => {
|
||||
render(<AccountsSelector providers={providers} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
placeholder: "Search Providers...",
|
||||
emptyMessage: "No Providers found.",
|
||||
});
|
||||
expect(screen.getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
@@ -140,12 +168,97 @@ describe("AccountsSelector", () => {
|
||||
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
|
||||
});
|
||||
|
||||
it("can use provider UID values for pages whose API filters by provider_uid__in", () => {
|
||||
render(
|
||||
<AccountsSelector providers={providers} filterKey="provider_uid__in" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Production AWS").closest("[data-value]"),
|
||||
).toHaveAttribute("data-value", "123456789012");
|
||||
});
|
||||
|
||||
it("disables select all when every account is already shown", () => {
|
||||
render(<AccountsSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: /select all accounts/i }),
|
||||
screen.getByRole("option", { name: /select all Providers/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks configured account values as disabled", () => {
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
disabledValues={["provider-1"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Production AWS").closest("[data-value]"),
|
||||
).toHaveAttribute("data-disabled", "true");
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can close the dropdown after selecting a launch-scan provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
closeOnSelect
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /open selector/i }));
|
||||
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: true });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /production aws/i }));
|
||||
|
||||
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
|
||||
});
|
||||
|
||||
it("shows the provider icon next to the name in the trigger for a single selection", async () => {
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(within(trigger).getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one icon per selected account without deduping by provider type", async () => {
|
||||
const secondAws = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: {
|
||||
...providers[0].attributes,
|
||||
uid: "999999999999",
|
||||
alias: "Staging AWS",
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={[providers[0], secondAws]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1", "provider-2"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
// Two AWS accounts -> two AWS icons in the trigger (no dedupe).
|
||||
expect(await within(trigger).findAllByText("AWS")).toHaveLength(2);
|
||||
expect(
|
||||
within(trigger).getByText("2 Providers selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -36,35 +23,22 @@ import {
|
||||
type ProviderType,
|
||||
} from "@/types/providers";
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
aws: <AWSProviderBadge width={18} height={18} />,
|
||||
azure: <AzureProviderBadge width={18} height={18} />,
|
||||
gcp: <GCPProviderBadge width={18} height={18} />,
|
||||
kubernetes: <KS8ProviderBadge width={18} height={18} />,
|
||||
m365: <M365ProviderBadge width={18} height={18} />,
|
||||
github: <GitHubProviderBadge width={18} height={18} />,
|
||||
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
|
||||
iac: <IacProviderBadge width={18} height={18} />,
|
||||
image: <ImageProviderBadge width={18} height={18} />,
|
||||
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
|
||||
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
|
||||
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
|
||||
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
|
||||
openstack: <OpenStackProviderBadge width={18} height={18} />,
|
||||
vercel: <VercelProviderBadge width={18} height={18} />,
|
||||
okta: <OktaProviderBadge width={18} height={18} />,
|
||||
};
|
||||
const ACCOUNT_SELECTOR_FILTER = {
|
||||
PROVIDER_ID: "provider_id__in",
|
||||
PROVIDER_UID: "provider_uid__in",
|
||||
} as const;
|
||||
|
||||
type AccountSelectorFilter =
|
||||
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface AccountsSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
search?: MultiSelectSearchProp;
|
||||
/**
|
||||
* Currently selected provider types (from the pending ProviderTypeSelector state).
|
||||
* Used only for contextual description/empty-state messaging — does NOT narrow
|
||||
* the list of available accounts, which remains independent of provider selection.
|
||||
*/
|
||||
selectedProviderTypes?: string[];
|
||||
filterKey?: AccountSelectorFilter;
|
||||
id?: string;
|
||||
disabledValues?: string[];
|
||||
closeOnSelect?: boolean;
|
||||
}
|
||||
|
||||
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||
@@ -98,74 +72,105 @@ export function AccountsSelector({
|
||||
providers,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
selectedProviderTypes,
|
||||
filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID,
|
||||
id = "accounts-selector",
|
||||
disabledValues = [],
|
||||
search = {
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
placeholder: "Search Providers...",
|
||||
emptyMessage: "No Providers found.",
|
||||
},
|
||||
closeOnSelect = false,
|
||||
}: AccountsSelectorProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
|
||||
const filterKey = "filter[provider_id__in]";
|
||||
const current = searchParams.get(filterKey) || "";
|
||||
const labelId = `${id}-label`;
|
||||
const urlFilterKey = `filter[${filterKey}]`;
|
||||
const current = searchParams.get(urlFilterKey) || "";
|
||||
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
|
||||
|
||||
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
|
||||
const visibleProviders = providers;
|
||||
// .filter((p) => p.attributes.connection?.connected)
|
||||
const getProviderValue = (provider: ProviderProps) =>
|
||||
filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID
|
||||
? provider.attributes.uid
|
||||
: provider.id;
|
||||
const disabledValuesSet = new Set(disabledValues);
|
||||
|
||||
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||
const selectedIds = (onBatchChange ? selectedValues : urlSelectedIds).filter(
|
||||
(id) => !disabledValuesSet.has(id),
|
||||
);
|
||||
|
||||
const handleMultiValueChange = (ids: string[]) => {
|
||||
const enabledIds = ids.filter((id) => !disabledValuesSet.has(id));
|
||||
|
||||
if (onBatchChange) {
|
||||
onBatchChange("provider_id__in", ids);
|
||||
onBatchChange(filterKey, enabledIds);
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
return;
|
||||
}
|
||||
navigateWithParams((params) => {
|
||||
params.delete(filterKey);
|
||||
params.delete(urlFilterKey);
|
||||
|
||||
if (ids.length > 0) {
|
||||
params.set(filterKey, ids.join(","));
|
||||
if (enabledIds.length > 0) {
|
||||
params.set(urlFilterKey, enabledIds.join(","));
|
||||
}
|
||||
});
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedIds.length === 0) return null;
|
||||
if (selectedIds.length === 1) {
|
||||
const p = providers.find((pr) => pr.id === selectedIds[0]);
|
||||
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
|
||||
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
|
||||
return <span className="truncate">{name}</span>;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{p && (
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={p.attributes.provider} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// One icon per selected account (no dedupe): two accounts of the same
|
||||
// provider show two icons, disambiguated by the UID tooltip on hover.
|
||||
const items = selectedIds
|
||||
.map((selectedId) =>
|
||||
providers.find((pr) => getProviderValue(pr) === selectedId),
|
||||
)
|
||||
.filter((p): p is ProviderProps => Boolean(p))
|
||||
.map((p) => ({
|
||||
key: p.id,
|
||||
type: p.attributes.provider as ProviderType,
|
||||
tooltip: p.attributes.uid,
|
||||
}));
|
||||
return (
|
||||
<span className="truncate">{selectedIds.length} accounts selected</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack items={items} />
|
||||
<span className="truncate">
|
||||
{selectedIds.length} Providers selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Build a contextual description based on currently selected provider types.
|
||||
// This is purely for user guidance (aria label + empty state) and does NOT
|
||||
// narrow the list of available accounts — all providers remain selectable.
|
||||
const filterDescription =
|
||||
selectedProviderTypes && selectedProviderTypes.length > 0
|
||||
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
|
||||
: "All connected provider accounts";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label
|
||||
htmlFor="accounts-selector"
|
||||
className="sr-only"
|
||||
id="accounts-label"
|
||||
>
|
||||
Filter by provider account. {filterDescription}. Select one or more
|
||||
accounts to view findings.
|
||||
<label htmlFor={id} className="sr-only" id={labelId}>
|
||||
Filter by Provider. Select one or more Providers to filter results.
|
||||
</label>
|
||||
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
|
||||
<MultiSelectTrigger
|
||||
id="accounts-selector"
|
||||
aria-labelledby="accounts-label"
|
||||
>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
|
||||
<MultiSelect
|
||||
values={selectedIds}
|
||||
onValuesChange={handleMultiValueChange}
|
||||
open={closeOnSelect ? selectorOpen : undefined}
|
||||
onOpenChange={closeOnSelect ? setSelectorOpen : undefined}
|
||||
>
|
||||
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={search}>
|
||||
{visibleProviders.length > 0 ? (
|
||||
@@ -174,7 +179,7 @@ export function AccountsSelector({
|
||||
role="option"
|
||||
aria-selected={selectedIds.length === 0}
|
||||
aria-disabled={selectedIds.length === 0}
|
||||
aria-label="Select all accounts (clears current selection to show all)"
|
||||
aria-label="Select all Providers (clears current selection to show all)"
|
||||
tabIndex={0}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||
onClick={() => {
|
||||
@@ -192,10 +197,10 @@ export function AccountsSelector({
|
||||
{selectedIds.length === 0 ? "All selected" : "Select All"}
|
||||
</div>
|
||||
{visibleProviders.map((p) => {
|
||||
const id = p.id;
|
||||
const value = getProviderValue(p);
|
||||
const isDisabled = disabledValuesSet.has(value);
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
const searchKeywords = [
|
||||
displayName,
|
||||
p.attributes.alias,
|
||||
@@ -205,23 +210,30 @@ export function AccountsSelector({
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
key={p.id}
|
||||
value={value}
|
||||
badgeLabel={displayName}
|
||||
keywords={searchKeywords}
|
||||
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
|
||||
disabled={isDisabled}
|
||||
aria-label={`${displayName} Provider (${providerType.toUpperCase()})`}
|
||||
onSelect={() => {
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span className="truncate">{displayName}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate">{displayName}</span>
|
||||
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
|
||||
</span>
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{selectedProviderTypes && selectedProviderTypes.length > 0
|
||||
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
|
||||
: "No connected accounts available"}
|
||||
No connected Providers available
|
||||
</div>
|
||||
)}
|
||||
</MultiSelectContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProviderTypeSelector } from "./provider-type-selector";
|
||||
@@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -114,8 +114,8 @@ describe("ProviderTypeSelector", () => {
|
||||
render(<ProviderTypeSelector providers={providers} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
placeholder: "Search Provider Types...",
|
||||
emptyMessage: "No Provider Types found.",
|
||||
});
|
||||
expect(screen.getByText("Amazon Web Services")).toBeInTheDocument();
|
||||
});
|
||||
@@ -141,8 +141,30 @@ describe("ProviderTypeSelector", () => {
|
||||
render(<ProviderTypeSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: /select all providers/i }),
|
||||
screen.getByRole("option", { name: /select all Provider Types/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one icon per selected type and a count in the trigger", async () => {
|
||||
const azure = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: { ...providers[0].attributes, provider: "azure" as const },
|
||||
};
|
||||
|
||||
render(
|
||||
<ProviderTypeSelector
|
||||
providers={[providers[0], azure]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["aws", "azure"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(
|
||||
within(trigger).getByText("2 Provider Types selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type ComponentType, lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -14,163 +18,6 @@ import {
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
const IacProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.IacProviderBadge,
|
||||
})),
|
||||
);
|
||||
const ImageProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.ImageProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OracleCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OracleCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const MongoDBAtlasProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.MongoDBAtlasProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AlibabaCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GoogleWorkspaceProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GoogleWorkspaceProviderBadge,
|
||||
})),
|
||||
);
|
||||
const VercelProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OktaProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OktaProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
const PROVIDER_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: {
|
||||
label: "Amazon Web Services",
|
||||
icon: AWSProviderBadge,
|
||||
},
|
||||
azure: {
|
||||
label: "Microsoft Azure",
|
||||
icon: AzureProviderBadge,
|
||||
},
|
||||
gcp: {
|
||||
label: "Google Cloud Platform",
|
||||
icon: GCPProviderBadge,
|
||||
},
|
||||
kubernetes: {
|
||||
label: "Kubernetes",
|
||||
icon: KS8ProviderBadge,
|
||||
},
|
||||
m365: {
|
||||
label: "Microsoft 365",
|
||||
icon: M365ProviderBadge,
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
icon: GitHubProviderBadge,
|
||||
},
|
||||
googleworkspace: {
|
||||
label: "Google Workspace",
|
||||
icon: GoogleWorkspaceProviderBadge,
|
||||
},
|
||||
iac: {
|
||||
label: "Infrastructure as Code",
|
||||
icon: IacProviderBadge,
|
||||
},
|
||||
image: {
|
||||
label: "Container Registry",
|
||||
icon: ImageProviderBadge,
|
||||
},
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
icon: OracleCloudProviderBadge,
|
||||
},
|
||||
mongodbatlas: {
|
||||
label: "MongoDB Atlas",
|
||||
icon: MongoDBAtlasProviderBadge,
|
||||
},
|
||||
alibabacloud: {
|
||||
label: "Alibaba Cloud",
|
||||
icon: AlibabaCloudProviderBadge,
|
||||
},
|
||||
cloudflare: {
|
||||
label: "Cloudflare",
|
||||
icon: CloudflareProviderBadge,
|
||||
},
|
||||
openstack: {
|
||||
label: "OpenStack",
|
||||
icon: OpenStackProviderBadge,
|
||||
},
|
||||
vercel: {
|
||||
label: "Vercel",
|
||||
icon: VercelProviderBadge,
|
||||
},
|
||||
okta: {
|
||||
label: "Okta",
|
||||
icon: OktaProviderBadge,
|
||||
},
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderTypeSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
@@ -210,8 +57,8 @@ export const ProviderTypeSelector = ({
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
search = {
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
placeholder: "Search Provider Types...",
|
||||
emptyMessage: "No Provider Types found.",
|
||||
},
|
||||
}: ProviderTypeSelectorProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_DATA)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
|
||||
.sort((a, b) =>
|
||||
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
|
||||
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
|
||||
);
|
||||
|
||||
const renderIcon = (providerType: ProviderType) => {
|
||||
const IconComponent = PROVIDER_DATA[providerType].icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
|
||||
<IconComponent width={24} height={24} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedTypes.length === 0) return null;
|
||||
if (selectedTypes.length === 1) {
|
||||
const providerType = selectedTypes[0] as ProviderType;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{renderIcon(providerType)}
|
||||
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_TYPE_DATA[providerType].label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} providers selected
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack
|
||||
items={(selectedTypes as ProviderType[]).map((type) => ({
|
||||
key: type,
|
||||
type,
|
||||
tooltip: PROVIDER_TYPE_DATA[type].label,
|
||||
}))}
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} Provider Types selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -286,7 +137,8 @@ export const ProviderTypeSelector = ({
|
||||
className="sr-only"
|
||||
id="provider-type-label"
|
||||
>
|
||||
Filter by provider type. Select one or more providers to view findings.
|
||||
Filter by Provider Type. Select one or more Provider Types to view
|
||||
findings.
|
||||
</label>
|
||||
<MultiSelect
|
||||
values={selectedTypes}
|
||||
@@ -296,7 +148,9 @@ export const ProviderTypeSelector = ({
|
||||
id="provider-type-selector"
|
||||
aria-labelledby="provider-type-label"
|
||||
>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All providers" />}
|
||||
{selectedLabel() || (
|
||||
<MultiSelectValue placeholder="All Provider Types" />
|
||||
)}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={search}>
|
||||
{availableTypes.length > 0 ? (
|
||||
@@ -305,7 +159,7 @@ export const ProviderTypeSelector = ({
|
||||
role="option"
|
||||
aria-selected={selectedTypes.length === 0}
|
||||
aria-disabled={selectedTypes.length === 0}
|
||||
aria-label="Select all providers (clears current selection to show all)"
|
||||
aria-label="Select all Provider Types (clears current selection to show all)"
|
||||
tabIndex={0}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||
onClick={() => {
|
||||
@@ -326,18 +180,23 @@ export const ProviderTypeSelector = ({
|
||||
<MultiSelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_DATA[providerType].label}
|
||||
keywords={[providerType, PROVIDER_DATA[providerType].label]}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
|
||||
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
|
||||
keywords={[
|
||||
providerType,
|
||||
PROVIDER_TYPE_DATA[providerType].label,
|
||||
]}
|
||||
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} size={24} />
|
||||
</span>
|
||||
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
No connected providers available
|
||||
No connected Provider Types available
|
||||
</div>
|
||||
)}
|
||||
</MultiSelectContent>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import {
|
||||
getFindingsBySeverity,
|
||||
SeverityByProviderType,
|
||||
} from "@/actions/overview";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function RiskPipelineViewSSR({
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
|
||||
// Fetch providers list to know account types
|
||||
const providersListResponse = await getProviders({ pageSize: 200 });
|
||||
const providersListResponse = await getAllProviders();
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Build severityByProviderType based on filters
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
adaptToRiskPlotData,
|
||||
getProvidersRiskData,
|
||||
} from "@/actions/overview/risk-plot";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
@@ -21,7 +21,7 @@ export async function RiskPlotSSR({
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
|
||||
// Fetch all providers
|
||||
const providersListResponse = await getProviders({ pageSize: 200 });
|
||||
const providersListResponse = await getAllProviders();
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Filter providers based on search params
|
||||
|
||||
@@ -518,7 +518,7 @@ describe("AlertFormModal", () => {
|
||||
expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3");
|
||||
expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5");
|
||||
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
|
||||
expect(screen.getByText("All accounts")).toBeVisible();
|
||||
expect(screen.getByText("All Providers")).toBeVisible();
|
||||
expect(within(filterControls).getByText("All Delta")).toBeVisible();
|
||||
expect(within(filterControls).getByText("All Resource Type")).toBeVisible();
|
||||
expect(
|
||||
@@ -547,7 +547,9 @@ describe("AlertFormModal", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByLabelText(/provider type/i));
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
|
||||
);
|
||||
const providerOptions = await screen.findAllByText("Google Cloud Platform");
|
||||
const visibleProviderOption = providerOptions.at(-1);
|
||||
expect(visibleProviderOption).toBeDefined();
|
||||
@@ -597,7 +599,9 @@ describe("AlertFormModal", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByLabelText(/provider type/i));
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
|
||||
);
|
||||
const providerOptions = await screen.findAllByText("Google Cloud Platform");
|
||||
const visibleProviderOption = providerOptions.at(-1);
|
||||
expect(visibleProviderOption).toBeDefined();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user