fix(compliance): resolve provider from scan in attributes endp (#11546)

This commit is contained in:
Pedro Martín
2026-06-11 18:00:36 +02:00
committed by GitHub
parent bba594a1db
commit 20eca78767
6 changed files with 258 additions and 3 deletions
+1
View File
@@ -6,6 +6,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🐞 Fixed
- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546)
- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558)
---
+182
View File
@@ -9570,6 +9570,188 @@ class TestComplianceOverviewViewSet:
assert "Category" in first_attr
assert "AWSService" in first_attr
def test_compliance_overview_attributes_resolves_provider_from_scan(
self, authenticated_client, tenants_fixture, providers_fixture
):
# csa_ccm_4.0 is a multi-provider universal framework: a single
# compliance_id whose requirements expose different checks per provider.
# Passing a scan must return the check IDs for that scan's provider,
# otherwise the endpoint defaults to the first provider that declares the
# framework and azure/gcp requirements end up with check IDs that match
# no findings.
tenant = tenants_fixture[0]
gcp_provider = providers_fixture[2]
azure_provider = providers_fixture[4]
assert gcp_provider.provider == Provider.ProviderChoices.GCP.value
assert azure_provider.provider == Provider.ProviderChoices.AZURE.value
now = datetime.now(timezone.utc)
gcp_scan = Scan.objects.create(
name="gcp scan",
provider=gcp_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
azure_scan = Scan.objects.create(
name="azure scan",
provider=azure_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
def request_attributes(scan_id=None):
params = {"filter[compliance_id]": "csa_ccm_4.0"}
if scan_id is not None:
params["filter[scan_id]"] = str(scan_id)
return authenticated_client.get(
reverse("complianceoverview-attributes"), params
)
def collect_check_ids(scan_id=None):
response = request_attributes(scan_id)
assert response.status_code == status.HTTP_200_OK
check_ids = set()
for item in response.json()["data"]:
check_ids.update(item["attributes"]["attributes"]["check_ids"])
return check_ids
gcp_check_ids = collect_check_ids(gcp_scan.id)
azure_check_ids = collect_check_ids(azure_scan.id)
# Each scan resolves to its own provider's checks, and they differ.
assert gcp_check_ids
assert azure_check_ids
assert gcp_check_ids != azure_check_ids
# The returned check IDs belong to the SDK's per-provider definition.
from api.compliance import get_prowler_provider_compliance
def expected_check_ids(provider_type):
framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"]
expected = set()
for requirement in framework.requirements:
expected.update(requirement.checks.get(provider_type, []))
return expected
assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value)
assert azure_check_ids <= expected_check_ids(
Provider.ProviderChoices.AZURE.value
)
# An explicit scan_id is authoritative: a non-existent scan must fail
# closed with 404 instead of silently falling back to another provider.
missing_response = request_attributes("00000000-0000-0000-0000-000000000000")
assert missing_response.status_code == status.HTTP_404_NOT_FOUND
# A malformed scan_id is rejected with 404 as well.
malformed_response = request_attributes("not-a-uuid")
assert malformed_response.status_code == status.HTTP_404_NOT_FOUND
# An empty value (filter[scan_id]=) must not fall back to the legacy
# provider picker: the explicit (if blank) selector fails closed.
empty_response = request_attributes("")
assert empty_response.status_code == status.HTTP_404_NOT_FOUND
# A scan belonging to another tenant is not visible (RLS), so it must
# return 404 rather than leaking the fallback provider's check IDs.
other_tenant = Tenant.objects.create(name="Other Compliance Tenant")
foreign_provider = Provider.objects.create(
provider="gcp",
uid="foreign-gcp-test",
alias="foreign_gcp",
tenant_id=other_tenant.id,
)
foreign_scan = Scan.objects.create(
name="foreign scan",
provider=foreign_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=other_tenant.id,
started_at=now,
completed_at=now,
)
foreign_response = request_attributes(foreign_scan.id)
assert foreign_response.status_code == status.HTTP_404_NOT_FOUND
def test_compliance_overview_attributes_scan_scoped_by_provider_group(
self,
authenticated_client_no_permissions_rbac,
providers_fixture,
):
# A user with limited visibility (no UNLIMITED_VISIBILITY) must only be
# able to resolve scans for providers in its provider groups. Tenant RLS
# alone is not enough here: both scans belong to the same tenant, so the
# endpoint has to scope the scan lookup by provider group, otherwise a
# restricted user could read another provider's compliance metadata.
client = authenticated_client_no_permissions_rbac
limited_user = client.user
membership = Membership.objects.filter(user=limited_user).first()
tenant = membership.tenant
allowed_provider = providers_fixture[2]
denied_provider = providers_fixture[4]
assert allowed_provider.provider == Provider.ProviderChoices.GCP.value
assert denied_provider.provider == Provider.ProviderChoices.AZURE.value
provider_group = ProviderGroup.objects.create(
name="limited-compliance-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,
)
now = datetime.now(timezone.utc)
allowed_scan = Scan.objects.create(
name="allowed scan",
provider=allowed_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
denied_scan = Scan.objects.create(
name="denied scan",
provider=denied_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
def request_attributes(scan_id):
return client.get(
reverse("complianceoverview-attributes"),
{
"filter[compliance_id]": "csa_ccm_4.0",
"filter[scan_id]": str(scan_id),
},
)
# The scan in the user's provider group resolves normally.
assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK
# The scan outside the user's provider group is invisible, so it fails
# closed with 404 instead of leaking the other provider's check IDs.
assert (
request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND
)
def test_compliance_overview_attributes_missing_compliance_id(
self, authenticated_client
):
+56 -1
View File
@@ -30,6 +30,7 @@ from dj_rest_auth.registration.views import SocialLoginView
from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg
from django.contrib.postgres.search import SearchQuery
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import (
BooleanField,
@@ -4644,6 +4645,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
location=OpenApiParameter.QUERY,
description="Compliance framework ID to get attributes for.",
),
OpenApiParameter(
name="filter[scan_id]",
required=False,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description="Scan ID used to resolve the provider for "
"multi-provider universal frameworks (e.g. CSA CCM), so "
"the returned check IDs match the scan's provider. When omitted, "
"the first provider that declares the framework is used.",
),
],
responses={
200: OpenApiResponse(
@@ -5084,7 +5095,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
provider_type = None
# If we couldn't determine from database, try each provider type
# When a scan is provided, resolve the provider from it. Multi-provider
# universal frameworks (e.g. CSA CCM) share a single compliance_id
# across providers but expose different checks per provider, so the
# metadata (and therefore the check IDs the UI uses to fetch findings)
# must be returned for the scan's provider. Without this, the endpoint
# falls back to the first provider that declares the framework and
# returns its check IDs, leaving azure/gcp/... requirements with no
# matching findings.
scan_id = request.query_params.get("filter[scan_id]")
if "filter[scan_id]" in request.query_params:
# An explicit scan_id is authoritative: fail closed instead of
# falling back to another provider. Otherwise an invalid, empty
# (filter[scan_id]=) or inaccessible scan would silently return the
# first provider's check IDs, recreating the multi-provider mismatch
# this endpoint fixes.
if not scan_id:
raise NotFound(detail=f"Scan '{scan_id}' not found.")
# Tenant isolation is already enforced by Postgres RLS on the
# connection (see BaseRLSViewSet). Scope the lookup by provider
# group as well so a user with limited visibility can't resolve
# another provider's scan and read its compliance metadata, mirroring
# the RBAC scoping get_queryset() applies to the rest of the ViewSet.
role = get_role(request.user, request.tenant_id)
if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False):
scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id)
else:
scan_queryset = Scan.objects.filter(provider__in=get_providers(role))
try:
scan = scan_queryset.select_related("provider").get(id=scan_id)
except (Scan.DoesNotExist, DjangoValidationError, ValueError):
raise NotFound(detail=f"Scan '{scan_id}' not found.")
provider_type = scan.provider.provider
if compliance_id not in get_compliance_frameworks(provider_type):
raise NotFound(
detail=(
f"Compliance framework '{compliance_id}' is not "
f"available for scan '{scan_id}'."
)
)
# Fall back to the first provider that declares the framework. Keeps the
# endpoint working for provider-agnostic callers that omit the scan.
if not provider_type:
for pt in Provider.ProviderChoices.values:
if compliance_id in get_compliance_frameworks(pt):
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.30.1] (Prowler UNRELEASED)
### 🐞 Fixed
- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546)
---
## [1.30.0] (Prowler v5.30.0)
### 🚀 Added
+10 -1
View File
@@ -73,12 +73,21 @@ export const getComplianceOverviewMetadataInfo = async ({
}
};
export const getComplianceAttributes = async (complianceId: string) => {
export const getComplianceAttributes = async (
complianceId: string,
scanId?: string,
) => {
const headers = await getAuthHeaders({ contentType: false });
try {
const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`);
url.searchParams.append("filter[compliance_id]", complianceId);
// Pass the scan so multi-provider universal frameworks (e.g. CSA CCM)
// resolve the check IDs for the scan's provider instead of defaulting to
// the first provider that declares the framework.
if (scanId) {
url.searchParams.append("filter[scan_id]", scanId);
}
const response = await fetch(url.toString(), {
headers,
@@ -87,7 +87,7 @@ export default async function ComplianceDetail({
"filter[scan_id]": selectedScanId ?? undefined,
},
}),
getComplianceAttributes(complianceId),
getComplianceAttributes(complianceId, selectedScanId ?? undefined),
selectedScanId
? getScan(selectedScanId, { include: "provider" })
: Promise.resolve(null),