mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 17:40:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b313a6dde1 | ||
|
|
0772169b5c | ||
|
|
1f27066635 | ||
|
|
f815380c3f | ||
|
|
5b8dab973e | ||
|
|
14e50ede21 | ||
|
|
11317c4ec0 | ||
|
|
ab325c9099 | ||
|
|
6fe8646903 | ||
|
|
9b0c4a0073 |
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.1
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -35,6 +35,20 @@ CVE-2026-13221 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
|
||||
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
|
||||
# to av_extend).
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. Prowler does not invoke perl at runtime and
|
||||
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
|
||||
# vulnerable deserialization path is unreachable. Fixed upstream in
|
||||
# Storable 3.41; no Debian bookworm fix is available yet.
|
||||
CVE-2026-57433 pkg:perl exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2025-7458 — SQLite integer overflow.
|
||||
# Package: libsqlite3-0.
|
||||
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped
|
||||
+2
-2
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.35",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.36.0"
|
||||
version = "1.36.1"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.36.0
|
||||
version: 1.36.1
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -605,6 +606,45 @@ def _process_finding_micro_batch(
|
||||
scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
|
||||
group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
|
||||
"""
|
||||
|
||||
def build_resource_defaults_from_finding(finding: ProwlerFinding) -> dict[str, Any]:
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"provider": provider_instance,
|
||||
"uid": finding.resource_uid,
|
||||
"region": finding.region,
|
||||
"service": finding.service_name,
|
||||
"type": finding.resource_type,
|
||||
"name": finding.resource_name,
|
||||
"groups": [group] if group else None,
|
||||
}
|
||||
|
||||
def recover_resource_after_cache_miss(finding: ProwlerFinding) -> Resource:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
resource_instance = Resource.objects.create(
|
||||
**build_resource_defaults_from_finding(finding)
|
||||
)
|
||||
except IntegrityError:
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
raise
|
||||
|
||||
return cache_resource(resource_uid, resource_instance)
|
||||
|
||||
# Accumulate objects for bulk operations
|
||||
findings_to_create = []
|
||||
dirty_resources = {}
|
||||
@@ -643,7 +683,103 @@ def _process_finding_micro_batch(
|
||||
|
||||
# All DB writes for this micro-batch run inside ONE rls_transaction,
|
||||
# with deadlock-retry at micro-batch granularity instead of per-finding.
|
||||
missing_cache_value = object()
|
||||
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
|
||||
resource_cache_originals: dict[str, Resource | object] = {}
|
||||
failed_count_originals: dict[str, int | None] = {}
|
||||
resource_field_originals: dict[str, dict[str, Any]] = {}
|
||||
tag_cache_original = dict(tag_cache)
|
||||
scan_resource_cache_original = set(scan_resource_cache)
|
||||
scan_categories_cache_original = {
|
||||
key: value.copy() for key, value in scan_categories_cache.items()
|
||||
}
|
||||
scan_resource_groups_cache_original = {
|
||||
key: value.copy() for key, value in scan_resource_groups_cache.items()
|
||||
}
|
||||
group_resources_cache_original = {
|
||||
key: set(value) for key, value in group_resources_cache.items()
|
||||
}
|
||||
|
||||
def cache_resource(resource_uid: str, resource_instance: Resource) -> Resource:
|
||||
if resource_uid not in resource_cache_originals:
|
||||
resource_cache_originals[resource_uid] = resource_cache.get(
|
||||
resource_uid, missing_cache_value
|
||||
)
|
||||
resource_cache[resource_uid] = resource_instance
|
||||
if resource_uid not in resource_failed_findings_cache:
|
||||
failed_count_originals[resource_uid] = None
|
||||
resource_failed_findings_cache[resource_uid] = 0
|
||||
return resource_instance
|
||||
|
||||
def snapshot_failed_count(resource_uid: str) -> None:
|
||||
if resource_uid not in failed_count_originals:
|
||||
failed_count_originals[resource_uid] = (
|
||||
resource_failed_findings_cache.get(resource_uid)
|
||||
)
|
||||
|
||||
def snapshot_resource_fields(
|
||||
resource_uid: str, resource_instance: Resource
|
||||
) -> None:
|
||||
if resource_uid in resource_field_originals:
|
||||
return
|
||||
resource_field_originals[resource_uid] = {
|
||||
field: copy.deepcopy(getattr(resource_instance, field))
|
||||
for field in (
|
||||
"name",
|
||||
"metadata",
|
||||
"details",
|
||||
"partition",
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"groups",
|
||||
"updated_at",
|
||||
)
|
||||
}
|
||||
|
||||
def restore_attempt_caches() -> None:
|
||||
for resource_uid, original_fields in resource_field_originals.items():
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
continue
|
||||
for field, value in original_fields.items():
|
||||
setattr(resource_instance, field, value)
|
||||
for resource_uid, original_resource in resource_cache_originals.items():
|
||||
if original_resource is missing_cache_value:
|
||||
resource_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_cache[resource_uid] = original_resource
|
||||
for resource_uid, original_count in failed_count_originals.items():
|
||||
if original_count is None:
|
||||
resource_failed_findings_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_failed_findings_cache[resource_uid] = original_count
|
||||
tag_cache.clear()
|
||||
tag_cache.update(tag_cache_original)
|
||||
scan_resource_cache.clear()
|
||||
scan_resource_cache.update(scan_resource_cache_original)
|
||||
scan_categories_cache.clear()
|
||||
scan_categories_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_categories_cache_original.items()
|
||||
}
|
||||
)
|
||||
scan_resource_groups_cache.clear()
|
||||
scan_resource_groups_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_resource_groups_cache_original.items()
|
||||
}
|
||||
)
|
||||
group_resources_cache.clear()
|
||||
group_resources_cache.update(
|
||||
{
|
||||
key: set(value)
|
||||
for key, value in group_resources_cache_original.items()
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
# 1) Pre-resolve Resources in bulk
|
||||
@@ -678,19 +814,8 @@ def _process_finding_micro_batch(
|
||||
resources_to_create = []
|
||||
for uid in missing_uids:
|
||||
f = first_finding_per_uid[uid]
|
||||
check_metadata = f.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
resources_to_create.append(
|
||||
Resource(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_instance,
|
||||
uid=uid,
|
||||
region=f.region,
|
||||
service=f.service_name,
|
||||
type=f.resource_type,
|
||||
name=f.resource_name,
|
||||
groups=[group] if group else None,
|
||||
)
|
||||
Resource(**build_resource_defaults_from_finding(f))
|
||||
)
|
||||
Resource.objects.bulk_create(
|
||||
resources_to_create,
|
||||
@@ -711,8 +836,7 @@ def _process_finding_micro_batch(
|
||||
}
|
||||
)
|
||||
for uid, r in existing_resources.items():
|
||||
resource_cache[uid] = r
|
||||
resource_failed_findings_cache.setdefault(uid, 0)
|
||||
cache_resource(uid, r)
|
||||
|
||||
# 2) Pre-resolve ResourceTags in bulk
|
||||
batch_tag_kv: set[tuple[str, str]] = set()
|
||||
@@ -758,47 +882,50 @@ def _process_finding_micro_batch(
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
# Should be unreachable after the pre-resolve step. Defensive log.
|
||||
logger.error(
|
||||
f"Resource {resource_uid} missing from cache after pre-resolve "
|
||||
f"on scan {scan_instance.id}; skipping finding."
|
||||
)
|
||||
continue
|
||||
resource_instance = recover_resource_after_cache_miss(finding)
|
||||
|
||||
# Detect resource field changes (defer save until end-of-batch bulk_update).
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
updated = False
|
||||
if finding.region and resource_instance.region != finding.region:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.region = finding.region
|
||||
updated = True
|
||||
if (
|
||||
finding.resource_name
|
||||
and resource_instance.name != finding.resource_name
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.name = finding.resource_name
|
||||
updated = True
|
||||
if resource_instance.service != finding.service_name:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.service = finding.service_name
|
||||
updated = True
|
||||
if resource_instance.type != finding.resource_type:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.type = finding.resource_type
|
||||
updated = True
|
||||
if resource_instance.metadata != finding.resource_metadata:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.metadata = json.dumps(
|
||||
finding.resource_metadata, cls=CustomEncoder
|
||||
)
|
||||
updated = True
|
||||
if resource_instance.details != finding.resource_details:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.details = finding.resource_details
|
||||
updated = True
|
||||
if resource_instance.partition != finding.partition:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.partition = finding.partition
|
||||
updated = True
|
||||
if group and (
|
||||
not resource_instance.groups
|
||||
or group not in resource_instance.groups
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.groups = (resource_instance.groups or []) + [
|
||||
group
|
||||
]
|
||||
@@ -860,6 +987,7 @@ def _process_finding_micro_batch(
|
||||
muted_reason = mute_rules_cache[finding_uid]
|
||||
|
||||
if status == FindingStatus.FAIL and not is_muted:
|
||||
snapshot_failed_count(resource_uid)
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
check_metadata["compliance"] = finding.compliance
|
||||
@@ -1013,6 +1141,7 @@ def _process_finding_micro_batch(
|
||||
if r is None:
|
||||
continue
|
||||
# Manually bump updated_at since bulk_update bypasses auto_now.
|
||||
snapshot_resource_fields(uid, r)
|
||||
r.updated_at = now_utc
|
||||
resources_to_bulk_update.append(r)
|
||||
if resources_to_bulk_update:
|
||||
@@ -1034,6 +1163,7 @@ def _process_finding_micro_batch(
|
||||
# Successful execution: leave deadlock retry loop.
|
||||
break
|
||||
except (OperationalError, IntegrityError) as db_err:
|
||||
restore_attempt_caches()
|
||||
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
|
||||
logger.warning(
|
||||
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
|
||||
|
||||
@@ -2,6 +2,7 @@ import csv
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from io import StringIO
|
||||
@@ -15,13 +16,16 @@ from api.models import (
|
||||
MuteRule,
|
||||
Provider,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceScanSummary,
|
||||
ResourceTag,
|
||||
ResourceTagMapping,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
)
|
||||
from django.db import IntegrityError, OperationalError
|
||||
from django.db import IntegrityError, OperationalError, transaction
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.scan import (
|
||||
@@ -52,6 +56,12 @@ def noop_rls_transaction(*args, **kwargs):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_rls_transaction(*args, **kwargs):
|
||||
with transaction.atomic():
|
||||
yield
|
||||
|
||||
|
||||
class FakeFinding:
|
||||
def __init__(self, **attrs):
|
||||
self.metadata = attrs.pop("metadata", {})
|
||||
@@ -70,6 +80,32 @@ class FakeFinding:
|
||||
return self.metadata
|
||||
|
||||
|
||||
class CacheMissAfterPreResolve(MutableMapping):
|
||||
def __init__(self, missing_uid):
|
||||
self._cache = {}
|
||||
self.missing_uid = missing_uid
|
||||
|
||||
def __contains__(self, key):
|
||||
if key == self.missing_uid:
|
||||
return True
|
||||
return key in self._cache
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._cache[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._cache[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self._cache[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._cache)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPerformScan:
|
||||
def test_perform_prowler_scan_success(
|
||||
@@ -1054,8 +1090,12 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify findings are muted with correct reason
|
||||
fail_finding_db = Finding.objects.get(uid=finding_uid_1)
|
||||
pass_finding_db = Finding.objects.get(uid=finding_uid_2)
|
||||
fail_finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_1
|
||||
)
|
||||
pass_finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_2
|
||||
)
|
||||
|
||||
assert fail_finding_db.muted
|
||||
assert fail_finding_db.muted_reason == mute_rule_reason
|
||||
@@ -1066,7 +1106,9 @@ class TestPerformScan:
|
||||
assert pass_finding_db.muted_at is not None
|
||||
|
||||
# Verify failed_findings_count is 0 for muted FAIL finding
|
||||
resource_1 = Resource.objects.get(uid="resource_uid_1")
|
||||
resource_1 = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_1"
|
||||
)
|
||||
assert resource_1.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_with_inactive_mute_rules(
|
||||
@@ -1146,13 +1188,17 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify finding is NOT muted
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert not finding_db.muted
|
||||
assert finding_db.muted_reason is None
|
||||
assert finding_db.muted_at is None
|
||||
|
||||
# Verify failed_findings_count increments for FAIL finding
|
||||
resource = Resource.objects.get(uid="resource_uid_inactive")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_inactive"
|
||||
)
|
||||
assert resource.failed_findings_count == 1
|
||||
|
||||
def test_perform_prowler_scan_mutelist_overrides_mute_rules(
|
||||
@@ -1232,13 +1278,17 @@ class TestPerformScan:
|
||||
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
|
||||
|
||||
# Verify mutelist reason takes precedence
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_reason == "Muted by mutelist"
|
||||
assert finding_db.muted_at is not None
|
||||
|
||||
# Verify failed_findings_count is 0
|
||||
resource = Resource.objects.get(uid="resource_both")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_both"
|
||||
)
|
||||
assert resource.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_mute_rules_multiple_findings(
|
||||
@@ -1330,14 +1380,20 @@ class TestPerformScan:
|
||||
|
||||
# Verify all findings are muted with same reason
|
||||
for uid in finding_uids:
|
||||
finding_db = Finding.objects.get(uid=uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_reason == mute_rule_reason
|
||||
assert finding_db.muted_at is not None
|
||||
|
||||
# Verify all resources have failed_findings_count = 0
|
||||
for i in range(len(finding_uids)):
|
||||
resource = Resource.objects.get(uid=f"resource_bulk_{i}")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider.id,
|
||||
uid=f"resource_bulk_{i}",
|
||||
)
|
||||
assert resource.failed_findings_count == 0
|
||||
|
||||
def test_perform_prowler_scan_mute_rules_error_handling(
|
||||
@@ -1415,12 +1471,18 @@ class TestPerformScan:
|
||||
assert scan.state == StateChoices.COMPLETED
|
||||
|
||||
# Verify finding is not muted (mute_rules_cache was empty dict)
|
||||
finding_db = Finding.objects.get(uid="finding_error_handling")
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid="finding_error_handling",
|
||||
)
|
||||
assert not finding_db.muted
|
||||
assert finding_db.muted_reason is None
|
||||
|
||||
# Verify failed_findings_count increments
|
||||
resource = Resource.objects.get(uid="resource_error")
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid="resource_error"
|
||||
)
|
||||
assert resource.failed_findings_count == 1
|
||||
|
||||
def test_perform_prowler_scan_muted_at_timestamp(
|
||||
@@ -1502,7 +1564,9 @@ class TestPerformScan:
|
||||
after_scan = datetime.now(UTC)
|
||||
|
||||
# Verify muted_at is within the scan time window
|
||||
finding_db = Finding.objects.get(uid=finding_uid)
|
||||
finding_db = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
|
||||
)
|
||||
assert finding_db.muted
|
||||
assert finding_db.muted_at is not None
|
||||
assert before_scan <= finding_db.muted_at <= after_scan
|
||||
@@ -1513,6 +1577,548 @@ class TestPerformScan:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProcessFindingMicroBatch:
|
||||
def _process_one_finding_micro_batch(
|
||||
self,
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=None,
|
||||
resource_failed_findings_cache=None,
|
||||
):
|
||||
resource_cache = resource_cache if resource_cache is not None else {}
|
||||
resource_failed_findings_cache = (
|
||||
resource_failed_findings_cache
|
||||
if resource_failed_findings_cache is not None
|
||||
else {}
|
||||
)
|
||||
caches = {
|
||||
"resource_cache": resource_cache,
|
||||
"tag_cache": {},
|
||||
"last_status_cache": {},
|
||||
"resource_failed_findings_cache": resource_failed_findings_cache,
|
||||
"unique_resources": set(),
|
||||
"scan_resource_cache": set(),
|
||||
"mute_rules_cache": {},
|
||||
"scan_categories_cache": {},
|
||||
"scan_resource_groups_cache": {},
|
||||
"group_resources_cache": {},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
|
||||
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
|
||||
):
|
||||
_process_finding_micro_batch(
|
||||
str(tenant.id),
|
||||
[finding],
|
||||
scan,
|
||||
provider,
|
||||
caches["resource_cache"],
|
||||
caches["tag_cache"],
|
||||
caches["last_status_cache"],
|
||||
caches["resource_failed_findings_cache"],
|
||||
caches["unique_resources"],
|
||||
caches["scan_resource_cache"],
|
||||
caches["mute_rules_cache"],
|
||||
caches["scan_categories_cache"],
|
||||
caches["scan_resource_groups_cache"],
|
||||
caches["group_resources_cache"],
|
||||
)
|
||||
|
||||
return caches
|
||||
|
||||
def test_process_finding_micro_batch_fallback_creates_resource_after_cache_miss(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:accessanalyzer:us-east-1:123456789012:analyzer/unknown"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-create",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing analyzer",
|
||||
severity=Severity.medium,
|
||||
check_id="accessanalyzer_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="analyzer/unknown",
|
||||
region="us-east-1",
|
||||
service_name="accessanalyzer",
|
||||
resource_type="analyzer",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert resource.provider_id == provider.id
|
||||
assert resource.region == finding.region
|
||||
assert resource.service == finding.service_name
|
||||
assert resource.type == finding.resource_type
|
||||
assert resource.name == finding.resource_name
|
||||
assert resource.groups == ["identity"]
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_fallback_recovers_existing_resource_after_cache_miss(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:guardduty:us-east-1:123456789012:detector/unknown"
|
||||
existing_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid=resource_uid,
|
||||
name="detector/unknown",
|
||||
region="us-east-1",
|
||||
service="guardduty",
|
||||
type="detector",
|
||||
)
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-existing",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing detector",
|
||||
severity=Severity.high,
|
||||
check_id="guardduty_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name=existing_resource.name,
|
||||
region=existing_resource.region,
|
||||
service_name=existing_resource.service,
|
||||
resource_type=existing_resource.type,
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert (
|
||||
Resource.objects.filter(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
existing_resource.refresh_from_db()
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert existing_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == existing_resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_fallback_recovers_after_create_race(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unknown"
|
||||
raced_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid=resource_uid,
|
||||
name="hub/unknown",
|
||||
region="us-east-1",
|
||||
service="securityhub",
|
||||
type="hub",
|
||||
)
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-failure",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/unknown",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
resource_filter_result = MagicMock()
|
||||
resource_filter_result.first.side_effect = [None, raced_resource]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"filter",
|
||||
return_value=resource_filter_result,
|
||||
),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=IntegrityError("duplicate resource"),
|
||||
),
|
||||
):
|
||||
caches = self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert (
|
||||
Resource.objects.filter(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
raced_resource.refresh_from_db()
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert raced_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
assert caches["resource_cache"][resource_uid].id == raced_resource.id
|
||||
assert caches["resource_failed_findings_cache"][resource_uid] == 1
|
||||
|
||||
def test_process_finding_micro_batch_cache_miss_retry_drops_rolled_back_resource(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "generic-resource-cache-miss-retry"
|
||||
cached_resource = Resource.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
uid="generic-cached-resource-retry",
|
||||
name="old-cached-resource",
|
||||
region="us-west-2",
|
||||
service="old-service",
|
||||
type="old-type",
|
||||
)
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-retry-clean-resource-cache",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing resource",
|
||||
severity=Severity.high,
|
||||
check_id="generic_resource_check",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="generic-resource",
|
||||
region="us-east-1",
|
||||
service_name="generic-service",
|
||||
resource_type="generic-type",
|
||||
resource_tags={"team": "platform"},
|
||||
resource_metadata={"owner": "security"},
|
||||
resource_details={"id": "generic-resource"},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"categories": ["security"], "resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
cached_resource_finding = FakeFinding(
|
||||
uid="finding-cache-miss-retry-restores-dirty-resource",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="cached resource changed",
|
||||
severity=Severity.high,
|
||||
check_id="generic_cached_resource_check",
|
||||
resource_uid=cached_resource.uid,
|
||||
resource_name="new-cached-resource",
|
||||
region="eu-west-1",
|
||||
service_name="new-service",
|
||||
resource_type="new-type",
|
||||
resource_tags={},
|
||||
resource_metadata={"owner": "platform"},
|
||||
resource_details={"id": "cached-resource"},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={"categories": ["security"], "resourcegroup": "identity"},
|
||||
muted=False,
|
||||
)
|
||||
resource_cache = CacheMissAfterPreResolve(resource_uid)
|
||||
resource_cache[cached_resource.uid] = cached_resource
|
||||
tag_cache = {}
|
||||
resource_failed_findings_cache = {cached_resource.uid: 0}
|
||||
scan_resource_cache: set[tuple[str, str, str, str]] = set()
|
||||
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
|
||||
scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
|
||||
group_resources_cache: dict[str, set] = {}
|
||||
original_bulk_create = ResourceFindingMapping.objects.bulk_create
|
||||
original_tag_mapping_bulk_create = ResourceTagMapping.objects.bulk_create
|
||||
mapping_bulk_create_calls = []
|
||||
tag_mapping_bulk_create_calls = []
|
||||
|
||||
def fail_once_then_bulk_create(objects, *args, **kwargs):
|
||||
mapping_bulk_create_calls.append([str(obj.resource_id) for obj in objects])
|
||||
if len(mapping_bulk_create_calls) == 1:
|
||||
raise IntegrityError("rollback after fallback resource creation")
|
||||
return original_bulk_create(objects, *args, **kwargs)
|
||||
|
||||
def track_tag_mappings_bulk_create(objects, *args, **kwargs):
|
||||
tag_mapping_bulk_create_calls.append([str(obj.tag_id) for obj in objects])
|
||||
return original_tag_mapping_bulk_create(objects, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 2),
|
||||
patch("tasks.jobs.scan.rls_transaction", new=atomic_rls_transaction),
|
||||
patch("api.db_utils.rls_transaction", new=atomic_rls_transaction),
|
||||
patch.object(
|
||||
ResourceTagMapping.objects,
|
||||
"bulk_create",
|
||||
side_effect=track_tag_mappings_bulk_create,
|
||||
),
|
||||
patch.object(
|
||||
ResourceFindingMapping.objects,
|
||||
"bulk_create",
|
||||
side_effect=fail_once_then_bulk_create,
|
||||
),
|
||||
):
|
||||
_process_finding_micro_batch(
|
||||
str(tenant.id),
|
||||
[finding, cached_resource_finding],
|
||||
scan,
|
||||
provider,
|
||||
resource_cache,
|
||||
tag_cache,
|
||||
{},
|
||||
resource_failed_findings_cache,
|
||||
set(),
|
||||
scan_resource_cache,
|
||||
{},
|
||||
scan_categories_cache,
|
||||
scan_resource_groups_cache,
|
||||
group_resources_cache,
|
||||
)
|
||||
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
provider_id=provider.id,
|
||||
uid=resource_uid,
|
||||
)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=finding.uid,
|
||||
)
|
||||
cached_resource.refresh_from_db()
|
||||
|
||||
assert len(mapping_bulk_create_calls) == 2
|
||||
assert mapping_bulk_create_calls[0] != mapping_bulk_create_calls[1]
|
||||
assert len(tag_mapping_bulk_create_calls) == 2
|
||||
assert tag_mapping_bulk_create_calls[0] != tag_mapping_bulk_create_calls[1]
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=finding.uid,
|
||||
).exists()
|
||||
assert cached_resource.findings.filter(
|
||||
tenant_id=tenant.id,
|
||||
scan_id=scan.id,
|
||||
uid=cached_resource_finding.uid,
|
||||
).exists()
|
||||
assert cached_resource.name == cached_resource_finding.resource_name
|
||||
assert cached_resource.region == cached_resource_finding.region
|
||||
assert cached_resource.service == cached_resource_finding.service_name
|
||||
assert cached_resource.type == cached_resource_finding.resource_type
|
||||
assert resource_cache[resource_uid].id == resource.id
|
||||
assert resource_failed_findings_cache[resource_uid] == 1
|
||||
assert resource_failed_findings_cache[cached_resource.uid] == 1
|
||||
assert scan_resource_cache == {
|
||||
(
|
||||
str(resource.id),
|
||||
finding.service_name,
|
||||
finding.region,
|
||||
finding.resource_type,
|
||||
),
|
||||
(
|
||||
str(cached_resource.id),
|
||||
cached_resource_finding.service_name,
|
||||
cached_resource_finding.region,
|
||||
cached_resource_finding.resource_type,
|
||||
),
|
||||
}
|
||||
assert (
|
||||
tag_cache[("team", "platform")].id
|
||||
== ResourceTag.objects.get(
|
||||
tenant_id=tenant.id,
|
||||
key="team",
|
||||
value="platform",
|
||||
).id
|
||||
)
|
||||
assert scan_categories_cache == {
|
||||
("security", "high"): {"total": 2, "failed": 2, "new_failed": 2}
|
||||
}
|
||||
assert scan_resource_groups_cache == {
|
||||
("identity", "high"): {"total": 2, "failed": 2, "new_failed": 2}
|
||||
}
|
||||
assert group_resources_cache == {
|
||||
"identity": {resource_uid, cached_resource.uid}
|
||||
}
|
||||
|
||||
def test_process_finding_micro_batch_propagates_retryable_cache_miss_db_errors(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/retryable"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-retryable-error",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/retryable",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=OperationalError("deadlock detected"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(OperationalError, match="deadlock detected"):
|
||||
self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
def test_process_finding_micro_batch_propagates_unrecovered_cache_miss_integrity_error(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = scan.provider
|
||||
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unrecovered"
|
||||
|
||||
finding = FakeFinding(
|
||||
uid="finding-cache-miss-unrecovered-integrity-error",
|
||||
status=StatusChoices.FAIL,
|
||||
status_extended="missing hub",
|
||||
severity=Severity.high,
|
||||
check_id="securityhub_enabled",
|
||||
resource_uid=resource_uid,
|
||||
resource_name="hub/unrecovered",
|
||||
region="us-east-1",
|
||||
service_name="securityhub",
|
||||
resource_type="hub",
|
||||
resource_tags={},
|
||||
resource_metadata={},
|
||||
resource_details={},
|
||||
partition="aws",
|
||||
raw={},
|
||||
compliance={},
|
||||
metadata={},
|
||||
muted=False,
|
||||
)
|
||||
|
||||
original_resource_filter = Resource.objects.filter
|
||||
resource_filter_result = MagicMock()
|
||||
resource_filter_result.first.side_effect = [None, None]
|
||||
|
||||
def resource_filter_side_effect(*args, **kwargs):
|
||||
if kwargs.get("uid") == resource_uid:
|
||||
return resource_filter_result
|
||||
return original_resource_filter(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"filter",
|
||||
side_effect=resource_filter_side_effect,
|
||||
),
|
||||
patch.object(
|
||||
Resource.objects,
|
||||
"create",
|
||||
side_effect=IntegrityError("constraint violation"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(IntegrityError, match="constraint violation"):
|
||||
self._process_one_finding_micro_batch(
|
||||
tenant,
|
||||
scan,
|
||||
provider,
|
||||
finding,
|
||||
resource_cache=CacheMissAfterPreResolve(resource_uid),
|
||||
)
|
||||
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
def test_process_finding_micro_batch_creates_records_and_updates_caches(
|
||||
self, tenants_fixture, scans_fixture
|
||||
):
|
||||
@@ -1573,8 +2179,12 @@ class TestProcessFindingMicroBatch:
|
||||
group_resources_cache,
|
||||
)
|
||||
|
||||
created_finding = Finding.objects.get(uid=finding.uid)
|
||||
resource = Resource.objects.get(uid=finding.resource_uid)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
resource = Resource.objects.get(
|
||||
tenant_id=tenant.id, provider_id=provider.id, uid=finding.resource_uid
|
||||
)
|
||||
|
||||
assert created_finding.scan_id == scan.id
|
||||
assert created_finding.status == StatusChoices.PASS
|
||||
@@ -1602,7 +2212,9 @@ class TestProcessFindingMicroBatch:
|
||||
assert set(resource.tags.values_list("key", "value")) == set(
|
||||
finding.resource_tags.items()
|
||||
)
|
||||
assert resource.findings.filter(uid=finding.uid).exists()
|
||||
assert resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
assert resource_cache[finding.resource_uid].id == resource.id
|
||||
assert resource_failed_findings_cache[finding.resource_uid] == 0
|
||||
@@ -1691,7 +2303,9 @@ class TestProcessFindingMicroBatch:
|
||||
)
|
||||
|
||||
existing_resource.refresh_from_db()
|
||||
created_finding = Finding.objects.get(uid=finding.uid)
|
||||
created_finding = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
)
|
||||
|
||||
assert created_finding.delta == Finding.DeltaChoices.CHANGED
|
||||
assert created_finding.status == StatusChoices.FAIL
|
||||
@@ -1725,7 +2339,9 @@ class TestProcessFindingMicroBatch:
|
||||
assert set(existing_resource.tags.values_list("key", "value")) == {
|
||||
("team", "devsec")
|
||||
}
|
||||
assert existing_resource.findings.filter(uid=finding.uid).exists()
|
||||
assert existing_resource.findings.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
|
||||
).exists()
|
||||
|
||||
assert resource_cache[finding.resource_uid].region == finding.region
|
||||
assert resource_cache[finding.resource_uid].service == finding.service_name
|
||||
@@ -1891,10 +2507,14 @@ class TestProcessFindingMicroBatch:
|
||||
)
|
||||
|
||||
# Verify the long UID finding was NOT created
|
||||
assert not Finding.objects.filter(uid=long_uid).exists()
|
||||
assert not Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=long_uid
|
||||
).exists()
|
||||
|
||||
# Verify the normal finding WAS created
|
||||
assert Finding.objects.filter(uid=normal_finding.uid).exists()
|
||||
assert Finding.objects.filter(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid=normal_finding.uid
|
||||
).exists()
|
||||
|
||||
# Verify logging was called for skipped finding
|
||||
assert mock_logger.warning.called
|
||||
@@ -2019,8 +2639,12 @@ class TestProcessFindingMicroBatch:
|
||||
"new_failed": 1,
|
||||
}
|
||||
|
||||
created_finding1 = Finding.objects.get(uid="finding-cat-1")
|
||||
created_finding2 = Finding.objects.get(uid="finding-cat-2")
|
||||
created_finding1 = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-1"
|
||||
)
|
||||
created_finding2 = Finding.objects.get(
|
||||
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-2"
|
||||
)
|
||||
assert set(created_finding1.categories) == {"gen-ai", "security"}
|
||||
assert set(created_finding2.categories) == {"security", "iam"}
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -4674,7 +4674,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.35.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f5ea116763aeffede9f399c8934fc280eaccd315" }
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.35#1459046985908c099bd2b2e279530e8a47bccdf8" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.36.0"
|
||||
version = "1.36.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -4862,7 +4862,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.35" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values
|
||||
@@ -49,7 +49,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.35.0"
|
||||
prowler_version = "5.35.1"
|
||||
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"
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class ecs_securitygroup_restrict_rdp_internet(Check):
|
||||
|
||||
for ingress_rule in security_group.ingress_rules:
|
||||
# Check if rule allows traffic (policy == "accept")
|
||||
if ingress_rule.get("policy", "accept") != "accept":
|
||||
if str(ingress_rule.get("policy", "accept")).lower() != "accept":
|
||||
continue
|
||||
|
||||
# Check protocol (tcp for RDP)
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class ecs_securitygroup_restrict_ssh_internet(Check):
|
||||
|
||||
for ingress_rule in security_group.ingress_rules:
|
||||
# Check if rule allows traffic (policy == "accept")
|
||||
if ingress_rule.get("policy", "accept") != "accept":
|
||||
if str(ingress_rule.get("policy", "accept")).lower() != "accept":
|
||||
continue
|
||||
|
||||
# Check protocol (tcp for SSH)
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.35.0"
|
||||
version = "5.35.1"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
+2
-2
@@ -37,7 +37,7 @@ class TestEcsSecurityGroupRestrictRdpInternet:
|
||||
"ip_protocol": "tcp",
|
||||
"source_cidr_ip": "0.0.0.0/0",
|
||||
"port_range": "3389/3389",
|
||||
"policy": "accept",
|
||||
"policy": "Accept",
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -80,7 +80,7 @@ class TestEcsSecurityGroupRestrictRdpInternet:
|
||||
"ip_protocol": "tcp",
|
||||
"source_cidr_ip": "10.0.0.0/24",
|
||||
"port_range": "3389/3389",
|
||||
"policy": "accept",
|
||||
"policy": "Accept",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
+2
-2
@@ -37,7 +37,7 @@ class TestEcsSecurityGroupRestrictSSHInternet:
|
||||
"ip_protocol": "tcp",
|
||||
"source_cidr_ip": "0.0.0.0/0",
|
||||
"port_range": "22/22",
|
||||
"policy": "accept",
|
||||
"policy": "Accept",
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -81,7 +81,7 @@ class TestEcsSecurityGroupRestrictSSHInternet:
|
||||
"ip_protocol": "tcp",
|
||||
"source_cidr_ip": "10.0.0.0/24",
|
||||
"port_range": "22/22",
|
||||
"policy": "accept",
|
||||
"policy": "Accept",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
# Patch Alpine OpenSSL runtime packages before all stages inherit the base image.
|
||||
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable
|
||||
# The build uses pnpm via corepack, so npm is unused — remove it (and npx) to drop
|
||||
# the bundled-npm CVE surface (node-tar CVE-2026-59873) from every stage, incl. prod.
|
||||
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments
|
||||
@@ -0,0 +1 @@
|
||||
Sidebar logo top spacing in the main app sidebar
|
||||
@@ -0,0 +1 @@
|
||||
Removed the unused `npm` CLI from the UI container image, eliminating the bundled `node-tar` `CVE-2026-59873` (and future bundled-npm CVEs); the image builds with `pnpm` via `corepack` and does not use `npm`
|
||||
@@ -0,0 +1 @@
|
||||
Removed leftover Git merge-conflict markers accidentally committed to `ui/package.json` and `ui/pnpm-lock.yaml` during the vitest 4.1.10 backport, restoring valid manifests
|
||||
@@ -0,0 +1 @@
|
||||
Bumped `vitest` and `@vitest/browser`, `@vitest/browser-playwright`, `@vitest/coverage-v8` from `4.1.8` to `4.1.10`, resolving the critical `@vitest/browser` Browser Mode file-access permission bypass (`GHSA-p63j-vcc4-9vmv`) flagged by `pnpm audit`; dev dependencies only, no runtime impact
|
||||
@@ -24,15 +24,20 @@ interface AppSidebarContentProps {
|
||||
export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useAuth();
|
||||
const { apiDocsUrl } = useRuntimeConfig();
|
||||
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
|
||||
const mode = useAppSidebarMode((state) => state.mode);
|
||||
const isCloudEnvironment = isCloud();
|
||||
const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions });
|
||||
const sections = getNavigationConfig({
|
||||
pathname,
|
||||
apiDocsUrl,
|
||||
cloudBillingEnabled,
|
||||
permissions,
|
||||
});
|
||||
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 px-5 pt-6 pb-7">
|
||||
<div className="shrink-0 px-5 pt-8 pb-7">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Prowler home"
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("getNavigationConfig", () => {
|
||||
const billing = getNavigationConfig({
|
||||
pathname: "/billing",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: true,
|
||||
permissions,
|
||||
})
|
||||
.flatMap((section) => section.items)
|
||||
@@ -183,17 +184,28 @@ describe("getNavigationConfig", () => {
|
||||
const cloudItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: true,
|
||||
permissions,
|
||||
}).flatMap((section) => section.items);
|
||||
const enterpriseItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: false,
|
||||
permissions: { ...permissions, manage_billing: true },
|
||||
}).flatMap((section) => section.items);
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
const localItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
cloudBillingEnabled: true,
|
||||
permissions: { ...permissions, manage_billing: true },
|
||||
}).flatMap((section) => section.items);
|
||||
|
||||
// Then
|
||||
expect(cloudItems.find((item) => item.label === "Billing")).toBeUndefined();
|
||||
expect(
|
||||
enterpriseItems.find((item) => item.label === "Billing"),
|
||||
).toBeUndefined();
|
||||
expect(localItems.find((item) => item.label === "Billing")).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
interface NavigationConfigOptions {
|
||||
pathname: string;
|
||||
apiDocsUrl?: string | null;
|
||||
cloudBillingEnabled?: boolean;
|
||||
permissions?: RolePermissionAttributes;
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ export function filterNavigationByPermissions(
|
||||
export function getNavigationConfig({
|
||||
pathname,
|
||||
apiDocsUrl = null,
|
||||
cloudBillingEnabled = false,
|
||||
permissions,
|
||||
}: NavigationConfigOptions): NavigationSection[] {
|
||||
const isCloudEnvironment = isCloud();
|
||||
@@ -265,7 +267,7 @@ export function getNavigationConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isCloudEnvironment
|
||||
...(isCloudEnvironment && cloudBillingEnabled
|
||||
? [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
|
||||
+4
-4
@@ -135,9 +135,9 @@
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/browser": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/browser": "4.1.10",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"dotenv": "16.6.1",
|
||||
"dotenv-expand": "12.0.3",
|
||||
@@ -160,7 +160,7 @@
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "4.1.8",
|
||||
"vitest": "4.1.10",
|
||||
"vitest-browser-react": "2.0.4"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
|
||||
|
||||
Generated
+87
-91
@@ -338,14 +338,14 @@ importers:
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@vitest/browser':
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)
|
||||
babel-plugin-react-compiler:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
@@ -413,11 +413,11 @@ importers:
|
||||
specifier: 5.5.4
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
vitest-browser-react:
|
||||
specifier: 2.0.4
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8)
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -3578,31 +3578,31 @@ packages:
|
||||
peerDependencies:
|
||||
vite: 7.3.5
|
||||
|
||||
'@vitest/browser-playwright@4.1.8':
|
||||
resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
|
||||
'@vitest/browser-playwright@4.1.10':
|
||||
resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==}
|
||||
peerDependencies:
|
||||
playwright: '*'
|
||||
vitest: 4.1.8
|
||||
vitest: 4.1.10
|
||||
|
||||
'@vitest/browser@4.1.8':
|
||||
resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
|
||||
'@vitest/browser@4.1.10':
|
||||
resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==}
|
||||
peerDependencies:
|
||||
vitest: 4.1.8
|
||||
vitest: 4.1.10
|
||||
|
||||
'@vitest/coverage-v8@4.1.8':
|
||||
resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
|
||||
'@vitest/coverage-v8@4.1.10':
|
||||
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.8
|
||||
vitest: 4.1.8
|
||||
'@vitest/browser': 4.1.10
|
||||
vitest: 4.1.10
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.8':
|
||||
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
|
||||
'@vitest/expect@4.1.10':
|
||||
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
|
||||
|
||||
'@vitest/mocker@4.1.8':
|
||||
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
|
||||
'@vitest/mocker@4.1.10':
|
||||
resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: 7.3.5
|
||||
@@ -3612,20 +3612,20 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
|
||||
|
||||
'@vitest/runner@4.1.8':
|
||||
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
|
||||
'@vitest/runner@4.1.10':
|
||||
resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
|
||||
|
||||
'@vitest/snapshot@4.1.8':
|
||||
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
|
||||
'@vitest/snapshot@4.1.10':
|
||||
resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
|
||||
|
||||
'@vitest/spy@4.1.8':
|
||||
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
|
||||
'@vitest/spy@4.1.10':
|
||||
resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
|
||||
|
||||
'@vitest/utils@4.1.8':
|
||||
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
||||
'@vitest/utils@4.1.10':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
@@ -6949,20 +6949,20 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
vitest@4.1.8:
|
||||
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||
vitest@4.1.10:
|
||||
resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.8
|
||||
'@vitest/browser-preview': 4.1.8
|
||||
'@vitest/browser-webdriverio': 4.1.8
|
||||
'@vitest/coverage-istanbul': 4.1.8
|
||||
'@vitest/coverage-v8': 4.1.8
|
||||
'@vitest/ui': 4.1.8
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
'@vitest/browser-preview': 4.1.10
|
||||
'@vitest/browser-webdriverio': 4.1.10
|
||||
'@vitest/coverage-istanbul': 4.1.10
|
||||
'@vitest/coverage-v8': 4.1.10
|
||||
'@vitest/ui': 4.1.10
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: 7.3.5
|
||||
@@ -9583,10 +9583,10 @@ snapshots:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.59.0)
|
||||
commondir: 1.0.1
|
||||
estree-walker: 2.0.2
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
fdir: 6.5.0(picomatch@4.0.5)
|
||||
is-reference: 1.2.1
|
||||
magic-string: 0.30.21
|
||||
picomatch: 4.0.4
|
||||
picomatch: 4.0.5
|
||||
optionalDependencies:
|
||||
rollup: 4.59.0
|
||||
|
||||
@@ -9594,7 +9594,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
estree-walker: 2.0.2
|
||||
picomatch: 4.0.4
|
||||
picomatch: 4.0.5
|
||||
optionalDependencies:
|
||||
rollup: 4.59.0
|
||||
|
||||
@@ -10538,7 +10538,7 @@ snapshots:
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.7
|
||||
semver: 7.8.0
|
||||
tinyglobby: 0.2.16
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.4.0(typescript@5.5.4)
|
||||
typescript: 5.5.4
|
||||
transitivePeerDependencies:
|
||||
@@ -10608,29 +10608,29 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
'@vitest/browser-playwright@4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@vitest/browser': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
|
||||
'@vitest/mocker': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
playwright: 1.56.1
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
'@vitest/browser@4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@blazediff/core': 1.9.1
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.1.8
|
||||
'@vitest/mocker': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.1.10
|
||||
magic-string: 0.30.21
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -10638,10 +10638,10 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
|
||||
'@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.8
|
||||
'@vitest/utils': 4.1.10
|
||||
ast-v8-to-istanbul: 1.0.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
@@ -10650,49 +10650,49 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/browser': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
|
||||
|
||||
'@vitest/expect@4.1.8':
|
||||
'@vitest/expect@4.1.10':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
|
||||
vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.8':
|
||||
'@vitest/runner@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.8
|
||||
'@vitest/utils': 4.1.10
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.8':
|
||||
'@vitest/snapshot@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.8': {}
|
||||
'@vitest/spy@4.1.10': {}
|
||||
|
||||
'@vitest/utils@4.1.8':
|
||||
'@vitest/utils@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
@@ -11931,10 +11931,6 @@ snapshots:
|
||||
dependencies:
|
||||
walk-up-path: 4.0.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.5):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
@@ -14397,8 +14393,8 @@ snapshots:
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
fdir: 6.5.0(picomatch@4.0.5)
|
||||
picomatch: 4.0.5
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
@@ -14645,42 +14641,42 @@ snapshots:
|
||||
terser: 5.49.0
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8):
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
|
||||
vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)):
|
||||
vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.8
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/runner': 4.1.8
|
||||
'@vitest/snapshot': 4.1.8
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
es-module-lexer: 2.3.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
picomatch: 4.0.5
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.16
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/node': 24.10.8
|
||||
'@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
'@vitest/browser-playwright': 4.1.10(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.5(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.49.0)(yaml@2.9.0))(vitest@4.1.10)
|
||||
'@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)
|
||||
jsdom: 27.4.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
+10
-4
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import type { NextAuthRequest } from "next-auth";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { readEnv } from "@/lib/runtime-env";
|
||||
|
||||
const publicRoutes = [
|
||||
"/sign-in",
|
||||
@@ -23,6 +24,8 @@ export default auth((req: NextAuthRequest) => {
|
||||
|
||||
const user = req.auth?.user;
|
||||
const sessionError = req.auth?.error;
|
||||
const cloudBillingEnabled =
|
||||
(readEnv("CLOUD_BILLING_ENABLED") ?? "false") !== "false";
|
||||
|
||||
// If there's a session error (e.g., RefreshAccessTokenError), redirect to login with error info
|
||||
if (sessionError && !isPublicRoute(pathname)) {
|
||||
@@ -38,13 +41,16 @@ export default auth((req: NextAuthRequest) => {
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/billing") &&
|
||||
(!cloudBillingEnabled || user?.permissions?.manage_billing !== true)
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/profile", req.url));
|
||||
}
|
||||
|
||||
if (user?.permissions) {
|
||||
const permissions = user.permissions;
|
||||
|
||||
if (pathname.startsWith("/billing") && !permissions.manage_billing) {
|
||||
return NextResponse.redirect(new URL("/profile", req.url));
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/integrations") &&
|
||||
!permissions.manage_integrations
|
||||
|
||||
Reference in New Issue
Block a user