Compare commits

...
Author SHA1 Message Date
renovate[bot] 79efa1dbb2 chore(docker): update docker 2026-10-03 23:18:07 +00:00
Alejandro Bailo 383a9bf903 fix(ui): bump Next.js to 16.3.6 to patch the next/og RCE advisory (#12922) 2026-10-01 11:54:44 +02:00
Alejandro Bailo 4605d9a770 feat(ui): invite a teammate from the AWS connect step (#12917) 2026-10-01 09:50:10 +02:00
César Arroba f0da33f451 revert(api): release providers blocked by scans whose worker died (#12915) 2026-09-30 13:00:50 +02:00
Alejandro Bailo a44a725507 fix(ui): retry the first-run redirect until the add-provider wizard opens (#12914) 2026-09-30 12:34:52 +02:00
César Arroba b8ca30400b fix(api): stop sending personal data to Sentry (#12912) 2026-09-30 12:28:42 +02:00
César Arroba a006525e78 fix(api): release providers blocked by scans whose worker died (#12899) 2026-09-30 11:09:21 +02:00
Pedro Martín ed510e217d chore(deps): bump pyjwt to 2.14.0 for osv-scanner (#12911) 2026-09-30 10:21:11 +02:00
Alejandro Bailo 04511f339e test(ui): stabilize attack-paths refit integration test (#12896) 2026-09-29 18:42:19 +02:00
Pedro Martín f418b32c81 fix(oci): use home region for identity bootstrap (#12865) 2026-09-29 17:50:54 +02:00
Pedro Martín 65fb146e76 chore(trivy): suppress fast-uri CVE-2026-84292 (#12907) 2026-09-29 17:04:46 +02:00
Prowler Botandprowler-bot 5ea363d582 chore(release): Bump versions to v5.45.0 (#12905)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 16:31:16 +02:00
Prowler Botandprowler-bot 3ec379a75a chore(changelog): v5.44.0 (#12900)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 13:32:41 +02:00
Pedro Martín ea020ed46e chore(changelog): v5.44.0 highlights (#12897) 2026-09-29 13:32:13 +02:00
Alejandro Bailo 60b936005c test(ui): scope E2E delete dialog and scans table locators (#12898) 2026-09-29 12:32:55 +02:00
Pedro Martín 03502c2426 fix(api): require operation permission to revoke tasks (#12893) 2026-09-28 17:15:39 +02:00
Alejandro Bailo e6320b178a fix(ui): bundle all icons so the UI renders without internet access (#12892) 2026-09-28 15:58:32 +02:00
Alejandro Bailo 4195a4f818 test(ui): add AWS provider in one step in the E2E helper (#12895) 2026-09-28 15:38:47 +02:00
César Arroba 8d003c60d0 fix(api): skip unconfigured attack paths sinks on provider deletion (#12894)
Provider deletion now skips attack path graph cleanup for a sink whose connection settings have already been removed, instead of failing. The skip is logged as a warning, while the configured active sink still raises on error as before.
2026-09-28 14:33:44 +02:00
Alejandro Bailo d5136f364c perf(ui): stream the findings page and load the Finding Group filter on open (#12891) 2026-09-28 12:21:13 +02:00
Rubén De la Torre Vico 453c953f37 fix(api): avoid field-named annotation in attack surface aggregation (#12889) 2026-09-28 11:39:36 +02:00
César Arroba c114aa304b fix(api): stop locking the API key row on every authenticated request (#12882) 2026-09-28 11:16:13 +02:00
202 changed files with 4559 additions and 930 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.44.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.45.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -76,7 +76,7 @@ jobs:
### Changes
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`).
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
+5 -1
View File
@@ -68,7 +68,7 @@ vulnerabilities:
expired_at: 2026-11-30
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
# (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
# SBOM and reports what it declares, which is not the same as what the image contains:
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
@@ -129,6 +129,10 @@ vulnerabilities:
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-84292
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-69192
purls:
- "pkg:npm/ip-address"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
FROM python:3.14.7-slim-trixie@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
+27
View File
@@ -4,6 +4,33 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.45.0] (Prowler v5.44.0)
### 🚀 Added
- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871)
### 🔄 Changed
- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
### 🐞 Fixed
- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465)
- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746)
- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832)
- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878)
- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882)
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894)
### 🔐 Security
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893)
---
## [1.44.0] (Prowler v5.43.0)
### 🐞 Fixed
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
FROM python:3.14.7-slim-trixie@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
@@ -1 +0,0 @@
Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded
@@ -1 +0,0 @@
Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup
@@ -1 +0,0 @@
Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider
@@ -1 +0,0 @@
Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans
@@ -0,0 +1 @@
OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1
+1
View File
@@ -0,0 +1 @@
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
@@ -1 +0,0 @@
Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls
@@ -1 +0,0 @@
Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region
@@ -1 +0,0 @@
`POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit
@@ -0,0 +1 @@
Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials
@@ -1 +0,0 @@
Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes.
+5 -5
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.45.0"
version = "1.46.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -375,7 +375,7 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
"pyjwt==2.13.0",
"pyjwt==2.14.0",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
@@ -476,8 +476,8 @@ constraint-dependencies = [
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
#
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.14.0
# patches GHSA-ffc3-869f-jxw9 (HMAC/PEM key-confusion); a constraint cannot satisfy these
# against the SDK's hard pins, so override them to the patched versions until the SDK
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
@@ -500,5 +500,5 @@ override-dependencies = [
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"dulwich==1.2.5",
"pyjwt[crypto]==2.13.0"
"pyjwt[crypto]==2.14.0"
]
+45 -33
View File
@@ -1,4 +1,5 @@
import logging
from datetime import timedelta
from math import isfinite
from uuid import UUID
@@ -6,7 +7,7 @@ from api.db_router import MainRouter
from api.models import TenantAPIKey, TenantAPIKeyManager
from cryptography.fernet import InvalidToken
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
@@ -18,12 +19,15 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger(__name__)
# Writing on every request makes all requests of a busy key contend on one row
API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60
class OrphanedAPIKeyError(Exception):
"""Raised when an API key outlived the user that owns it.
Handled by `authenticate`, which commits the revocation written while detecting it
and then rejects the request with `AuthenticationFailed`.
The revocation is written by a plain `update()` before this is raised, so it is
already persisted by the time `authenticate` catches it and rejects the request.
"""
@@ -37,8 +41,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
"""
Override to use admin connection, bypassing RLS during authentication.
Returns the validated API key row, locked with `select_for_update`, so callers
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
Returns the validated API key row from a single read. `authenticate` builds
the auth claims from that same row instead of looking it up again, so a key
revoked or orphaned right after validation can't still authenticate.
"""
try:
payload = self.key_crypto.decrypt(key)
@@ -67,9 +72,11 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
raise AuthenticationFailed("API Key has already expired.")
try:
# Loading `entity` in the same query keeps a user deleted after this read
# from turning the later `api_key.entity` access into a 500
api_key = (
self.model.objects.using(MainRouter.admin_db)
.select_for_update()
.select_related("entity")
.get(id=api_key_pk)
)
except ObjectDoesNotExist:
@@ -85,8 +92,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
# Revoke it as well, so it stops showing up as active and later attempts fail
# the `revoked` check above like any other revoked key.
if api_key.entity_id is None:
api_key.revoked = True
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
self.model.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).update(revoked=True)
logger.warning(
"Revoked orphaned API key: prefix=%s tenant=%s",
api_key.prefix,
@@ -112,34 +120,38 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
# Validation, the `last_used_at` update and the auth claims all read the same
# row, locked until the transaction ends. Looking the key up a second time to
# build the claims used to leave a window where a key revoked or orphaned right
# after passing validation still authenticated.
with transaction.atomic(using=MainRouter.admin_db):
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
# Rejected below instead of here: leaving the block normally commits
# the revocation `_authenticate_credentials` wrote, while raising from
# inside would roll it back.
pass
else:
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
raise AuthenticationFailed("No entity matching this api key.")
api_key.last_used_at = timezone.now()
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
self._throttled_touch_last_used_at(api_key)
raise AuthenticationFailed("No entity matching this api key.")
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
@staticmethod
def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None:
"""Write `last_used_at` at most once per throttle interval, without locking the row."""
now = timezone.now()
stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS)
if api_key.last_used_at is not None and api_key.last_used_at >= stale_before:
return
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).filter(
Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
).update(last_used_at=now)
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
+66 -11
View File
@@ -1,7 +1,7 @@
from enum import Enum
from api.db_router import MainRouter
from api.models import Integration, Provider, Role, User
from api.models import Integration, Provider, Role, Task, User
from django.db.models import Q, QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
@@ -17,6 +17,50 @@ class Permissions(Enum):
UNLIMITED_VISIBILITY = "unlimited_visibility"
# Revoking a task needs the permission of the operation that queued it.
# None and unmapped names are not revocable; a revoked provider deletion
# would leave the provider soft-deleted with nothing re-queuing the cleanup.
TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = {
"provider-connection-check": [Permissions.MANAGE_PROVIDERS],
"provider-deletion": None,
"integration-connection-check": [Permissions.MANAGE_INTEGRATIONS],
"integration-s3": [Permissions.MANAGE_INTEGRATIONS],
"integration-security-hub": [Permissions.MANAGE_INTEGRATIONS],
"integration-jira": [Permissions.MANAGE_INTEGRATIONS],
"scan-perform": [Permissions.MANAGE_SCANS],
"scan-perform-scheduled": [Permissions.MANAGE_SCANS],
"scan-compliance-overviews": [Permissions.MANAGE_SCANS],
"scan-compliance-reports": [Permissions.MANAGE_SCANS],
"scan-finding-group-summaries": [Permissions.MANAGE_SCANS],
"scan-report": [Permissions.MANAGE_SCANS],
"attack-paths-scan-perform": [Permissions.MANAGE_SCANS],
"findings-mute-latest-scans": [Permissions.MANAGE_SCANS],
"lighthouse-connection-check": [],
"lighthouse-provider-connection-check": [],
"lighthouse-provider-models-refresh": [],
}
def get_user_roles(user: User, tenant_id: str) -> list[Role]:
"""Return every role assigned to the user in the tenant."""
return list(
User.objects.using(MainRouter.admin_db)
.get(id=user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
def roles_have_permissions(
roles: list[Role], required_permissions: list[Permissions]
) -> bool:
"""Return True when every required permission is granted by at least one role."""
return all(
any(getattr(role, permission.value, False) for role in roles)
for permission in required_permissions
)
class HasPermissions(BasePermission):
"""
Custom permission to check if the user's role has the required permissions.
@@ -34,19 +78,11 @@ class HasPermissions(BasePermission):
if not tenant_id:
return False
user_roles = list(
User.objects.using(MainRouter.admin_db)
.get(id=request.user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
user_roles = get_user_roles(request.user, tenant_id)
if not user_roles:
return False
return all(
any(getattr(role, permission.value, False) for role in user_roles)
for permission in required_permissions
)
return roles_have_permissions(user_roles, required_permissions)
def get_role(user: User, tenant_id: str) -> Role:
@@ -85,6 +121,25 @@ def get_providers(role: Role) -> QuerySet[Provider]:
).distinct()
def get_tasks(role: Role) -> QuerySet[Task]:
"""Return the tasks visible to the role: tenant-wide ones and those of its providers."""
queryset = Task.objects.filter(tenant_id=role.tenant_id)
if role.unlimited_visibility:
return queryset
# Task has no provider FK, so match provider ids inside the stored kwargs.
# all_objects keeps a soft-deleted provider visible to its own groups, so the
# role that queued its deletion can still follow the task.
hidden = Q()
for provider_id in (
Provider.all_objects.filter(tenant_id=role.tenant_id)
.exclude(provider_groups__in=role.provider_groups.all())
.values_list("id", flat=True)
):
hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id))
return queryset.exclude(hidden) if hidden else queryset
def get_integrations(
role: Role, providers: QuerySet[Provider] | None = None
) -> QuerySet[Integration]:
+9 -4
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.45.0
version: 1.46.0
description: |-
Prowler API specification.
@@ -14823,7 +14823,9 @@ paths:
get:
operationId: api_v1_tasks_list
description: Retrieve a list of all tasks with options for filtering by name,
state, and other criteria.
state, and other criteria. Tasks that reference a provider are only returned
when the role can access it; tasks without a provider reference are returned
for every role.
summary: List all tasks
parameters:
- in: query
@@ -14922,7 +14924,8 @@ paths:
/api/v1/tasks/{id}:
get:
operationId: api_v1_tasks_retrieve
description: Fetch detailed information about a specific task by its ID.
description: Fetch detailed information about a specific task by its ID. Tasks
tied to a provider outside the visibility of the role are not found.
summary: Retrieve data from a specific task
parameters:
- in: query
@@ -14963,7 +14966,9 @@ paths:
delete:
operationId: api_v1_tasks_destroy
description: Try to revoke a task using its ID. Only tasks that are not yet
in progress can be revoked.
in progress can be revoked, and the caller needs the same permission as the
operation that queued the task (for example MANAGE_SCANS for a scan). Provider
deletions cannot be revoked.
summary: Revoke a task
parameters:
- in: path
@@ -1,9 +1,9 @@
import json
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS
from api.db_router import MainRouter
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
@@ -11,6 +11,7 @@ from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
from django.db.utils import ConnectionDoesNotExist
from django.urls import reverse
from drf_simple_apikey.crypto import get_crypto
from freezegun import freeze_time
from rest_framework.test import APIClient
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
@@ -527,7 +528,7 @@ class TestAPIKeyAuthentication:
def test_last_used_at_tracking(
self, create_test_user, tenants_fixture, api_keys_fixture
):
"""Verify last_used_at timestamp updates on each authentication."""
"""Verify last_used_at timestamp is set on first use and throttled after that."""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -536,7 +537,11 @@ class TestAPIKeyAuthentication:
# Use API key to authenticate
api_key_headers = get_api_key_header(api_key._raw_key)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert first_response.status_code == 200
# Reload from database and check last_used_at is set
@@ -544,17 +549,23 @@ class TestAPIKeyAuthentication:
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Use the same key again after a small delay
time.sleep(0.1)
# Using the same key again within the throttle interval does not rewrite it
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert second_response.status_code == 200
# Reload and verify last_used_at was updated
api_key.refresh_from_db()
second_used_at = api_key.last_used_at
assert second_used_at is not None
assert second_used_at > first_used_at
assert api_key.last_used_at == first_used_at
# Past the throttle interval, the next use refreshes it
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
third_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert third_response.status_code == 200
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
@pytest.mark.django_db
@@ -1441,6 +1452,7 @@ class TestAPIKeyRLSBypass:
The update to last_used_at during authentication must also use the
admin database since it occurs before RLS context is established.
Past the throttle interval, using the key again refreshes the timestamp.
"""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -1448,7 +1460,11 @@ class TestAPIKeyRLSBypass:
assert api_key.last_used_at is None
api_key_headers = get_api_key_header(api_key._raw_key)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert first_response.status_code == 200
@@ -1456,9 +1472,11 @@ class TestAPIKeyRLSBypass:
first_timestamp = api_key.last_used_at
assert first_timestamp is not None
time.sleep(0.1)
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
second_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert second_response.status_code == 200
api_key.refresh_from_db()
+137 -24
View File
@@ -5,16 +5,18 @@ from uuid import uuid4
import pytest
from api.authentication import (
API_KEY_LAST_USED_AT_THROTTLE_SECONDS,
OrphanedAPIKeyError,
SSEAuthentication,
TenantAPIKeyAuthentication,
)
from api.db_router import MainRouter
from api.models import TenantAPIKey
from api.models import TenantAPIKey, User
from django.db import connections
from django.db.models.query import QuerySet
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from freezegun import freeze_time
from rest_framework.exceptions import AuthenticationFailed
@@ -286,14 +288,15 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
def test_authenticate_reads_the_api_key_once_without_a_row_lock(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the API key is read a single time and the row is locked.
"""Test the API key is read a single time and no row is locked.
Validation, the `last_used_at` update and the claims must all come from the
same authoritative row: a second, unlocked lookup would reopen the window
where a key revoked in between still authenticates.
same authoritative row: a second lookup would reopen the window where a key
revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized
every request for a hot key onto one locked row and is not used any more.
"""
api_key = api_keys_fixture[0]
@@ -310,33 +313,40 @@ class TestTenantAPIKeyAuthentication:
]
assert len(api_key_selects) == 1
assert "FOR UPDATE" in api_key_selects[0]
assert "FOR UPDATE" not in api_key_selects[0]
def test_authenticate_ignores_revocation_after_the_locked_read(
def test_authenticate_ignores_revocation_after_the_single_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the claims describe the row that was validated, not a later state.
Regression test: the key used to be looked up again to build the auth dict,
without rechecking `revoked` or `entity`. A key revoked or orphaned between
both reads still authenticated, and the claims came from that stale row. With
a single locked read the write below cannot land mid-authentication, and the
revocation only takes effect on the next request.
both reads still authenticated, and the claims came from that stale row.
There is now only a single read, so this race is closed by construction and
the revocation only takes effect on the next request.
"""
api_key = api_keys_fixture[0]
entity_at_validation = api_key.entity
original_save = TenantAPIKey.save
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
# Runs after validation, right before the claims are built: the exact
# window a concurrent revocation or user deletion used to slip into
def revoke_and_orphan_after_reading(self, request, key):
# Runs right after the single read `authenticate` will use to build the
# claims: the exact window a concurrent revocation used to slip into
result = original_authenticate_credentials(self, request, key)
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
return original_save(instance, *args, **kwargs)
return result
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
revoke_and_orphan_after_reading,
):
entity, auth_dict = auth_backend.authenticate(request)
assert entity == entity_at_validation
@@ -350,6 +360,43 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_survives_owner_deleted_after_the_single_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test a user deleted right after the read does not turn into a 500.
Without the row lock a concurrent user deletion can land between the read
and building the claims. `entity` is loaded by the same query, so no later
lookup can raise `DoesNotExist`.
"""
api_key = api_keys_fixture[0]
owner_id = api_key.entity_id
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
def delete_owner_after_reading(self, request, key):
result = original_authenticate_credentials(self, request, key)
User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete()
return result
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
delete_owner_after_reading,
):
entity, auth_dict = auth_backend.authenticate(request)
assert auth_dict["sub"] == str(owner_id)
assert entity.id == owner_id
# From the next request on, the orphaned key is rejected with a 401
with pytest.raises(AuthenticationFailed):
auth_backend.authenticate(request)
def test_authenticate_expired_api_key(
self, auth_backend, create_test_user, tenants_fixture, request_factory
):
@@ -421,24 +468,90 @@ class TestTenantAPIKeyAuthentication:
if original_last_used:
assert api_key.last_used_at > original_last_used
def test_authenticate_saves_to_admin_database(
def test_authenticate_updates_last_used_at_on_admin_database(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that the API key save operation uses admin database."""
"""Test that the `last_used_at` update runs against the admin database."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
# Mock the save method to verify it's called with using='admin'
with patch.object(TenantAPIKey, "save") as mock_save:
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
# Verify save was called with using=admin_db
mock_save.assert_called_once_with(
update_fields=["last_used_at"], using=MainRouter.admin_db
)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
assert "last_used_at" in api_key_updates[0]
def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that a second authentication within the throttle interval is a no-op write."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
# First call sets last_used_at
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Second call, still within the throttle interval, must issue no UPDATE
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert api_key_updates == []
api_key.refresh_from_db()
assert api_key.last_used_at == first_used_at
def test_authenticate_rewrites_last_used_at_after_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that `last_used_at` is refreshed once it is older than the throttle interval."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
start = datetime.now(UTC)
with freeze_time(start):
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
def test_authenticate_returns_correct_auth_dict(
self, auth_backend, api_keys_fixture, request_factory
+15
View File
@@ -30,6 +30,21 @@ def test_initialize_sentry_uses_configured_dsn():
assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send
def test_initialize_sentry_sends_no_personal_data():
with (
patch.object(
sentry_settings.env,
"str",
return_value="https://fake-public-key@sentry.example.invalid/1",
),
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
):
sentry_settings.initialize_sentry()
assert mock_init.call_args.kwargs["send_default_pii"] is False
assert mock_init.call_args.kwargs["max_request_body_size"] == "never"
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
"""Build a real LogRecord so getMessage() works like in production."""
record = logging.LogRecord(
+21 -23
View File
@@ -215,36 +215,34 @@ class TestOracleCloudProviderSecret:
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
def test_accepts_and_ignores_region_field(self):
secret = self.valid_secret(region="us-phoenix-1")
serializer = OracleCloudProviderSecret(data=secret)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
@pytest.mark.parametrize(
"legacy_field, legacy_value",
[
("region", None),
("region", ""),
("region", {"name": "us-ashburn-1"}),
],
)
def test_accepts_and_ignores_any_legacy_region_value(
self, legacy_field, legacy_value
):
def test_keeps_region_as_home_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(**{legacy_field: legacy_value})
data=self.valid_secret(region=" me-abudhabi-1 ")
)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["region"] == "me-abudhabi-1"
assert legacy_field not in serializer.validated_data
def test_rejects_unknown_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region="mars-north-1")
)
assert not serializer.is_valid()
assert "region" in serializer.errors
@pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}])
def test_drops_blank_or_non_string_region(self, legacy_value):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region=legacy_value)
)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
class TestProviderSecretFieldSchema:
def test_oraclecloud_schema_includes_legacy_region_field(self):
def test_oraclecloud_schema_region_is_not_deprecated(self):
schema = ProviderSecretField._spectacular_annotation["field"]
oraclecloud_schema = next(
credential_schema
@@ -253,7 +251,7 @@ class TestProviderSecretFieldSchema:
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
)
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
assert "deprecated" not in oraclecloud_schema["properties"]["region"]
class TestKubernetesProviderSecret:
+34 -8
View File
@@ -172,7 +172,7 @@ class TestInitializeProwlerProvider:
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_removes_region_string(
def test_initialize_oraclecloud_provider_passes_region_as_home_region(
self, mock_return_prowler_provider
):
provider = MagicMock()
@@ -182,7 +182,7 @@ class TestInitializeProwlerProvider:
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
"region": "us-ashburn-1",
"region": "me-abudhabi-1",
}
mock_return_prowler_provider.return_value = MagicMock()
@@ -193,6 +193,7 @@ class TestInitializeProwlerProvider:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
home_region="me-abudhabi-1",
)
@patch("api.utils.return_prowler_provider")
@@ -254,11 +255,35 @@ class TestProwlerProviderConnectionTest:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region=getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
),
region=OraclecloudProvider._bootstrap_region,
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_oraclecloud_connection_test_uses_stored_region_for_identity(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
"region": "me-abudhabi-1",
}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region="me-abudhabi-1",
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@@ -434,7 +459,7 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region(
self,
):
secret_dict = {
@@ -461,6 +486,7 @@ class TestGetProwlerProviderKwargs:
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..fake",
"pass_phrase": "fake-passphrase",
"home_region": "us-ashburn-1",
}
def test_get_prowler_provider_kwargs_with_mutelist(self):
+307 -6
View File
@@ -60,6 +60,7 @@ from api.models import (
User,
UserRoleRelationship,
)
from api.rbac.permissions import TASK_REVOKE_PERMISSIONS
from api.rls import Tenant
from api.uuid_utils import datetime_to_uuid7
from api.v1.views import (
@@ -3362,7 +3363,7 @@ current-context: test-context
provider_secret = ProviderSecret.objects.get()
assert "region" not in provider_secret.secret
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
def test_provider_secrets_create_oraclecloud_stores_region(
self,
authenticated_client,
oraclecloud_provider,
@@ -3371,14 +3372,14 @@ current-context: test-context
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(
key_content=" test-key-content ", region=" us-ashburn-1 "
key_content=" test-key-content ", region=" me-abudhabi-1 "
),
)
assert response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get()
assert provider_secret.secret["key_content"] == "test-key-content"
assert "region" not in provider_secret.secret
assert provider_secret.secret["region"] == "me-abudhabi-1"
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
self,
@@ -3411,7 +3412,7 @@ current-context: test-context
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
def test_provider_secrets_update_oraclecloud_stores_region(
self,
authenticated_client,
oraclecloud_provider,
@@ -3429,7 +3430,7 @@ current-context: test-context
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
"secret": self._oraclecloud_secret(region=" me-abudhabi-1 ")
},
}
}
@@ -3442,7 +3443,7 @@ current-context: test-context
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
assert provider_secret.secret["region"] == "me-abudhabi-1"
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
@@ -5239,6 +5240,7 @@ class TestTaskViewSet:
@patch("api.v1.views.AsyncResult", return_value=Mock())
def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture):
_, task2 = tasks_fixture
self._set_task_name(task2, "scan-perform")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": task2.id})
)
@@ -5254,12 +5256,311 @@ class TestTaskViewSet:
def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture):
task1, _ = tasks_fixture
self._set_task_name(task1, "scan-perform")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": task1.id})
)
# Task status is SUCCESS
assert response.status_code == status.HTTP_400_BAD_REQUEST
@staticmethod
def _set_task_name(task, name):
task.task_runner_task.task_name = name
task.task_runner_task.save(update_fields=["task_name"])
@staticmethod
def _set_task_kwargs(task, kwargs):
task.task_runner_task.task_kwargs = json.dumps(repr(kwargs))
task.task_runner_task.save(update_fields=["task_kwargs"])
@staticmethod
def _client_with_role(tenant, factory, **permissions):
user = User.objects.create_user(
name=f"revoker-{uuid4()}",
email=f"revoker-{uuid4()}@prowler.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER
)
flags = {
"manage_users": False,
"manage_account": False,
"manage_billing": False,
"manage_providers": False,
"manage_integrations": False,
"manage_scans": False,
"unlimited_visibility": True,
**permissions,
}
role = Role.objects.create(
name=f"revoker-{uuid4()}", tenant_id=tenant.id, **flags
)
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
return factory(user, tenant)
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_without_permission_is_forbidden(
self, mock_async_result, authenticated_client_no_permissions_rbac, tasks_fixture
):
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "provider-connection-check")
response = authenticated_client_no_permissions_rbac.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
@pytest.mark.parametrize(
"task_name, permissions, expected_status",
[
(
"provider-connection-check",
{"manage_providers": True},
status.HTTP_202_ACCEPTED,
),
(
"provider-connection-check",
{"manage_scans": True},
status.HTTP_403_FORBIDDEN,
),
("scan-perform", {"manage_scans": True}, status.HTTP_202_ACCEPTED),
(
"scan-perform-scheduled",
{"manage_providers": True},
status.HTTP_403_FORBIDDEN,
),
(
"integration-jira",
{"manage_integrations": True},
status.HTTP_202_ACCEPTED,
),
("integration-jira", {"manage_providers": True}, status.HTTP_403_FORBIDDEN),
("lighthouse-connection-check", {}, status.HTTP_202_ACCEPTED),
],
)
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_requires_originating_operation_permission(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
task_name,
permissions,
expected_status,
):
tenant, *_ = tenants_fixture
_, pending_task = tasks_fixture
self._set_task_name(pending_task, task_name)
client = self._client_with_role(
tenant, authenticated_client_for_tenant_factory, **permissions
)
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == expected_status
if expected_status == status.HTTP_202_ACCEPTED:
mock_async_result.return_value.revoke.assert_called_once()
else:
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_provider_deletion_is_forbidden_even_for_admin(
self, mock_async_result, authenticated_client, tasks_fixture
):
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "provider-deletion")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_unmapped_task_is_forbidden(
self, mock_async_result, authenticated_client, tasks_fixture
):
_, pending_task = tasks_fixture
assert pending_task.task_runner_task.task_name not in TASK_REVOKE_PERMISSIONS
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
def test_every_rls_task_has_revoke_permissions(self):
from config.celery import RLSTask, celery_app
rls_task_names = {
name for name, task in celery_app.tasks.items() if isinstance(task, RLSTask)
}
assert rls_task_names
assert rls_task_names <= set(TASK_REVOKE_PERMISSIONS)
@patch("api.v1.views.AsyncResult")
def test_tasks_hidden_for_providers_outside_role_visibility(
self,
mock_async_result,
authenticated_client_no_permissions_rbac,
tasks_fixture,
aws_provider_pair,
):
client = authenticated_client_no_permissions_rbac
limited_user = client.user
tenant = Membership.objects.filter(user=limited_user).first().tenant
allowed_provider, denied_provider = aws_provider_pair
allowed_task, denied_task = tasks_fixture
self._set_task_kwargs(
allowed_task,
{"tenant_id": str(tenant.id), "provider_id": str(allowed_provider.id)},
)
self._set_task_name(denied_task, "provider-deletion")
self._set_task_kwargs(
denied_task,
{"tenant_id": str(tenant.id), "provider_id": str(denied_provider.id)},
)
provider_group = ProviderGroup.objects.create(
name="limited-task-group", tenant_id=tenant.id
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id,
provider_group=provider_group,
provider=allowed_provider,
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id,
role=limited_user.roles.first(),
provider_group=provider_group,
)
response = client.get(reverse("task-list"))
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [
str(allowed_task.id)
]
response = client.get(reverse("task-detail", kwargs={"pk": denied_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
response = client.delete(reverse("task-detail", kwargs={"pk": denied_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_of_soft_deleted_provider_stay_visible_to_its_groups(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
aws_provider_pair,
):
tenant, *_ = tenants_fixture
provider, _ = aws_provider_pair
finished_task, pending_task = tasks_fixture
client = self._client_with_role(
tenant,
authenticated_client_for_tenant_factory,
manage_providers=True,
unlimited_visibility=False,
)
provider_group = ProviderGroup.objects.create(
name="own-group", tenant_id=tenant.id
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id, provider_group=provider_group, provider=provider
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id,
role=client.user.roles.first(),
provider_group=provider_group,
)
for task, name in (
(finished_task, "provider-deletion"),
(pending_task, "provider-connection-check"),
):
self._set_task_name(task, name)
self._set_task_kwargs(
task, {"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
)
provider.is_deleted = True
provider.save()
response = client.get(reverse("task-detail", kwargs={"pk": finished_task.id}))
assert response.status_code == status.HTTP_200_OK
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == status.HTTP_202_ACCEPTED
mock_async_result.return_value.revoke.assert_called_once()
def test_tasks_without_provider_stay_visible_for_limited_roles(
self, authenticated_client_no_permissions_rbac, tasks_fixture, aws_provider_pair
):
response = authenticated_client_no_permissions_rbac.get(reverse("task-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(tasks_fixture)
def test_tasks_list_without_role_is_forbidden(
self, authenticated_client_rbac_noroles, tasks_fixture
):
response = authenticated_client_rbac_noroles.get(reverse("task-list"))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_tasks_revoke_without_permission_hides_task_status(
self, authenticated_client_no_permissions_rbac, tasks_fixture
):
finished_task, _ = tasks_fixture
self._set_task_name(finished_task, "provider-connection-check")
response = authenticated_client_no_permissions_rbac.delete(
reverse("task-detail", kwargs={"pk": finished_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_unauthenticated_returns_401(
self, mock_async_result, tasks_fixture
):
from rest_framework.test import APIClient
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "scan-perform")
response = APIClient().delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_foreign_tenant_task_returns_404(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
):
_, foreign_tenant, *_ = tenants_fixture
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "scan-perform")
client = self._client_with_role(
foreign_tenant, authenticated_client_for_tenant_factory, manage_scans=True
)
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_async_result.return_value.revoke.assert_not_called()
@pytest.mark.django_db
class TestAttackPathsScanViewSet:
+14 -7
View File
@@ -302,17 +302,26 @@ def get_prowler_provider_kwargs(
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into SDK provider kwargs."""
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
if home_region:
prowler_provider_kwargs["home_region"] = home_region
return prowler_provider_kwargs
def _oraclecloud_home_region(region) -> str | None:
"""Return the stored OCI region as a home region, ignoring blank or non-string legacy values."""
if isinstance(region, str) and region.strip():
return region.strip()
return None
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into test_connection kwargs."""
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
if (
prowler_provider_kwargs.get("user")
@@ -323,11 +332,9 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
or prowler_provider_kwargs.get("key_file")
)
):
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
prowler_provider_kwargs["region"] = getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
# Identity calls only succeed in a region the tenancy is subscribed to.
prowler_provider_kwargs["region"] = (
home_region or OraclecloudProvider._bootstrap_region
)
return prowler_provider_kwargs
@@ -301,8 +301,7 @@ from rest_framework_json_api import serializers
},
"region": {
"type": "string",
"deprecated": True,
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
"description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.",
},
},
"required": ["user", "fingerprint", "tenancy"],
+13 -4
View File
@@ -71,6 +71,7 @@ from django.db import IntegrityError, transaction
from drf_spectacular.utils import extend_schema_field
from jwt.exceptions import InvalidKeyError
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.providers.oraclecloud.config import OCI_REGIONS
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from rest_framework_json_api import serializers
@@ -1917,9 +1918,16 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class LegacyOCIRegionField(serializers.Field):
class OCIHomeRegionField(serializers.Field):
"""Optional OCI home region; blank or non-string legacy values are dropped."""
def to_internal_value(self, data):
return data
if not isinstance(data, str) or not data.strip():
return None
region = data.strip()
if region not in OCI_REGIONS:
raise serializers.ValidationError(f"Invalid OCI region: {region}")
return region
def to_representation(self, value):
return value
@@ -1932,10 +1940,11 @@ class OracleCloudProviderSecret(serializers.Serializer):
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
region = LegacyOCIRegionField(required=False, allow_null=True)
region = OCIHomeRegionField(required=False, allow_null=True)
def validate(self, attrs):
attrs.pop("region", None)
if not attrs.get("region"):
attrs.pop("region", None)
if "key_file" not in attrs and "key_content" not in attrs:
raise serializers.ValidationError(
+37 -8
View File
@@ -125,10 +125,14 @@ from api.models import (
)
from api.pagination import ComplianceOverviewPagination
from api.rbac.permissions import (
TASK_REVOKE_PERMISSIONS,
Permissions,
get_integrations,
get_providers,
get_role,
get_tasks,
get_user_roles,
roles_have_permissions,
)
from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
@@ -2858,17 +2862,29 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
list=extend_schema(
tags=["Task"],
summary="List all tasks",
description="Retrieve a list of all tasks with options for filtering by name, state, and other criteria.",
description=(
"Retrieve a list of all tasks with options for filtering by name, state, and other "
"criteria. Tasks that reference a provider are only returned when the role can "
"access it; tasks without a provider reference are returned for every role."
),
),
retrieve=extend_schema(
tags=["Task"],
summary="Retrieve data from a specific task",
description="Fetch detailed information about a specific task by its ID.",
description=(
"Fetch detailed information about a specific task by its ID. Tasks tied to a provider "
"outside the visibility of the role are not found."
),
),
destroy=extend_schema(
tags=["Task"],
summary="Revoke a task",
description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.",
description=(
"Try to revoke a task using its ID. Only tasks that are not yet in progress can be "
"revoked, and the caller needs the same permission as the operation that queued "
"the task (for example MANAGE_SCANS for a scan). Provider deletions cannot be "
"revoked."
),
responses={202: OpenApiResponse(response=TaskSerializer)},
),
)
@@ -2884,13 +2900,26 @@ class TaskViewSet(BaseRLSViewSet):
required_permissions = []
def get_queryset(self):
return Task.objects.annotate(
name=F("task_runner_task__task_name"),
state=F("task_runner_task__status"),
).select_related("task_runner_task")
return (
get_tasks(self.user_role)
.annotate(
name=F("task_runner_task__task_name"),
state=F("task_runner_task__status"),
)
.select_related("task_runner_task")
)
def destroy(self, request, *args, pk=None, **kwargs):
task = get_object_or_404(Task, pk=pk)
task = self.get_object()
required_permissions = TASK_REVOKE_PERMISSIONS.get(
task.task_runner_task.task_name
)
# Same multi-role semantics as HasPermissions.
if required_permissions is None or not roles_have_permissions(
get_user_roles(request.user, request.tenant_id), required_permissions
):
raise PermissionDenied("You do not have permission to revoke this task.")
if task.task_runner_task.status not in ["PENDING", "RECEIVED"]:
serializer = TaskSerializer(task)
return Response(
+3 -3
View File
@@ -193,10 +193,10 @@ def initialize_sentry():
sentry_sdk.init(
dsn=sentry_dsn,
# Add data like request headers and IP for users,
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
before_send=before_send,
send_default_pii=True,
# No user identity, IPs, cookies, headers or request bodies: bodies carry emails and provider details.
send_default_pii=False,
max_request_body_size="never",
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
_experiments={
# Set continuous_profiling_auto_start to True
+14 -3
View File
@@ -13,6 +13,7 @@ from api.models import (
Tenant,
)
from celery.utils.log import get_task_logger
from django.conf import settings
from django.db import DatabaseError
from tasks.jobs.queries import (
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
@@ -106,9 +107,19 @@ def delete_provider(tenant_id: str, pk: str):
try:
if attack_paths_sink_backends:
for sink_backend in attack_paths_sink_backends:
sink_module.get_backend_for_name(sink_backend).drop_subgraph(
tenant_database_name, str(pk)
)
try:
backend = sink_module.get_backend_for_name(sink_backend)
except RuntimeError as sink_error:
# A retired sink has no connection settings left, and no graph left to drop
if sink_backend == settings.ATTACK_PATHS_SINK_DATABASE.lower():
raise
logger.warning(
f"Skipping graph cleanup on unconfigured sink {sink_backend}: {sink_error}"
)
continue
backend.drop_subgraph(tenant_database_name, str(pk))
else:
graph_database.drop_subgraph(tenant_database_name, str(pk))
+4 -2
View File
@@ -2113,7 +2113,9 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
.annotate(
total=Count("id"),
failed=Count("id", filter=Q(status="FAIL", muted=False)),
muted=Count("id", filter=Q(status="FAIL", muted=True)),
# Not `muted`: an annotation named after a model field comes
# back as `muted_new` from the psqlextra queryset.
muted_count=Count("id", filter=Q(status="FAIL", muted=True)),
)
)
@@ -2124,7 +2126,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0
aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0
aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0
aggregated_counts[attack_surface_type]["muted"] += stats["muted_count"] or 0
overview_objects = []
for attack_surface_type, counts in aggregated_counts.items():
+56 -1
View File
@@ -4,6 +4,7 @@ import pytest
from api.attack_paths import database as graph_database
from api.models import Provider, Tenant, TenantComplianceSummary
from django.core.exceptions import ObjectDoesNotExist
from django.test import override_settings
from tasks.jobs.deletion import delete_provider, delete_tenant
@@ -123,6 +124,60 @@ class TestDeleteProvider:
"tenant-db", str(instance.id)
)
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
def test_delete_provider_skips_unconfigured_retired_sink(
self, aws_provider, create_attack_paths_scan
):
instance = aws_provider
tenant_id = str(instance.tenant_id)
create_attack_paths_scan(instance, sink_backend="neo4j")
create_attack_paths_scan(instance, sink_backend="neptune")
neo4j_backend = MagicMock()
def get_backend_for_name(name):
if name == "neptune":
raise RuntimeError("NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set")
return neo4j_backend
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
),
patch(
"tasks.jobs.deletion.sink_module.get_backend_for_name",
side_effect=get_backend_for_name,
),
patch("tasks.jobs.deletion.graph_database.drop_database"),
):
result = delete_provider(tenant_id, instance.id)
assert result
assert not Provider.all_objects.filter(pk=instance.id).exists()
neo4j_backend.drop_subgraph.assert_called_once_with(
"tenant-db", str(instance.id)
)
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
def test_delete_provider_raises_when_active_sink_unconfigured(
self, aws_provider, create_attack_paths_scan
):
instance = aws_provider
tenant_id = str(instance.tenant_id)
create_attack_paths_scan(instance, sink_backend="neo4j")
with (
patch(
"tasks.jobs.deletion.sink_module.get_backend_for_name",
side_effect=RuntimeError("NEO4J_HOST / NEO4J_PORT must be set"),
),
patch("tasks.jobs.deletion.graph_database.drop_database"),
pytest.raises(RuntimeError),
):
delete_provider(tenant_id, instance.id)
assert Provider.all_objects.filter(pk=instance.id).exists()
def test_delete_provider_continues_when_temp_db_drop_fails(
self, aws_provider, create_attack_paths_scan
):
@@ -149,10 +204,10 @@ class TestDeleteProvider:
assert result
assert not Provider.all_objects.filter(pk=instance.id).exists()
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_delete_provider_recalculates_tenant_compliance_summary(
self,
aws_provider_pair,
provider_compliance_scores_fixture,
):
instance = aws_provider_pair[0]
tenant_id = instance.tenant_id
+72 -5
View File
@@ -12,6 +12,7 @@ from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
AttackSurfaceOverview,
Finding,
MuteRule,
Provider,
@@ -5327,8 +5328,13 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0},
{
"check_id": "check_internet_1",
"total": 10,
"failed": 3,
"muted_count": 1,
},
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted_count": 0},
]
ctx = MagicMock()
@@ -5377,7 +5383,7 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0},
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted_count": 0},
]
ctx = MagicMock()
@@ -5460,8 +5466,13 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0},
{
"check_id": "check_internet_1",
"total": 10,
"failed": 3,
"muted_count": 1,
},
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted_count": 0},
]
ctx = MagicMock()
@@ -5482,6 +5493,62 @@ class TestAggregateAttackSurface:
assert overview.failed_findings == 5 # 3 + 2
assert overview.muted_failed_findings == 1 # 1 + 0
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
def test_aggregate_attack_surface_counts_real_findings(
self, mock_get_mapping, tenants_fixture, scans_fixture
):
"""Run the aggregation query against real Finding rows.
The other tests mock the queryset, so they never execute the real
`annotate`. This one guards the row keys the query returns."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
mock_get_mapping.return_value = {
"privilege-escalation": {"check_privesc_1"},
"secrets": {"check_secrets_1"},
}
def create_finding(uid, check_id, status, muted):
Finding.objects.create(
tenant_id=tenant.id,
uid=uid,
scan=scan,
status=status,
status_extended="status extended",
impact=Severity.high,
severity=Severity.high,
raw_result={"status": status},
check_id=check_id,
check_metadata={"CheckId": check_id},
muted=muted,
first_seen_at="2024-01-02T00:00:00Z",
)
create_finding("privesc_fail", "check_privesc_1", Status.FAIL, False)
create_finding("privesc_fail_2", "check_privesc_1", Status.FAIL, False)
create_finding("privesc_fail_muted", "check_privesc_1", Status.FAIL, True)
create_finding("privesc_pass", "check_privesc_1", Status.PASS, False)
create_finding("secrets_pass_muted", "check_secrets_1", Status.PASS, True)
create_finding("unmapped_fail", "check_unmapped", Status.FAIL, False)
aggregate_attack_surface(str(tenant.id), str(scan.id))
overviews = {
overview.attack_surface_type: overview
for overview in AttackSurfaceOverview.objects.filter(
tenant_id=tenant.id, scan_id=scan.id
)
}
assert set(overviews) == {"privilege-escalation", "secrets"}
assert overviews["privilege-escalation"].total_findings == 4
assert overviews["privilege-escalation"].failed_findings == 2
assert overviews["privilege-escalation"].muted_failed_findings == 1
assert overviews["secrets"].total_findings == 1
assert overviews["secrets"].failed_findings == 0
assert overviews["secrets"].muted_failed_findings == 0
@patch("tasks.jobs.scan.Scan.all_objects.select_related")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_uses_select_related(
Generated
+6 -6
View File
@@ -291,7 +291,7 @@ constraints = [
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
@@ -387,7 +387,7 @@ overrides = [
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.14.0" },
]
[[package]]
@@ -4938,7 +4938,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.45.0"
version = "1.46.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5332,11 +5332,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.13.0"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
+3 -3
View File
@@ -1,6 +1,6 @@
services:
api-dev-init:
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
image: busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
@@ -64,7 +64,7 @@ services:
condition: service_healthy
postgres:
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -88,7 +88,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
image: valkey/valkey:8-alpine@sha256:081c2f5cb575efc901aa80ff9cdbd1ec6a301682fd35e1ebb4b0990a4a4a8507
hostname: "valkey"
volumes:
- ./_data/valkey:/data
+3 -3
View File
@@ -6,7 +6,7 @@
#
services:
api-init:
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
image: busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
@@ -62,7 +62,7 @@ services:
start_period: 60s
postgres:
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
restart: unless-stopped
hostname: "postgres-db"
volumes:
@@ -83,7 +83,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
image: valkey/valkey:8-alpine@sha256:081c2f5cb575efc901aa80ff9cdbd1ec6a301682fd35e1ebb4b0990a4a4a8507
restart: unless-stopped
hostname: "valkey"
volumes:
+65
View File
@@ -4,6 +4,71 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.44.0" description="September 29, 2026">
### 🔁 Findings — Re-check a Resource with a Partial Scan
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
A resource that has just been fixed can be confirmed from the Findings page without waiting for the next full scan. **Re-check resource** is available in the actions menu of every resource row and in the resource detail drawer, and a hint icon next to **Last seen** opens it directly. It launches a partial scan that runs again only the checks that last reported on that resource; its findings update when the scan completes, and every other resource keeps the results of the latest full scan. Roles need the Manage Scans permission, and a re-check is refused while the provider has a scan running or queued.
Re-checked resources that now pass drop out of the finding groups list and its drill-down instead of opening a detail panel that still reports `FAIL`. Partial scans do not change overviews or compliance until the next full scan and produce no report files, so the Scans table marks them as **Partial**, offers no report download for them, and the per-scan Compliance selector leaves them out.
Partial scans can also be launched outside the Findings page:
- **API:** `POST /api/v1/scans` accepts up to 10 resources in `resource_uids`, and scans expose `is_partial` with a `filter[is_partial]` filter.
- **MCP Server:** `prowler_trigger_scan` takes a `resource_uids` argument, and `prowler_list_scans` and `prowler_get_scan` return `is_partial`, with an `is_partial` filter on `prowler_list_scans`.
- **Lighthouse AI:** can launch a partial scan to re-check specific resources, such as confirming a remediation.
### ☁️ AWS — Connect an Account in One Step
The Add Provider wizard connects an AWS account in a single step. The account ID is read from the role ARN (or typed when using static access keys), the role is assumed with Prowler's own credentials, and the account, its credentials and the connection test are handled by one submit. A confirmed connection goes straight to the launch step. A refused connection stays on the form with the reason the API returned, so the fields can be fixed and retried without registering the account twice.
New tenants without providers now land on this wizard on their first sign-in instead of a welcome modal, and the sidebar action reads **Add Provider** until the first provider is connected.
Read more in the [Getting Started with AWS documentation](https://docs.prowler.com/user-guide/providers/aws/getting-started-aws).
### 🔌 Connection Tests No Longer Give Up Early
The provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable. The UI now waits for the full time limit of the backend task, and if that is still exhausted it shows the provider's current connection state instead of a failure.
On the SDK side, STS calls after a role assumption reuse the region that answered, so an unreachable partition region is waited on once instead of twice. The new `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable sets the Boto3 retries for deployments that build the AWS provider without CLI flags, next to the existing timeout variables; `0` disables retries.
Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration#retries-configuration).
### 🗄️ Self-Hosted — S3-Compatible Storage and Air-Gapped Deployments
- `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL` points scan output uploads and downloads at S3-compatible object storage such as MinIO. Previously the only way to reach it was exporting process-wide AWS environment variables, which also hijacked unrelated AWS API calls such as role assumption for AWS providers.
- Report downloads from a bucket with default SSE-KMS encryption no longer fail with `InvalidArgument`: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, download URLs are signed with Signature Version 4 for that region.
- Icons ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly without internet access.
- Celery worker fatal errors are logged instead of silenced, and every long-running service in `docker-compose.yml` restarts automatically after an unexpected crash.
### 📊 Consistent Latest Scan Across Endpoints
Every endpoint now resolves a provider's latest completed scan the same way, so overlapping scans no longer make findings, compliance and mute rules read from different scans. Providers whose latest completed scan has no `completed_at` timestamp are no longer missing from those endpoints, and resources no longer keep a stale failed findings count when a scoped or imported scan completes after a full scan.
### 🛠️ Prowler App Fixes
- API key authentication no longer locks the key row on every request, so a heavily used key no longer serializes all its requests; `last_used_at` is updated at most once per minute.
- `POST /api/v1/scans` returns the new scan ID in `task_args` again.
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j.
- A periodic sweep drops orphaned Attack Paths temporary Neo4j databases left behind when a worker or Neo4j crashes mid-scan.
- **Prowler Cloud:** imported findings no longer stay stuck in `pending` when the ingestion worker picks up the job before it is committed, and a failed enqueue marks the ingestion as failed.
- **Prowler Cloud:** the Lighthouse AI connection check reports a network failure as one, naming the endpoint it could not reach, instead of hitting a time limit that looked the same as a bad key.
- **Prowler Cloud:** the finding groups endpoints no longer query Manual Pass triages once per finding, and skip that overlay for tenants with no active Manual Pass.
- The Findings page renders a skeleton at once and streams the table before the filters, and the **Finding Group** options load when the dropdown opens.
- Mute rule creation errors show the API error message instead of the raw response body.
- The sidebar no longer throws a hydration error on full page loads for users who last used the chat mode.
### 🔐 Security Updates
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the role's visibility.
- The UI E2E workflow receives its AWS credentials through environment variables instead of template expansion.
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.44.0) for the complete list of changes.
</Update>
<Update label="v5.43.0" description="September 21, 2026">
### 🏛️ Compliance — FedRAMP 20x Consolidated Rules 2026
@@ -47,6 +47,8 @@ The former build-time variables map to the new runtime variables as follows:
Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry.
The Registry links read `PROWLER_REGISTRY_INDEX_URL`, the same base URL the backend installs artifacts from. The Registry page and the credential banner link to it, and its origin is allowed for images. `UI_REGISTRY_MEDIA_URL` adds the Registry media service origin so artifact logos load. When `PROWLER_REGISTRY_INDEX_URL` is unset or invalid, the links are hidden instead of pointing to the public Prowler Registry, which keeps private and air-gapped deployments from sending users to an unreachable host. `UI_REGISTRY_URL` is no longer read.
The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again.
To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts.
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.43.0"
PROWLER_API_VERSION="5.43.0"
PROWLER_UI_VERSION="5.44.0"
PROWLER_API_VERSION="5.44.0"
```
<Note>
@@ -12,7 +12,7 @@ The following steps apply to Prowler Cloud and Prowler Local Server.
1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID.
2. Go to **Identity & Security** → **Users**, select the principal that owns the API key, and copy the **User OCID**.
3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint.
4. Note the **Region** identifier to scan (for example, `us-ashburn-1`).
4. In **Tenancy Details**, note the **Home Region** identifier (for example, `me-abudhabi-1`). Any other region the tenancy is subscribed to also works.
### Step 2: Access Prowler Cloud
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
@@ -26,12 +26,18 @@ Prowler Cloud connects to OCI with API key credentials. Provide:
- **User OCID** for the API key owner
- **Fingerprint** of the API key
- **Region** (for example, `us-ashburn-1`)
- **Home Region**: select it from the list (for example, `me-abudhabi-1`)
- **Private Key Content** (paste the full PEM value)
- **Passphrase (Optional)** if the private key is encrypted
Select **Next**, then **Launch Scan** to validate the connection and start the first OCI scan. The private key content is encoded for secure transmission.
<Note>
The home region is used only to validate the credentials and discover the regions the tenancy is subscribed to. It does not limit the scan: Prowler audits every subscribed region. OCI Identity and Access Management (IAM) only answers in subscribed regions, so a tenancy that is not subscribed to the selected region cannot be validated.
</Note>
Providers created without a region keep using `us-ashburn-1` for validation. If such a provider fails with `401 NotAuthenticated`, update its credentials and select the home region.
![Add OCI API Key Credentials](./images/oci-add-api-key-credentials.png)
---
@@ -334,6 +340,11 @@ prowler oci \
#### Region Issues
**Error: "OCI credential validation failed" with `401 NotAuthenticated` on `get_tenancy`**
- The tenancy is not subscribed to the region used for validation (by default `us-ashburn-1`)
- In Prowler Cloud or Prowler Local Server, update the provider credentials and select the tenancy home region
- In Prowler CLI, set `region` in `~/.oci/config` to the home region. All subscribed regions are still scanned; `--region` also works but limits the scan to that region
**Error: "Invalid region"**
- Check available regions: `prowler oci --list-regions`
- Verify your tenancy is subscribed to the region
@@ -148,6 +148,12 @@ New roles have no provider visibility by default. Assign at least one Provider G
Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission.
#### Task Visibility and Revocation
<VersionBadge version="5.44.0" />
Background tasks, such as provider deletions, connection checks and scans, follow the visibility of the provider they belong to: a role can see a task when it can access its provider. Tasks that carry no provider reference are treated as tenant-wide and are visible to every role. Revoking a pending task requires the same permission as the operation that queued it, for example **Manage Scans** for a scan. Provider deletions cannot be revoked.
#### Creating a Provider Group
Follow these steps to create a provider group in your account:
@@ -93,6 +93,10 @@ After adding your cloud account credentials, click the `Check connection` button
For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan.
</Note>
<Note>
To delegate the AWS connection, select `I don't have access, invite a teammate` on the same step when you cannot create the IAM role or do not have the account credentials. Prowler App sends the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. This option is available to users who can manage the account.
</Note>
## Step 6: Scan Started
After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress:
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# =============================================================================
# Final stage - Minimal runtime environment
# =============================================================================
FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76
FROM python:3.14.7-alpine3.23@sha256:218761489de417a6eb0808e264cbdd7043ec6659fe5a61898815e9848536541d
LABEL maintainer="https://github.com/prowler-cloud"
@@ -0,0 +1 @@
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
+1
View File
@@ -81,5 +81,6 @@ constraint-dependencies = [
"cryptography==50.0.0",
"joserfc==1.6.8",
"mcp==1.28.1",
"pyjwt==2.14.0",
"python-multipart==0.0.30"
]
+6 -5
View File
@@ -13,6 +13,7 @@ constraints = [
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "joserfc", specifier = "==1.6.8" },
{ name = "mcp", specifier = "==1.28.1" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "python-multipart", specifier = "==0.0.30" },
]
@@ -977,11 +978,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.13.0"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
@@ -1292,8 +1293,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
{ name = "cryptography", marker = "sys_platform != 'win32'" },
{ name = "jeepney", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
+16
View File
@@ -4,6 +4,22 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.44.0] (Prowler v5.44.0)
### 🚀 Added
- `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870)
### 🐞 Fixed
- STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870)
### 🔐 Security
- Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion [(#12864)](https://github.com/prowler-cloud/prowler/pull/12864)
---
## [5.43.0] (Prowler v5.43.0)
### 🚀 Added
@@ -1 +0,0 @@
`PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags
@@ -1 +0,0 @@
STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region
@@ -0,0 +1 @@
OCI API key credentials accept a home region to bootstrap identity calls, so tenancies not subscribed to us-ashburn-1 can connect
@@ -0,0 +1 @@
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
@@ -1 +0,0 @@
Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion
+1 -1
View File
@@ -52,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.44.0"
prowler_version = "5.45.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -89,6 +89,7 @@ class OraclecloudProvider(Provider):
key_content: str = None,
tenancy: str = None,
pass_phrase: str = None,
home_region: str = None,
):
"""
Initializes the OCI provider.
@@ -110,6 +111,7 @@ class OraclecloudProvider(Provider):
- key_content: Content of the private key (base64 encoded).
- tenancy: The OCID of the tenancy.
- pass_phrase: The passphrase for the private key, if encrypted.
- home_region: Region used to bootstrap identity calls with API key credentials; it does not filter the audited regions.
Raises:
- OCISetUpSessionError: If an error occurs during the setup process.
@@ -140,7 +142,7 @@ class OraclecloudProvider(Provider):
)
has_direct_credentials = user and fingerprint and tenancy
bootstrap_region = single_region or (
self._bootstrap_region if has_direct_credentials else None
(home_region or self._bootstrap_region) if has_direct_credentials else None
)
# Setup OCI Session
+2 -2
View File
@@ -144,7 +144,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.44.0"
version = "5.45.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -349,7 +349,7 @@ constraint-dependencies = [
"pydash==8.0.6",
"pyflakes==3.2.0",
"pygments==2.20.0",
"pyjwt==2.13.0",
"pyjwt==2.14.0",
"pylint==3.3.4",
"pynacl==1.6.2",
"pyopenssl==26.4.0",
@@ -543,6 +543,58 @@ class TestOraclecloudProviderInit:
assert mock_get_regions_to_audit.call_args_list[0].args == (None,)
assert provider.regions == all_subscribed_regions
def test_init_with_home_region_bootstraps_there_without_scan_filter(self):
mock_session = OCISession(
config={"region": "me-abudhabi-1"}, signer=None, profile=None
)
mock_identity = OCIIdentityInfo(
tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample",
tenancy_name="test-tenancy",
user_id="ocid1.user.oc1..aaaaaaaexample",
region="me-abudhabi-1",
profile=None,
audited_regions=set(),
audited_compartments=[],
)
all_subscribed_regions = [
OCIRegion(key="me-abudhabi-1", name="me-abudhabi-1", is_home_region=True),
OCIRegion(key="me-dubai-1", name="me-dubai-1", is_home_region=False),
]
with (
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session",
return_value=mock_session,
) as mock_setup_session,
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity",
return_value=mock_identity,
),
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit",
return_value=all_subscribed_regions,
) as mock_get_regions_to_audit,
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit",
return_value=["ocid1.compartment.oc1..aaaaaaaexample"],
),
patch("prowler.providers.common.provider.Provider.set_global_provider"),
):
provider = OraclecloudProvider(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
home_region="me-abudhabi-1",
config_content={"dummy": True},
mutelist_content={"Accounts": {}},
)
assert mock_setup_session.call_args.kwargs["region"] == "me-abudhabi-1"
assert mock_get_regions_to_audit.call_args_list[0].args == (None,)
assert provider.regions == all_subscribed_regions
assert provider.home_region == "me-abudhabi-1"
def test_init_with_config_file_auth_without_region_uses_session_config_region_for_identity(
self,
):
@@ -0,0 +1,20 @@
import re
from pathlib import Path
from prowler.providers.oraclecloud.config import OCI_REGIONS
UI_REGIONS_FILE = (
Path(__file__).resolve().parents[3]
/ "ui"
/ "lib"
/ "provider-credentials"
/ "oci-regions.ts"
)
def test_ui_home_region_list_matches_sdk_regions():
ui_regions = set(
re.findall(r'"([a-z]{2,3}-[a-z-]+-\d+)"', UI_REGIONS_FILE.read_text())
)
assert ui_regions == set(OCI_REGIONS)
+21
View File
@@ -4,6 +4,27 @@ All notable changes to the **Prowler UI** are documented in this file.
<!-- changelog: release notes start -->
## [1.44.0] (Prowler v5.44.0)
### 🚀 Added
- Sidebar action reads Add Provider while the tenant has no providers [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852)
### 🔄 Changed
- AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored and tested with the account [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852)
- New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852)
- Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens [(#12891)](https://github.com/prowler-cloud/prowler/pull/12891)
### 🐞 Fixed
- Mute rule creation errors show the API error message instead of the raw JSON:API response body [(#12853)](https://github.com/prowler-cloud/prowler/pull/12853)
- Provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable; the wait now covers the backend task's full time limit and falls back to the provider's current connection state if it is still exhausted [(#12869)](https://github.com/prowler-cloud/prowler/pull/12869)
- Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode [(#12873)](https://github.com/prowler-cloud/prowler/pull/12873)
- Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments [(#12892)](https://github.com/prowler-cloud/prowler/pull/12892)
---
## [1.43.0] (Prowler v5.43.0)
### 🚀 Added
+1 -1
View File
@@ -1,5 +1,5 @@
# Keep in sync with ui/.nvmrc.
FROM node:24.18.1-alpine@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS base
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS base
LABEL maintainer="https://github.com/prowler-cloud"
@@ -8,6 +8,8 @@ import { describe, expect, it } from "vitest";
import { BrowserHarness } from "./browser-harness";
const QUIET_MS = 50;
/** Exposes the protected waiting helpers; no fixture or DOM is involved. */
class WaitingHarness extends BrowserHarness<null> {
constructor() {
@@ -21,6 +23,10 @@ class WaitingHarness extends BrowserHarness<null> {
probeOrNull<T>(fn: () => T | null | undefined | false): Promise<T | null> {
return this.waitForOrNull(fn, 200, "probe");
}
probeStable<T>(read: () => T): Promise<T> {
return this.waitForStable(read, QUIET_MS, 1000, "probe");
}
}
describe("BrowserHarness waiting helpers", () => {
@@ -58,4 +64,20 @@ describe("BrowserHarness waiting helpers", () => {
}),
).resolves.toBe("ready");
});
it("resolves with a value only once it has held for the quiet window", async () => {
const harness = new WaitingHarness();
let reads = 0;
let settledAt = 0;
// Changes on each of the first reads, then holds at 4.
const settled = await harness.probeStable(() => {
reads += 1;
if (reads === 4) settledAt = performance.now();
return Math.min(reads, 4);
});
expect(settled).toBe(4);
expect(performance.now() - settledAt).toBeGreaterThanOrEqual(QUIET_MS);
});
});
+25
View File
@@ -211,6 +211,31 @@ export abstract class BrowserHarness<TFixture> {
}
}
/** Wait until `read` returns the same value for `quietMs`, and return it. */
protected async waitForStable<T>(
read: () => T,
quietMs: number,
timeoutMs = 5000,
label?: string,
): Promise<T> {
let value = read();
let since = performance.now();
const settled = await this.waitFor(
() => {
const next = read();
if (!Object.is(next, value)) {
value = next;
since = performance.now();
return null;
}
return performance.now() - since >= quietMs ? { value } : null;
},
timeoutMs,
label ?? `a value stable for ${quietMs}ms`,
);
return settled.value;
}
protected async waitForText(
pattern: RegExp,
timeoutMs = 5000,
@@ -14,6 +14,7 @@ import {
includesMutedFindings,
splitCsvFilterValues,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -151,6 +152,25 @@ export const getLatestFindingGroups = async (
params: FetchFindingGroupsParams = {},
) => fetchFindingGroupsEndpoint("finding-groups/latest", params);
/**
* Options for the "Finding Group" filter. Walks every finding-group page on the
* server, so the browser issues a single request instead of one per page
* (client-side Server Action calls are dispatched sequentially).
*/
export const getFindingGroupCheckOptions = async ({
filters,
hasHistoricalData,
}: {
filters: Record<string, string>;
hasHistoricalData: boolean;
}) =>
getFindingGroupFilterOptions({
fetchFindingGroups: hasHistoricalData
? getFindingGroups
: getLatestFindingGroups,
filters,
});
interface FetchFindingGroupResourcesParams {
checkId: string;
page?: number;
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { toSentInvitation } from "./invitation.adapter";
const created = {
data: {
id: "inv-1",
type: "invitations",
attributes: {
email: "teammate@company.com",
token: "abc123DEF45678",
state: "pending",
expires_at: "2026-10-07T10:00:00Z",
},
},
};
describe("toSentInvitation", () => {
it("reads the id, email and token of a created invitation", () => {
expect(toSentInvitation(created)).toEqual({
id: "inv-1",
email: "teammate@company.com",
token: "abc123DEF45678",
});
});
it("returns null when the action resolved without a value", () => {
// A 5xx makes `sendInvite` resolve undefined.
expect(toSentInvitation(undefined)).toBeNull();
});
it("returns null on a rejection, with or without an errors array", () => {
expect(
toSentInvitation({ errors: [{ detail: "Invalid email" }] }),
).toBeNull();
expect(toSentInvitation({ error: "Something went wrong" })).toBeNull();
});
it("returns null when the record is missing any of the fields the link needs", () => {
expect(
toSentInvitation({
data: { id: "inv-1", attributes: { email: "a@b.com" } },
}),
).toBeNull();
expect(
toSentInvitation({
data: { id: "inv-1", attributes: { token: "abc123DEF45678" } },
}),
).toBeNull();
});
});
@@ -0,0 +1,25 @@
import type { SentInvitation } from "@/types/onboarding-invite";
const readString = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
/**
* The created record out of `sendInvite`'s JSON:API response. Null for every
* failure shape: `undefined` (a 5xx makes the action resolve without a value),
* `{ errors }`, a bare `{ error }`, or a record missing what the link needs.
*/
export function toSentInvitation(response: unknown): SentInvitation | null {
if (!response || typeof response !== "object") return null;
const { data } = response as { data?: unknown };
if (!data || typeof data !== "object") return null;
const { id, attributes } = data as { id?: unknown; attributes?: unknown };
const fields =
attributes && typeof attributes === "object"
? (attributes as Record<string, unknown>)
: {};
const invitationId = readString(id);
const email = readString(fields.email);
const token = readString(fields.token);
if (!invitationId || !email || !token) return null;
return { id: invitationId, email, token };
}
@@ -5,11 +5,9 @@ import type { InvitationRoleOption } from "@/types/onboarding-invite";
const ROLES_PAGE_SIZE = 50;
// Roles the onboarding invite step can offer; empty when the read fails so
// the step can fall back to skipping rather than blocking the checkpoint.
export const getOnboardingInviteRoles = async (): Promise<
InvitationRoleOption[]
> => {
// Roles an invitation can grant; empty when the read fails so a caller can
// fall back (skip, disable) rather than block.
export const getInvitationRoles = async (): Promise<InvitationRoleOption[]> => {
const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE });
const roles: unknown = rolesData?.data;
if (!Array.isArray(roles)) return [];
@@ -1,6 +1,6 @@
"use client";
import { Icon } from "@iconify/react";
import { Mail, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -12,6 +12,7 @@ import {
} from "@/app/(auth)/invitation/_lib/invitation-errors";
import { AuthBrand } from "@/components/auth/oss/auth-brand";
import { Button } from "@/components/shadcn";
import { Spinner } from "@/components/shadcn/spinner/spinner";
type AcceptState =
| { kind: "no-token" }
@@ -74,10 +75,9 @@ export function AcceptInvitationClient({
{/* No token */}
{state.kind === "no-token" && (
<div className="flex flex-col items-center gap-4">
<Icon
icon="solar:danger-triangle-bold"
className="text-text-warning-primary"
width={48}
<TriangleAlert
aria-hidden="true"
className="text-text-warning-primary size-12"
/>
<h1 className="text-xl font-semibold">Invalid Invitation Link</h1>
<p className="text-text-neutral-tertiary">
@@ -93,11 +93,7 @@ export function AcceptInvitationClient({
{/* Accepting */}
{state.kind === "accepting" && (
<div className="flex flex-col items-center gap-4">
<Icon
icon="eos-icons:loading"
className="text-text-neutral-tertiary"
width={48}
/>
<Spinner className="size-12" />
<h1 className="text-xl font-semibold">Accepting Invitation...</h1>
<p className="text-text-neutral-tertiary">
Please wait while we process your invitation.
@@ -108,10 +104,9 @@ export function AcceptInvitationClient({
{/* Error */}
{state.kind === "error" && (
<div className="flex flex-col items-center gap-4">
<Icon
icon="solar:danger-triangle-bold"
className="text-text-error-primary"
width={48}
<TriangleAlert
aria-hidden="true"
className="text-text-error-primary size-12"
/>
<h1 className="text-xl font-semibold">
Could Not Accept Invitation
@@ -129,11 +124,7 @@ export function AcceptInvitationClient({
{/* Choice page for unauthenticated users */}
{state.kind === "choose" && (
<div className="flex flex-col items-center gap-6">
<Icon
icon="solar:letter-bold"
className="text-button-primary"
width={48}
/>
<Mail aria-hidden="true" className="text-button-primary size-12" />
<div>
<h1 className="text-xl font-semibold">
You&apos;ve Been Invited
@@ -48,6 +48,11 @@ vi.mock(
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
// The findings filters lazily load check options through this Server Action.
vi.mock("@/actions/finding-groups", () => ({
getFindingGroupCheckOptions: vi.fn().mockResolvedValue([]),
}));
vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info",
() => ({
+2 -1
View File
@@ -1,3 +1,4 @@
import { BellRing } from "lucide-react";
import { redirect } from "next/navigation";
import { getLatestMetadataInfo } from "@/actions/findings";
@@ -101,7 +102,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) {
: undefined;
return (
<ContentLayout title="Alerts" icon="lucide:bell-ring">
<ContentLayout title="Alerts" icon={<BellRing />}>
{!hasError ? (
<AlertsLighthouseContext
totalCount={apiMeta?.pagination?.count ?? alerts.length}
@@ -18,6 +18,8 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
private static readonly VIEWPORT_SEL = ".react-flow__viewport";
private static readonly MINIMAP_SEL = ".react-flow__minimap";
private static readonly BACKGROUND_SEL = ".react-flow__background";
// Matches the graph's auto-fit duration; a pause this long means no fit is mid-flight.
private static readonly FIT_ANIMATION_MS = 300;
private static isFindingElement(el: Element): boolean {
return (
@@ -255,17 +257,31 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
/** Wait until the React Flow viewport transform changes from `previous`. */
async waitForViewportChange(
previous: string,
timeoutMs = 2000,
timeoutMs?: number,
): Promise<void> {
await this.waitFor(() => this.viewportTransform !== previous, timeoutMs);
await this.waitFor(
() => this.viewportTransform !== previous,
timeoutMs,
"the viewport transform to change",
);
}
/** Wait until the viewport stops moving and return its settled transform. */
async waitForViewportSettled(): Promise<string> {
return this.waitForStable(
() => this.viewportTransform,
AttackPathPageHarness.FIT_ANIMATION_MS,
undefined,
"the viewport to settle",
);
}
/** Wait until every requested node is fully contained in the graph canvas. */
async waitForNodesInViewport(
nodeIds: string[],
timeoutMs = 2000,
timeoutMs?: number,
): Promise<void> {
await this.waitFor(() => {
const allInViewport = () => {
const canvas = this.q(AttackPathPageHarness.FLOW_SEL);
if (!canvas) return false;
@@ -282,7 +298,12 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
nodeRect.bottom <= canvasRect.bottom
);
});
}, timeoutMs);
};
await this.waitFor(
allInViewport,
timeoutMs,
`nodes ${nodeIds.join(", ")} to be in the viewport`,
);
}
/** Wait until exactly `count` edges are highlighted. */
@@ -407,19 +407,24 @@ describe("exploring the graph", () => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForGraphStable(3);
const initialViewport = graph.viewportTransform;
// Settle before each capture so the next change can only come from the
// action under test, not the tail of the previous fit animation.
const initialViewport = await graph.waitForViewportSettled();
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForViewportChange(initialViewport);
const contextualViewport = graph.viewportTransform;
const contextualViewport = await graph.waitForViewportSettled();
const visibleNodeIds = graph.renderedNodeIds;
await graph.fit();
await graph.waitForViewportChange(contextualViewport);
// The fit must end with the whole visible graph on screen, not just move
await graph.waitForViewportSettled();
await graph.waitForNodesInViewport(visibleNodeIds);
});
test("clicking an expanded resource re-fits the remaining visible graph", async ({
mountWith,
+3 -1
View File
@@ -1,3 +1,5 @@
import { GitBranch } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default function AttackPathsLayout({
@@ -8,7 +10,7 @@ export default function AttackPathsLayout({
return (
<ContentLayout
title="Attack Paths"
icon="lucide:git-branch"
icon={<GitBranch />}
onboardingAction={{ flowId: "attack-paths" }}
>
{children}
+4 -4
View File
@@ -1,4 +1,4 @@
import { Info } from "lucide-react";
import { Info, ShieldCheck } from "lucide-react";
import { Suspense } from "react";
import {
@@ -105,7 +105,7 @@ export default async function Compliance({
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
icon={<ShieldCheck />}
onboardingAction={
hasCompletedScan
? { flowId: "view-compliance" }
@@ -172,7 +172,7 @@ export default async function Compliance({
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
icon={<ShieldCheck />}
onboardingAction={{
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
@@ -323,7 +323,7 @@ export default async function Compliance({
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
icon={<ShieldCheck />}
onboardingAction={onboardingAction}
>
<CompliancePageTabs
+13 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@iconify/react";
import * as Sentry from "@sentry/nextjs";
import { RefreshCw, ServerOff, TriangleAlert } from "lucide-react";
import { useEffect } from "react";
import { Button } from "@/components/shadcn";
@@ -84,10 +84,17 @@ export default function Error({
<Card variant="base" className="w-full max-w-lg">
<CardHeader>
<div className="flex items-start gap-3">
<Icon
icon={is500Error ? "tabler:server-off" : "tabler:rocket-off"}
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
{is500Error ? (
<ServerOff
aria-hidden="true"
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
) : (
<TriangleAlert
aria-hidden="true"
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
/>
)}
<div className="flex flex-col gap-2">
<CardTitle className="text-lg">
{is500Error
@@ -105,7 +112,7 @@ export default function Error({
<CardContent>
<div className="flex items-center justify-start gap-3">
<Button onClick={reset} size="sm" className="gap-2">
<Icon icon="tabler:refresh" className="h-4 w-4" />
<RefreshCw aria-hidden="true" className="h-4 w-4" />
Try Again
</Button>
<CustomLink href="/" target="_self" className="font-bold">
@@ -0,0 +1,103 @@
import {
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import { createScanDetailsMapping, splitCsvFilterValues } from "@/lib";
import { getSelectedFindingCheckOptions } from "@/lib/finding-group-filter-options";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
interface FindingsFiltersSectionProps {
filters: Record<string, string>;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
query: string;
encodedSort?: string;
completedScans: ScanProps[];
}
/**
* Streams behind its own Suspense boundary: everything the filter controls
* need is fetched here, in parallel, so the table never waits for it.
*/
export async function FindingsFiltersSection({
filters,
resolvedFilters,
hasHistoricalData,
query,
encodedSort,
completedScans,
}: FindingsFiltersSectionProps) {
const selectedCheckIds = [
...splitCsvFilterValues(resolvedFilters["filter[check_id]"]),
...splitCsvFilterValues(resolvedFilters["filter[check_id__in]"]),
];
const [providersData, providerGroupsData, metadataInfoData, selectedChecks] =
await Promise.all([
getAllProviders(),
getAllProviderGroups(),
(hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo)({
query,
sort: encodedSort,
filters: resolvedFilters,
}),
getSelectedFindingCheckOptions({
fetchFindingGroups: hasHistoricalData
? getFindingGroups
: getLatestFindingGroups,
filters: resolvedFilters,
selectedCheckIds,
}),
]);
const attributes = metadataInfoData?.data?.attributes;
const uniqueRegions = attributes?.regions || [];
const uniqueServices = attributes?.services || [];
const uniqueResourceTypes = attributes?.resource_types || [];
const uniqueCategories = attributes?.categories || [];
const uniqueGroups = attributes?.groups || [];
const providers = providersData?.data || [];
const scanDetails = createScanDetailsMapping(
completedScans,
providersData,
) as { [uid: string]: ScanEntity }[];
return (
<FindingsFilters
providers={providers}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScans.map((scan) => scan.id)}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptionsSource={{
filters: resolvedFilters,
hasHistoricalData,
initialOptions: selectedChecks,
}}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providers}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={isCloud()}
/>
}
/>
);
}
@@ -0,0 +1,17 @@
import { FILTER_CONTROL_COLUMN_CLASS } from "@/components/findings/findings-filters.utils";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
const FILTER_CONTROL_PLACEHOLDERS = 5;
export const FindingsFiltersSkeleton = () => {
return (
<div className="flex flex-wrap items-center gap-3">
{Array.from({ length: FILTER_CONTROL_PLACEHOLDERS }, (_, index) => (
<Skeleton
key={index}
className={`h-[52px] rounded-lg ${FILTER_CONTROL_COLUMN_CLASS}`}
/>
))}
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { Tag } from "lucide-react";
import { SkeletonTableFindings } from "@/components/findings/table";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
export default function FindingsLoading() {
return (
<ContentLayout
title="Findings"
icon={<Tag />}
onboardingAction={{ flowId: "explore-findings" }}
>
<div className="mb-6">
<FindingsFiltersSkeleton />
</div>
<SkeletonTableFindings />
</ContentLayout>
);
}
+41 -83
View File
@@ -1,3 +1,4 @@
import { Tag } from "lucide-react";
import { Suspense } from "react";
import {
@@ -5,12 +6,7 @@ import {
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
FindingsGroupTable,
SkeletonTableFindings,
@@ -19,17 +15,17 @@ import { ContentLayout } from "@/components/shadcn/content-layout";
import { FilterTransitionWrapper } from "@/contexts";
import {
applyDefaultMutedFilter,
createScanDetailsMapping,
extractFiltersAndQuery,
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
import { ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
import { FindingsFiltersSection } from "./_components/findings-filters-section";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
export default async function Findings({
searchParams,
}: {
@@ -39,54 +35,37 @@ export default async function Findings({
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, providerGroupsData, scansData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getScans({ pageSize: 50 }),
// The page shell awaits only what both the filters and the table depend on:
// the completed scans (onboarding + scan filter) and the scan date range.
const [scansData, filtersWithScanDates] = await Promise.all([
getScans({
pageSize: 50,
filters: { "filter[state]": "completed" },
fields: {
scans: "name,state,unique_resource_count,completed_at,provider",
},
}),
resolveFindingScanDateFilters({
filters,
scans: [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
}),
]);
const filtersWithScanDates = await resolveFindingScanDateFilters({
filters,
scans: scansData?.data || [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
});
const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates);
const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates);
const metadataInfoData = await (
hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo
)({
query,
sort: encodedSort,
filters: resolvedFilters,
});
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes =
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
const completedScans: ScanProps[] =
scansData?.data?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1,
) || [];
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1,
);
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const onboardingAction =
completedScanIds.length > 0
completedScans.length > 0
? { flowId: "explore-findings" }
: {
flowId: "explore-findings",
@@ -94,45 +73,24 @@ export default async function Findings({
useFallback: true,
};
const scanDetails = createScanDetailsMapping(
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const alertsEnabled = isCloud();
return (
<ContentLayout
title="Findings"
icon="lucide:tag"
icon={<Tag />}
onboardingAction={onboardingAction}
>
<FilterTransitionWrapper>
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providersData?.data || []}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={alertsEnabled}
/>
}
/>
<Suspense fallback={<FindingsFiltersSkeleton />}>
<FindingsFiltersSection
filters={filters}
resolvedFilters={resolvedFilters}
hasHistoricalData={hasHistoricalData}
query={query}
encodedSort={encodedSort}
completedScans={completedScans}
/>
</Suspense>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
<SSRDataTable
+3 -1
View File
@@ -1,10 +1,12 @@
import { Puzzle } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { IntegrationsContent } from "./integrations-content";
export default async function Integrations() {
return (
<ContentLayout title="Integrations" icon="lucide:puzzle">
<ContentLayout title="Integrations" icon={<Puzzle />}>
<IntegrationsContent />
</ContentLayout>
);
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { X } from "lucide-react";
import React from "react";
import { WorkflowSendInvite } from "@/components/invitations/workflow";
@@ -14,7 +15,7 @@ export default function InvitationLayout({ children }: InvitationLayoutProps) {
<>
<NavigationHeader
title="Send Invitation"
icon="icon-park-outline:close-small"
icon={<X />}
href="/invitations"
/>
<div className="h-16" />
+2 -1
View File
@@ -1,3 +1,4 @@
import { Mail } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -22,7 +23,7 @@ export default async function Invitations({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return (
<ContentLayout title="Invitations" icon="lucide:mail">
<ContentLayout title="Invitations" icon={<Mail />}>
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom
@@ -1,15 +1,15 @@
import { Icon } from "@iconify/react";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
import { AmazonWebServicesIcon, OpenAIIcon } from "@/components/icons/Icons";
import type { IconComponent } from "@/types/components";
const LIGHTHOUSE_V2_PROVIDER_ICONS = {
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: "simple-icons:openai",
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: "simple-icons:amazonwebservices",
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: "simple-icons:openai",
} as const satisfies Record<LighthouseV2ProviderType, string>;
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: OpenAIIcon,
[LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: AmazonWebServicesIcon,
[LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: OpenAIIcon,
} as const satisfies Record<LighthouseV2ProviderType, IconComponent>;
export function ProviderIcon({
provider,
@@ -18,11 +18,6 @@ export function ProviderIcon({
provider: LighthouseV2ProviderType;
className?: string;
}) {
return (
<Icon
aria-hidden="true"
className={className}
icon={LIGHTHOUSE_V2_PROVIDER_ICONS[provider]}
/>
);
const Icon = LIGHTHOUSE_V2_PROVIDER_ICONS[provider];
return <Icon aria-hidden="true" className={className} />;
}
+2 -2
View File
@@ -56,7 +56,7 @@ export default async function AIChatbot({
const chatRouteKey = validSessionId ?? initialPrompt ?? "new";
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon size={32} />}>
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.CHAT} closeSidePanel />
{/* [contain:layout] traps streamdown's fixed fullscreen overlay inside
the chat area so it never covers the sidebar or navbar. */}
@@ -91,7 +91,7 @@ export default async function AIChatbot({
}
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon size={32} />}>
<div className="-mx-6 -my-4 h-[calc(100dvh-4.5rem)] sm:-mx-8">
<Chat
hasConfig={hasConfig}
@@ -2,7 +2,7 @@
import "@/styles/globals.css";
import { Icon } from "@iconify/react";
import { Star, Trash2, X } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
@@ -76,7 +76,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
<NavigationHeader
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
icon="icon-park-outline:close-small"
icon={<X />}
href={LIGHTHOUSE_ROUTE.SETTINGS}
/>
<div className="h-8" />
@@ -96,7 +96,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
onClick={handleSetDefault}
className="w-full sm:w-auto"
>
<Icon icon="heroicons:star" className="h-4 w-4" />
<Star aria-hidden="true" className="h-4 w-4" />
Set as Default
</Button>
)}
@@ -108,7 +108,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
onClick={() => setIsDeleteOpen(true)}
className="w-full sm:w-auto"
>
<Icon icon="heroicons:trash" className="h-4 w-4" />
<Trash2 aria-hidden="true" className="h-4 w-4" />
Delete Provider
</Button>
</div>
+2 -1
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { Group } from "lucide-react";
import React from "react";
import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -10,7 +11,7 @@ interface ProviderLayoutProps {
export default function ProviderLayout({ children }: ProviderLayoutProps) {
return (
<ContentLayout title="Manage Groups" icon="lucide:group">
<ContentLayout title="Manage Groups" icon={<Group />}>
{children}
</ContentLayout>
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { VolumeX } from "lucide-react";
import { Suspense } from "react";
import { ContentLayout } from "@/components/shadcn/content-layout";
@@ -15,7 +16,7 @@ export default async function MutelistPage({
const searchParamsKey = JSON.stringify(resolvedSearchParams);
return (
<ContentLayout title="Mutelist" icon="lucide:volume-x">
<ContentLayout title="Mutelist" icon={<VolumeX />}>
<MutelistTabs
simpleContent={
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
+2 -1
View File
@@ -1,3 +1,4 @@
import { SquareChartGantt } from "lucide-react";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -59,7 +60,7 @@ export default async function Home({
]);
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
<OverviewProviderContext
searchParams={resolvedSearchParams}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Users } from "lucide-react";
import { Suspense } from "react";
import { getSamlConfig } from "@/actions/integrations/saml";
@@ -26,7 +27,7 @@ export default async function Profile({
const resolvedSearchParams = await searchParams;
return (
<ContentLayout title="User Profile" icon="lucide:users">
<ContentLayout title="User Profile" icon={<Users />}>
<Suspense fallback={<SkeletonUserInfo />}>
<SSRDataUser searchParams={resolvedSearchParams} />
</Suspense>
+2 -1
View File
@@ -1,3 +1,4 @@
import { CloudCog } from "lucide-react";
import { Suspense } from "react";
import { SkeletonTableProviders } from "@/components/providers/table";
@@ -35,7 +36,7 @@ export default async function Providers({
return (
<ContentLayout
title="Providers"
icon="lucide:cloud-cog"
icon={<CloudCog />}
onboardingAction={{ flowId: "add-provider" }}
>
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
+4 -6
View File
@@ -1,10 +1,10 @@
import { Package } from "lucide-react";
import { redirect } from "next/navigation";
import { getRegistryBootstrap } from "@/actions/registry/registry";
import { RegistryExplorer } from "@/components/registry/registry-explorer";
import { ContentLayout } from "@/components/shadcn/content-layout/content-layout";
import { getRegistryPresentation } from "@/lib/registry/presentation";
import { readEnv } from "@/lib/runtime-env";
import { readRegistryPresentation } from "@/lib/registry/presentation";
import { REGISTRY_FAILURE } from "@/types/registry";
export const dynamic = "force-dynamic";
@@ -14,12 +14,10 @@ export default async function RegistryPage() {
if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile");
return (
<ContentLayout title="Registry" icon="lucide:package">
<ContentLayout title="Registry" icon={<Package />}>
<RegistryExplorer
initialState={bootstrap.state}
registryKeyUrl={
getRegistryPresentation(readEnv("UI_REGISTRY_URL")).keyUrl
}
registryUrl={readRegistryPresentation().registryUrl}
/>
</ContentLayout>
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { Warehouse } from "lucide-react";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -80,7 +81,7 @@ export default async function Resources({
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
return (
<ContentLayout title="Resources" icon="lucide:warehouse">
<ContentLayout title="Resources" icon={<Warehouse />}>
<FilterTransitionWrapper>
<div className="mb-6">
<ResourcesFilters
+2 -5
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { X } from "lucide-react";
import React from "react";
import { WorkflowAddEditRole } from "@/components/roles/workflow";
@@ -12,11 +13,7 @@ interface RoleLayoutProps {
export default function RoleLayout({ children }: RoleLayoutProps) {
return (
<>
<NavigationHeader
title="Role Management"
icon="icon-park-outline:close-small"
href="/roles"
/>
<NavigationHeader title="Role Management" icon={<X />} href="/roles" />
<div className="h-16" />
<div className="grid grid-cols-1 gap-8 px-4 sm:px-6 lg:grid-cols-12 lg:px-0">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
+2 -1
View File
@@ -1,3 +1,4 @@
import { UserCog } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -18,7 +19,7 @@ export default async function Roles({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return (
<ContentLayout title="Roles" icon="lucide:user-cog">
<ContentLayout title="Roles" icon={<UserCog />}>
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom
+2 -1
View File
@@ -1,3 +1,4 @@
import { SlidersVertical } from "lucide-react";
import { redirect } from "next/navigation";
import { getProviders } from "@/actions/providers";
@@ -28,7 +29,7 @@ export default async function ScanConfigPage() {
const richProviders = providersResponse.data;
return (
<ContentLayout title="Configuration" icon="lucide:sliders">
<ContentLayout title="Configuration" icon={<SlidersVertical />}>
<ScanConfigurationsManager
initialConfigs={configs}
richProviders={richProviders}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Timer } from "lucide-react";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
@@ -218,7 +219,7 @@ export default async function Scans({
return (
<ContentLayout
title="Scans"
icon="lucide:timer"
icon={<Timer />}
onboardingAction={onboardingAction}
>
<ScansPageShell
+3 -4
View File
@@ -1,13 +1,12 @@
import { Server } from "lucide-react";
import { FilterControls } from "@/components/filters";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Services() {
// const searchParamsKey = JSON.stringify(searchParams || {});
return (
<ContentLayout
title="Services"
icon="material-symbols:linked-services-outline"
>
<ContentLayout title="Services" icon={<Server />}>
<div className="h-4" />
<FilterControls />
<div className="h-4" />
+2 -1
View File
@@ -1,3 +1,4 @@
import { User } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
@@ -20,7 +21,7 @@ export default async function Users({
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
return (
<ContentLayout title="Users" icon="lucide:user">
<ContentLayout title="Users" icon={<User />}>
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-end">
<Button asChild>
+3 -1
View File
@@ -1,8 +1,10 @@
import { Tags } from "lucide-react";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Workloads() {
return (
<ContentLayout title="Workloads" icon="lucide:tags">
<ContentLayout title="Workloads" icon={<Tags />}>
<p>Workloads</p>
</ContentLayout>
);

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