From f19478f2f6748f6a95a72033f067ea85e40d0001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 27 Aug 2026 09:17:39 +0200 Subject: [PATCH] fix(compliance): discover universal frameworks from entry point (#12536) Co-authored-by: alejandrobailo --- ...universal-compliance-entry-points.fixed.md | 1 + prowler/config/config.py | 21 +-- prowler/lib/check/compliance_models.py | 56 ++++-- .../check/universal_compliance_models_test.py | 89 +++++++++ .../external/test_dynamic_provider_loading.py | 7 +- .../compliance-watchlist.test.ts | 21 ++- .../compliance-watchlist.ts | 24 ++- .../[compliancetitle]/page.test.tsx | 4 - .../compliance/[compliancetitle]/page.tsx | 38 ++-- .../cross-account-overview-section.test.tsx | 29 ++- .../cross-account-overview-section.tsx | 8 +- .../cross-provider-detail.test.tsx | 170 ++++++++++++++++++ .../_components/cross-provider-detail.tsx | 66 ++++--- .../cross-provider-framework-grid.tsx | 9 +- .../cross-provider-overview.test.tsx | 163 +++++++++++------ .../_components/cross-provider-overview.tsx | 71 ++++---- .../__tests__/cross-provider-catalog.test.ts | 162 +++++++++++++++++ .../cross-provider-frameworks.test.ts | 61 +------ .../_lib/__tests__/watchlist-context.test.ts | 12 +- .../compliance/_lib/cross-provider-catalog.ts | 35 ++++ .../_lib/cross-provider-frameworks.ts | 91 +--------- .../compliance/_lib/watchlist-context.ts | 6 +- ...ross-provider-catalog-unavailable.fixed.md | 1 + .../cross-provider-framework-catalog.fixed.md | 1 + ui/types/compliance-watchlist.ts | 5 + 25 files changed, 820 insertions(+), 331 deletions(-) create mode 100644 prowler/changelog.d/universal-compliance-entry-points.fixed.md create mode 100644 ui/app/(prowler)/compliance/_components/cross-provider-detail.test.tsx create mode 100644 ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts create mode 100644 ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts create mode 100644 ui/changelog.d/cross-provider-catalog-unavailable.fixed.md create mode 100644 ui/changelog.d/cross-provider-framework-catalog.fixed.md diff --git a/prowler/changelog.d/universal-compliance-entry-points.fixed.md b/prowler/changelog.d/universal-compliance-entry-points.fixed.md new file mode 100644 index 0000000000..81fbff0864 --- /dev/null +++ b/prowler/changelog.d/universal-compliance-entry-points.fixed.md @@ -0,0 +1 @@ +`prowler.compliance.universal` entry point directories are resolved through a single shared helper and deduplicated by resolved path, so a directory reached through two entry points is parsed once and a package that fails to import no longer hides the rest diff --git a/prowler/config/config.py b/prowler/config/config.py index 6d2e71132f..de010e2cc8 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -10,7 +10,10 @@ import requests import yaml from packaging import version -from prowler.lib.check.compliance_models import load_compliance_framework_universal +from prowler.lib.check.compliance_models import ( + get_universal_compliance_entry_point_dirs, + load_compliance_framework_universal, +) # Re-exported from a leaf module so prowler.lib.check.utils can import the # constant without participating in the config <-> compliance_models <-> utils @@ -170,21 +173,7 @@ def get_available_compliance_frameworks(provider=None): available_compliance_frameworks.append(name) # External multi-provider frameworks via the dedicated universal group; # filtered by supports_provider when a provider is given. - for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): - try: - module = ep.load() - path = ( - module.__path__[0] - if hasattr(module, "__path__") - else os.path.dirname(module.__file__) - ) - except Exception as error: - logger.warning( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - continue - if not os.path.isdir(path): - continue + for path in get_universal_compliance_entry_point_dirs(): for file in os.scandir(path): if file.is_file() and file.name.endswith(".json"): name = file.name.removesuffix(".json") diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 80322a6929..fae5422e3d 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -1049,6 +1049,46 @@ def load_compliance_framework_universal(path: str) -> ComplianceFramework: return None +# Kept apart from the per-provider `prowler.compliance` group so the legacy +# loader never parses a universal JSON. +UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP = "prowler.compliance.universal" + + +def get_universal_compliance_entry_point_dirs() -> list[str]: + """Existing directories contributed through the universal compliance entry + point group, in entry point order. + + Deduped by resolved path, so a directory reached through a symlink counts + once. A package that fails to import is logged and skipped: one broken + plugin must not hide the rest. + """ + dirs = [] + seen = set() + for ep in importlib.metadata.entry_points( + group=UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP + ): + try: + module = ep.load() + path = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not os.path.isdir(path): + continue + resolved = os.path.realpath(path) + if resolved in seen: + continue + seen.add(resolved) + dirs.append(path) + return dirs + + def _load_jsons_from_dir(dir_path: str, provider: str, bulk: dict) -> None: """Scan *dir_path* for JSON files and add matching frameworks to *bulk*.""" for filename in os.listdir(dir_path): @@ -1109,20 +1149,10 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict: if compliance_root and os.path.isdir(compliance_root): _load_jsons_from_dir(compliance_root, provider, bulk) - # External multi-provider frameworks via the dedicated universal entry - # point group, kept separate from the per-provider `prowler.compliance` - # group so the legacy loader never parses a universal JSON. Built-ins - # (already in bulk) win on a name collision. - for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + # Built-ins are already in `bulk` and win on a name collision. + for ep_dir in get_universal_compliance_entry_point_dirs(): try: - module = ep.load() - ep_dir = ( - module.__path__[0] - if hasattr(module, "__path__") - else os.path.dirname(module.__file__) - ) - if os.path.isdir(ep_dir): - _load_jsons_from_dir(ep_dir, provider, bulk) + _load_jsons_from_dir(ep_dir, provider, bulk) except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/tests/lib/check/universal_compliance_models_test.py b/tests/lib/check/universal_compliance_models_test.py index c8f614488c..61109265a1 100644 --- a/tests/lib/check/universal_compliance_models_test.py +++ b/tests/lib/check/universal_compliance_models_test.py @@ -26,6 +26,7 @@ from prowler.lib.check.compliance_models import ( UniversalComplianceRequirement, adapt_legacy_to_universal, get_bulk_compliance_frameworks_universal, + get_universal_compliance_entry_point_dirs, load_compliance_framework_universal, ) from tests.lib.outputs.compliance.fixtures import ( @@ -1237,3 +1238,91 @@ class TestGetBulkUniversalEntryPoints: assert "pkg_a_1.0" in bulk assert "pkg_b_1.0" in bulk + + +class TestGetUniversalComplianceEntryPointDirs: + """Directories external packages contribute.""" + + @staticmethod + def _entry_point(path=None, *, file_path=None, load_error=None): + ep = MagicMock() + ep.name = "external" + ep.group = "prowler.compliance.universal" + if load_error is not None: + ep.load.side_effect = load_error + return ep + module = MagicMock() + if path is not None: + module.__path__ = [path] + else: + # A module, not a package: only __file__. + del module.__path__ + module.__file__ = file_path + ep.load.return_value = module + return ep + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_returns_entry_point_dirs_in_order(self, mock_ep): + with ( + tempfile.TemporaryDirectory() as dir_a, + tempfile.TemporaryDirectory() as dir_b, + ): + mock_ep.return_value = [ + self._entry_point(dir_a), + self._entry_point(dir_b), + ] + + assert get_universal_compliance_entry_point_dirs() == [dir_a, dir_b] + + mock_ep.assert_called_with(group="prowler.compliance.universal") + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_dedupes_the_same_directory(self, mock_ep): + """Two entry points, one directory: loaded once.""" + with tempfile.TemporaryDirectory() as ep_dir: + mock_ep.return_value = [ + self._entry_point(ep_dir), + self._entry_point(ep_dir), + ] + + assert get_universal_compliance_entry_point_dirs() == [ep_dir] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_dedupes_a_directory_reached_through_a_symlink(self, mock_ep, tmp_path): + """Same directory behind a symlink: still loaded once.""" + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real, target_is_directory=True) + mock_ep.return_value = [ + self._entry_point(str(real)), + self._entry_point(str(link)), + ] + + assert get_universal_compliance_entry_point_dirs() == [str(real)] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_skips_paths_that_are_not_directories(self, mock_ep): + mock_ep.return_value = [self._entry_point("/does/not/exist")] + + assert get_universal_compliance_entry_point_dirs() == [] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_a_broken_entry_point_does_not_hide_the_others(self, mock_ep): + with tempfile.TemporaryDirectory() as ep_dir: + mock_ep.return_value = [ + self._entry_point(load_error=ImportError("boom")), + self._entry_point(ep_dir), + ] + + assert get_universal_compliance_entry_point_dirs() == [ep_dir] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_module_without_path_falls_back_to_its_file_directory(self, mock_ep): + with tempfile.TemporaryDirectory() as ep_dir: + module_file = os.path.join(ep_dir, "compliance.py") + with open(module_file, "w"): + pass + mock_ep.return_value = [self._entry_point(file_path=module_file)] + + assert get_universal_compliance_entry_point_dirs() == [ep_dir] diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index d4e7b4665d..115b6bf180 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -1624,8 +1624,11 @@ class TestCompliance: assert "custom_1.0_ext" in frameworks - @patch("prowler.config.config.importlib.metadata.entry_points") - def test_get_available_compliance_includes_external_universal(self, mock_ep): + @patch("prowler.config.config._get_ep_compliance_dirs", return_value={}) + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_get_available_compliance_includes_external_universal( + self, mock_ep, _mock_ep_dirs + ): """External universal frameworks under prowler.compliance.universal are listed, for a provider and for the provider=None case that feeds --compliance choices.""" diff --git a/ui/actions/compliance-watchlist/compliance-watchlist.test.ts b/ui/actions/compliance-watchlist/compliance-watchlist.test.ts index 8e72d0bdc5..a2b3f57c8c 100644 --- a/ui/actions/compliance-watchlist/compliance-watchlist.test.ts +++ b/ui/actions/compliance-watchlist/compliance-watchlist.test.ts @@ -93,6 +93,7 @@ describe("getComplianceCatalog", () => { watchlistCount: 0, eligibleProviderTypes: [], }, + unavailable: true, }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -163,7 +164,7 @@ describe("getComplianceCatalog", () => { ); }); - it("degrades to an empty catalog when the request fails", async () => { + it("flags an empty catalog as unavailable when the request fails", async () => { fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 500)); const catalog = await getComplianceCatalog(); @@ -175,9 +176,21 @@ describe("getComplianceCatalog", () => { watchlistCount: 0, eligibleProviderTypes: [], }, + unavailable: true, }); }); + it("does not flag a catalog that is legitimately empty", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ data: [], meta: { pagination: { page: 1, pages: 1 } } }), + ); + + const catalog = await getComplianceCatalog(); + + expect(catalog.entries).toEqual([]); + expect(catalog.unavailable).toBe(false); + }); + it("degrades to an empty catalog when fetch throws", async () => { fetchMock.mockRejectedValue(new Error("network down")); @@ -186,7 +199,8 @@ describe("getComplianceCatalog", () => { expect(catalog.entries).toEqual([]); }); - it("keeps the rest of the catalog when a single page fails", async () => { + it("flags the catalog as unavailable when a single page fails", async () => { + // Given - one of three catalog pages times out fetchMock .mockResolvedValueOnce( jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 3 })), @@ -196,12 +210,15 @@ describe("getComplianceCatalog", () => { jsonResponse(catalogPage("iso27001_aws", { page: 3, pages: 3 })), ); + // When - the catalog keeps the pages it could read const catalog = await getComplianceCatalog(); + // Then - consumers know the merged result is incomplete expect(catalog.entries.map((entry) => entry.complianceId)).toEqual([ "cis_1.4_aws", "iso27001_aws", ]); + expect(catalog.unavailable).toBe(true); }); it("bounds how many pages it requests at once", async () => { diff --git a/ui/actions/compliance-watchlist/compliance-watchlist.ts b/ui/actions/compliance-watchlist/compliance-watchlist.ts index ebfee79884..0707a5d666 100644 --- a/ui/actions/compliance-watchlist/compliance-watchlist.ts +++ b/ui/actions/compliance-watchlist/compliance-watchlist.ts @@ -12,6 +12,7 @@ import { } from "@/lib/compliance/watchlist"; import type { ComplianceCatalog, + ComplianceCatalogLoad, ComplianceWatchlistActionResult, ComplianceWatchlistBulkDiff, ComplianceWatchlistTarget, @@ -45,6 +46,12 @@ const EMPTY_CATALOG: ComplianceCatalog = { meta: { totalEntries: 0, watchlistCount: 0, eligibleProviderTypes: [] }, }; +// Gave up without an answer: distinct from a legitimately empty catalog. +const UNAVAILABLE_CATALOG: ComplianceCatalogLoad = { + ...EMPTY_CATALOG, + unavailable: true, +}; + const GENERIC_ERROR = "Could not update the compliance watchlist."; const watchlistTargetSchema = z.object({ @@ -97,9 +104,9 @@ const buildCatalogUrl = (page: number, providerTypes?: string[]): string => { export const getComplianceCatalog = async ( input: { providerTypes?: string[] } = {}, -): Promise => { +): Promise => { const parsedInput = complianceCatalogInputSchema.safeParse(input); - if (!parsedInput.success) return EMPTY_CATALOG; + if (!parsedInput.success) return UNAVAILABLE_CATALOG; const { providerTypes } = parsedInput.data; @@ -127,15 +134,16 @@ export const getComplianceCatalog = async ( }; const firstPage = await fetchPage(1); - if (!firstPage) return EMPTY_CATALOG; + if (!firstPage) return UNAVAILABLE_CATALOG; const pageCount = Math.min( Math.max(1, firstPage.pageCount), MAX_CATALOG_PAGES, ); - if (pageCount <= 1) return firstPage.catalog; + if (pageCount <= 1) return { ...firstPage.catalog, unavailable: false }; const rest: ComplianceCatalog[] = []; + let incomplete = firstPage.pageCount > MAX_CATALOG_PAGES; for (let page = 2; page <= pageCount; page += CATALOG_FETCH_CONCURRENCY) { const batch = await Promise.all( Array.from( @@ -143,13 +151,17 @@ export const getComplianceCatalog = async ( (_, index) => fetchPage(page + index), ), ); + if (batch.some((pageResult) => pageResult === null)) incomplete = true; rest.push(...batch.flatMap((page) => (page ? [page.catalog] : []))); } - return mergeCatalogPages([firstPage.catalog, ...rest]); + return { + ...mergeCatalogPages([firstPage.catalog, ...rest]), + unavailable: incomplete, + }; } catch (error) { console.error("Error fetching compliance catalog:", error); - return EMPTY_CATALOG; + return UNAVAILABLE_CATALOG; } }; diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.test.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.test.tsx index 7a68931bbc..1ac58fbbeb 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.test.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.test.tsx @@ -120,10 +120,6 @@ vi.mock("../_components/cross-provider-detail", () => ({ CrossProviderDetail: () => null, })); -vi.mock("../_lib/cross-provider-frameworks", () => ({ - resolveCrossProviderFramework: vi.fn(), -})); - vi.mock("../_lib/search-params-key", () => ({ buildSearchParamsKey: vi.fn(() => "search-params"), })); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 418334c9ab..b153c203f9 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -51,7 +51,6 @@ import { ScanEntity } from "@/types/scans"; import { CrossAccountDetail } from "../_components/cross-account-detail"; import { CrossProviderDetail } from "../_components/cross-provider-detail"; -import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks"; import { buildSearchParamsKey } from "../_lib/search-params-key"; const getSingleSearchParam = ( @@ -86,20 +85,11 @@ export default async function ComplianceDetail({ redirect("/compliance"); } - const framework = resolveCrossProviderFramework( - complianceId, - compliancetitle, - ); - if (!framework) { - notFound(); - } - - const crossProviderTitle = framework.title.split("-").join(" "); return ( - -
@@ -107,16 +97,16 @@ export default async function ComplianceDetail({
- } - > - -
-
+ + } + > + + ); } // Cross-account mode: one regular framework aggregated across every diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx index df47e8a366..6e4a8532c8 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.test.tsx @@ -36,12 +36,9 @@ vi.mock("@/actions/compliance-watchlist", () => ({ // The watchlist context reads the session through next-auth, which cannot be // imported in this environment; the watchlist behaviour has its own tests. +// It also carries the catalog this section reads its exclusions from. vi.mock("../_lib/watchlist-context", () => ({ - loadComplianceWatchlistContext: vi.fn(async () => ({ - entries: [], - eligibleProviderTypes: [], - canManage: false, - })), + loadComplianceWatchlistContext: vi.fn(), })); vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ @@ -115,6 +112,18 @@ describe("CrossAccountOverviewSection", () => { vi.mocked(getAllProviders).mockReset(); vi.mocked(getScans).mockReset(); vi.mocked(getCompliancesOverview).mockReset(); + vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ + entries: [ + makeComplianceCatalogEntry({ + complianceId: "csa_ccm_4.0", + providerType: "*", + framework: "CSA-CCM", + }), + ], + eligibleProviderTypes: [], + canManage: false, + unavailable: false, + }); }); it("renders nothing when no provider type has two or more accounts", async () => { @@ -324,11 +333,13 @@ describe("CrossAccountOverviewSection watchlist", () => { const withWatchlist = ( entries: ReturnType[], canManage = true, + unavailable = false, ) => vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ entries, eligibleProviderTypes: ["aws"], canManage, + unavailable, }); it("keeps the provider-type grouping when the filter is on", async () => { @@ -365,6 +376,14 @@ describe("CrossAccountOverviewSection watchlist", () => { expect(screen.queryByTestId("cross-account-card")).not.toBeInTheDocument(); }); + it("renders nothing when the catalog could not be read", async () => { + withWatchlist([], true, true); + + const { container } = await renderSection(); + + expect(container).toBeEmptyDOMElement(); + }); + it("ignores the filter without a catalog, so OSS never blanks out", async () => { useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true }); withWatchlist([], false); diff --git a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx index d377e21274..eb2ace5a99 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-overview-section.tsx @@ -16,7 +16,7 @@ import type { SearchParamsProps } from "@/types"; import type { ComplianceOverviewData } from "@/types/compliance"; import { isKnownProviderType, type KnownProviderType } from "@/types/providers"; -import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks"; +import { loadCrossProviderFrameworks } from "../_lib/cross-provider-catalog"; import { loadComplianceWatchlistContext } from "../_lib/watchlist-context"; import type { CrossAccountFrameworkEntry } from "../_types"; @@ -87,8 +87,12 @@ export const CrossAccountOverviewSection = async ({ scansByType.filter((entry) => entry !== null), ); + // Universal frameworks belong to "Across providers" above. Without the + // catalog we cannot tell them apart, and that section already reports it. + const catalog = await loadCrossProviderFrameworks(); + if (catalog.unavailable) return null; const universalIds = new Set( - CROSS_PROVIDER_FRAMEWORKS.map((entry) => entry.complianceId), + catalog.frameworks.map((entry) => entry.complianceId), ); const entriesByType = await Promise.all( diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.test.tsx new file mode 100644 index 0000000000..9fa2b50586 --- /dev/null +++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.test.tsx @@ -0,0 +1,170 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; + +import { + getCrossProviderComplianceOverview, + getLatestCrossProviderPdf, +} from "../_actions/cross-provider"; +import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; + +import { CrossProviderDetail } from "./cross-provider-detail"; + +vi.mock("@/actions/manage-groups/manage-groups", () => ({ + getAllProviderGroups: vi.fn(), +})); + +vi.mock("@/actions/providers", () => ({ + getAllProviders: vi.fn(), +})); + +vi.mock("@/components/icons/compliance/IconCompliance", () => ({ + getComplianceIcon: vi.fn(() => "/compliance.svg"), +})); + +vi.mock("@/components/lighthouse/context-contributor", () => ({ + LighthouseContextContributor: () => null, +})); + +vi.mock("@/components/shadcn/content-layout", () => ({ + ContentLayout: ({ + title, + children, + }: { + title: string; + children: ReactNode; + }) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/lib/compliance/compliance-mapper", () => ({ + getComplianceMapper: () => ({ + getTopFailedSections: () => ({ + items: [], + type: "section", + prepopulated: false, + }), + mapComplianceData: () => [], + }), +})); + +vi.mock("../_actions/cross-provider", () => ({ + getCrossProviderComplianceOverview: vi.fn(), + getLatestCrossProviderPdf: vi.fn(), +})); + +vi.mock("../_lib/aggregated-compliance-detail", () => ({ + getAggregatedInitialExpandedKeys: () => [], + getAggregatedRequirementsTotals: () => ({ pass: 0, fail: 0, manual: 0 }), +})); + +vi.mock("../_lib/cross-provider-accordion", () => ({ + toCrossProviderAccordionItems: () => [], +})); + +vi.mock("../_lib/cross-provider-adapter", () => ({ + buildRequirementExtrasMap: () => new Map(), + computeProviderBreakdown: () => [], + crossProviderToMapperInput: () => ({ + attributesData: {}, + requirementsData: {}, + }), +})); + +vi.mock("./aggregated-compliance-detail", () => ({ + AggregatedComplianceDetail: ({ + compliancetitle, + }: { + compliancetitle: string; + }) => ( +
+ ), +})); + +vi.mock("./cross-provider-filters", () => ({ + CrossProviderFilters: () => null, +})); + +vi.mock("./cross-provider-hub-link", () => ({ + CrossProviderHubLink: () => null, +})); + +vi.mock("./cross-provider-pdf-button", () => ({ + CrossProviderPdfButton: () => null, +})); + +vi.mock("./provider-coverage-card", () => ({ + ProviderCoverageCard: () => null, +})); + +describe("CrossProviderDetail", () => { + beforeEach(() => { + vi.mocked(getAllProviders).mockResolvedValue({ + data: [], + links: { first: "", last: "", next: null, prev: null }, + meta: { pagination: { page: 1, pages: 1, count: 0 }, version: "" }, + }); + vi.mocked(getAllProviderGroups).mockResolvedValue({ + data: [], + links: { first: "", last: "", next: null, prev: null }, + meta: { pagination: { page: 1, pages: 1, count: 0 }, version: "" }, + }); + vi.mocked(getLatestCrossProviderPdf).mockResolvedValue(null); + vi.mocked(getCrossProviderComplianceOverview).mockResolvedValue({ + status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS, + response: { + data: { + type: "cross-provider-compliance-overviews", + id: "acme_1.0", + attributes: { + compliance_id: "acme_1.0", + framework: "ACME", + name: "ACME Framework", + version: "1.0", + description: "External framework", + compatible_providers: ["aws"], + requested_providers: ["aws"], + providers: ["aws"], + scan_ids: [], + scan_ids_by_provider: {}, + requirements_passed: 0, + requirements_failed: 0, + requirements_manual: 0, + total_requirements: 0, + requirements: [], + }, + }, + }, + }); + }); + + it("uses API identity instead of route-controlled title and version", async () => { + // Given - a valid framework id with spoofed route metadata + const props = { + compliancetitle: "Spoofed-Framework", + complianceId: "acme_1.0", + searchParams: { version: "999.0" }, + }; + + // When - the server detail renders the API result + render(await CrossProviderDetail(props)); + + // Then - every visible identity comes from the validated overview + expect(screen.getByTestId("content-layout")).toHaveAttribute( + "data-title", + "ACME - 1.0", + ); + expect(getComplianceIcon).toHaveBeenCalledWith("ACME"); + expect(screen.getByTestId("aggregated-compliance")).toHaveAttribute( + "data-title", + "ACME", + ); + }); +}); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx index 418e8b8db9..9bdfc91688 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx @@ -5,9 +5,11 @@ import { getAllProviders } from "@/actions/providers"; import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor"; import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { ContentLayout } from "@/components/shadcn/content-layout"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants"; import { buildComplianceContext } from "@/lib/lighthouse/context/contributions"; +import { isKnownProviderType } from "@/types/providers"; import { getCrossProviderComplianceOverview, @@ -23,10 +25,7 @@ import { computeProviderBreakdown, crossProviderToMapperInput, } from "../_lib/cross-provider-adapter"; -import { - CROSS_PROVIDER_FRAMEWORKS, - parseCrossProviderFilters, -} from "../_lib/cross-provider-frameworks"; +import { parseCrossProviderFilters } from "../_lib/cross-provider-frameworks"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; import { AggregatedComplianceDetail } from "./aggregated-compliance-detail"; @@ -72,31 +71,45 @@ export const CrossProviderDetail = async ({ overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR ) { - return ; + return ( + + + + ); } if ( overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR ) { - return ; + return ( + + + + ); } const overviewData = overviewResponse.response.data; if (!overviewData?.attributes) { return ( - - - - No cross-provider compliance data was returned for this framework. - Universal frameworks aggregate the latest completed scan of every - compatible provider — run a scan to populate this view. - - + + + + + No cross-provider compliance data was returned for this framework. + Universal frameworks aggregate the latest completed scan of every + compatible provider — run a scan to populate this view. + + + ); } const attrs = overviewData.attributes; + const frameworkTitle = attrs.framework || attrs.name || "Compliance"; + const pageTitle = attrs.version + ? `${frameworkTitle} - ${attrs.version}` + : frameworkTitle; // Scoped to the EXACT scans the overview resolved (not the raw filters), so // an offered "Download latest" always matches the data on screen even if a @@ -128,13 +141,14 @@ export const CrossProviderDetail = async ({ targetSection, ); - const catalogEntry = CROSS_PROVIDER_FRAMEWORKS.find( - (entry) => entry.complianceId === complianceId, - ); - const compatibleTypes = - catalogEntry?.compatibleProviders ?? - providerBreakdown.map((b) => b.provider); - const logoPath = getComplianceIcon(compliancetitle); + // What the framework declares, so externally registered ones are covered. + const compatibleTypes: string[] = attrs.compatible_providers.length + ? attrs.compatible_providers + : providerBreakdown.map((b) => b.provider); + // Select and breakdown both need an icon and a label; the summary above + // still counts the type as compatible. + const selectableTypes = compatibleTypes.filter(isKnownProviderType); + const logoPath = getComplianceIcon(frameworkTitle); const providerAccounts: CrossProviderAccountOption[] = ( providersData?.data || [] @@ -155,7 +169,7 @@ export const CrossProviderDetail = async ({ ).map((group) => ({ id: group.id, name: group.attributes.name })); return ( - <> + - {attrs.name || compliancetitle.split("-").join(" ")} + {attrs.name || frameworkTitle} } description={ @@ -196,7 +210,7 @@ export const CrossProviderDetail = async ({ } filters={ @@ -211,6 +225,6 @@ export const CrossProviderDetail = async ({ accordionItems={accordionItems} initialExpandedKeys={initialExpandedKeys} /> - + ); }; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-framework-grid.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-framework-grid.tsx index 3f6ffe7077..4bfebef22f 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-framework-grid.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-framework-grid.tsx @@ -23,9 +23,6 @@ interface CrossProviderFrameworkGridProps { cards: CrossProviderCard[]; /** MANAGE_SCANS, forwarded to each card's pin. */ canManageWatchlist: boolean; - /** False when the tenant has no catalog at all (OSS), in which case the - * stored filter must not be able to blank the grid. */ - watchlistEnabled: boolean; } /** @@ -37,11 +34,9 @@ interface CrossProviderFrameworkGridProps { export const CrossProviderFrameworkGrid = ({ cards, canManageWatchlist, - watchlistEnabled, }: CrossProviderFrameworkGridProps) => { - const showOnlyWatchlist = useShowOnlyWatchlist(); - - const filterToWatchlist = watchlistEnabled && showOnlyWatchlist; + // Cards derive from the catalog, so no catalog means no cards to filter. + const filterToWatchlist = useShowOnlyWatchlist(); const isPinned = (card: CrossProviderCard) => card.watchlist.state === WATCHLIST_PIN_STATE.PINNED; const visibleCards = filterToWatchlist ? cards.filter(isPinned) : cards; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx index 362780f5d9..de6a2c87b8 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx @@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ACTION_ERROR_STATUS, USAGE_LIMIT_MESSAGE } from "@/lib/action-errors"; import { useComplianceWatchlistViewStore } from "@/store/compliance/store"; import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist"; +import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist"; import { getCrossProviderComplianceOverview } from "../_actions/cross-provider"; -import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks"; import { loadComplianceWatchlistContext } from "../_lib/watchlist-context"; import type { CrossProviderOverviewResult } from "../_types"; import { @@ -40,12 +40,9 @@ vi.mock("@/actions/compliance-watchlist", () => ({ // The watchlist context reads the session through next-auth, which cannot be // imported in this environment; the watchlist behaviour has its own tests. +// It also carries the catalog the section builds its cards from. vi.mock("../_lib/watchlist-context", () => ({ - loadComplianceWatchlistContext: vi.fn(async () => ({ - entries: [], - eligibleProviderTypes: [], - canManage: false, - })), + loadComplianceWatchlistContext: vi.fn(), })); vi.mock("./cross-provider-filters", () => ({ @@ -72,6 +69,45 @@ vi.mock("./cross-provider-framework-card", () => ({ ), })); +const DORA_ID = "dora_2022_2554"; + +// One card per entry, ordered by title. +const UNIVERSAL_FRAMEWORKS = [ + { complianceId: "csa_ccm_4.0", framework: "CSA-CCM" }, + { complianceId: "cis_controls_8.1", framework: "CIS-Controls" }, + { complianceId: "cmmc_2.0", framework: "CMMC" }, + { complianceId: DORA_ID, framework: "DORA" }, +]; + +const EXPECTED_TITLES = ["CIS-Controls", "CMMC", "CSA-CCM", "DORA"]; + +const catalogEntries = (pinned: string[] = []): ComplianceCatalogEntry[] => + UNIVERSAL_FRAMEWORKS.map(({ complianceId, framework }) => + makeComplianceCatalogEntry({ + complianceId, + // The catalog keys a universal framework under `*`. + providerType: "*", + framework, + inWatchlist: pinned.includes(complianceId), + watchlistEntryId: pinned.includes(complianceId) + ? `entry-${complianceId}` + : null, + }), + ); + +const withCatalog = ( + entries: ComplianceCatalogEntry[], + eligibleProviderTypes: string[] = ["aws", "azure"], + canManage = true, + unavailable = false, +) => + vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ + entries, + eligibleProviderTypes, + canManage, + unavailable, + }); + const successResult = (complianceId: string): CrossProviderOverviewResult => ({ status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS, response: { @@ -110,13 +146,67 @@ const renderOverview = async () => describe("CrossProviderOverview", () => { beforeEach(() => { vi.mocked(getCrossProviderComplianceOverview).mockReset(); + vi.mocked(loadComplianceWatchlistContext).mockReset(); + withCatalog(catalogEntries()); + }); + + it("renders one card per universal framework the catalog reports", async () => { + // ACME is what an entry-point package would contribute. + withCatalog([ + ...catalogEntries(), + makeComplianceCatalogEntry({ + complianceId: "acme_1.0", + providerType: "*", + framework: "ACME", + }), + ]); + vi.mocked(getCrossProviderComplianceOverview).mockImplementation( + async ({ complianceId }) => successResult(complianceId), + ); + + // When + await renderOverview(); + + // Then + const cards = screen.getAllByTestId("framework-card"); + expect(cards.map((card) => card.textContent)).toEqual([ + "ACME", + ...EXPECTED_TITLES, + ]); + }); + + it("renders no cards when the catalog reports no universal framework", async () => { + withCatalog([]); + + await renderOverview(); + + expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument(); + expect(getCrossProviderComplianceOverview).not.toHaveBeenCalled(); + expect( + screen.getByText(/No cross-provider compliance data yet/i), + ).toBeInTheDocument(); + }); + + it("reports the failure instead of claiming there is no data", async () => { + withCatalog([], ["aws", "azure"], true, true); + + await renderOverview(); + + expect( + screen.getByText(CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE), + ).toBeInTheDocument(); + expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument(); + expect( + screen.queryByText(/No cross-provider compliance data yet/i), + ).not.toBeInTheDocument(); + expect(getCrossProviderComplianceOverview).not.toHaveBeenCalled(); }); it("degrades to a partial view when a single framework fails to load", async () => { // Given: DORA fails, the other frameworks load vi.mocked(getCrossProviderComplianceOverview).mockImplementation( async ({ complianceId }) => - complianceId === "dora_2022_2554" + complianceId === DORA_ID ? loadErrorResult : successResult(complianceId), ); @@ -126,7 +216,7 @@ describe("CrossProviderOverview", () => { // Then: loaded cards render, the failed framework is called out by name expect(screen.getAllByTestId("framework-card")).toHaveLength( - CROSS_PROVIDER_FRAMEWORKS.length - 1, + UNIVERSAL_FRAMEWORKS.length - 1, ); expect(screen.getByText(/Could not load DORA/)).toBeInTheDocument(); expect( @@ -154,7 +244,7 @@ describe("CrossProviderOverview", () => { // Given: one framework hits the usage limit (402) vi.mocked(getCrossProviderComplianceOverview).mockImplementation( async ({ complianceId }) => - complianceId === "dora_2022_2554" + complianceId === DORA_ID ? { status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR, result: { status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED }, @@ -173,58 +263,32 @@ describe("CrossProviderOverview", () => { }); }); -// DORA's compatible provider types, per the static catalog. -const DORA_ID = "dora_2022_2554"; - -const catalogEntry = ( - complianceId: string, - providerType: string, - inWatchlist: boolean, -) => - makeComplianceCatalogEntry({ - complianceId, - providerType, - inWatchlist, - watchlistEntryId: inWatchlist ? `entry-${providerType}` : null, - }); - describe("CrossProviderOverview watchlist", () => { beforeEach(() => { localStorage.clear(); useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false }); + vi.mocked(loadComplianceWatchlistContext).mockReset(); vi.mocked(getCrossProviderComplianceOverview).mockImplementation( async ({ complianceId }) => successResult(complianceId), ); }); - const withWatchlist = ( - entries: ReturnType[], - eligibleProviderTypes: string[], - canManage = true, - ) => - vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ - entries, - eligibleProviderTypes, - canManage, - }); - - it("keeps the configured framework order when one is pinned", async () => { - // One card, one entry: the catalog keys a universal framework under `*`. - withWatchlist([catalogEntry(DORA_ID, "*", true)], ["aws", "azure"]); + it("keeps the catalog order when one framework is pinned", async () => { + withCatalog(catalogEntries([DORA_ID])); await renderOverview(); const cards = screen.getAllByTestId("framework-card"); - expect(cards).toHaveLength(CROSS_PROVIDER_FRAMEWORKS.length); - expect(cards.map((card) => card.textContent)).toEqual( - CROSS_PROVIDER_FRAMEWORKS.map((framework) => framework.title), + expect(cards.map((card) => card.textContent)).toEqual(EXPECTED_TITLES); + expect(cards[EXPECTED_TITLES.indexOf("DORA")]).toHaveAttribute( + "data-pin-state", + "pinned", ); - expect(cards[2]).toHaveAttribute("data-pin-state", "pinned"); }); it("narrows the grid to the pinned frameworks when the filter is on", async () => { useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true }); - withWatchlist([catalogEntry(DORA_ID, "*", true)], ["aws", "azure"]); + withCatalog(catalogEntries([DORA_ID])); await renderOverview(); @@ -235,7 +299,7 @@ describe("CrossProviderOverview watchlist", () => { it("explains the blank grid when nothing universal is pinned", async () => { useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true }); - withWatchlist([catalogEntry(DORA_ID, "*", false)], ["aws"]); + withCatalog(catalogEntries()); await renderOverview(); @@ -245,14 +309,13 @@ describe("CrossProviderOverview watchlist", () => { expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument(); }); - it("ignores the filter without a catalog, so OSS never blanks out", async () => { - useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true }); - withWatchlist([], [], false); + it("cannot manage the watchlist without the permission", async () => { + withCatalog(catalogEntries([DORA_ID]), ["aws", "azure"], false); await renderOverview(); - expect(screen.getAllByTestId("framework-card")).toHaveLength( - CROSS_PROVIDER_FRAMEWORKS.length, - ); + for (const card of screen.getAllByTestId("framework-card")) { + expect(card).toHaveAttribute("data-can-manage", "false"); + } }); }); diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx index fb31c50713..69b7150de3 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx @@ -18,12 +18,12 @@ import { } from "@/lib/lighthouse/context/constants"; import { buildComplianceContext } from "@/lib/lighthouse/context/contributions"; import { SearchParamsProps } from "@/types"; -import type { KnownProviderType } from "@/types/providers"; +import { isKnownProviderType } from "@/types/providers"; import { getCrossProviderComplianceOverview } from "../_actions/cross-provider"; import { computeProviderBreakdown } from "../_lib/cross-provider-adapter"; +import { loadCrossProviderFrameworks } from "../_lib/cross-provider-catalog"; import { - CROSS_PROVIDER_FRAMEWORKS, type CrossProviderFrameworkEntry, parseCrossProviderFilters, } from "../_lib/cross-provider-frameworks"; @@ -51,15 +51,18 @@ const emptySummary = ( requirementsFailed: 0, requirementsManual: 0, totalRequirements: 0, - providerBreakdown: entry.compatibleProviders.map((provider) => ({ - provider, - pass: 0, - fail: 0, - manual: 0, - total: 0, - score: 0, - unscanned: true, - })), + // Chips need an icon and a label, which only known types have. + providerBreakdown: entry.providerTypes + .filter(isKnownProviderType) + .map((provider) => ({ + provider, + pass: 0, + fail: 0, + manual: 0, + total: 0, + score: 0, + unscanned: true, + })), }); export const CrossProviderOverview = async ({ @@ -69,16 +72,10 @@ export const CrossProviderOverview = async ({ }) => { const filters = parseCrossProviderFilters(searchParams); - const [responses, providersData, providerGroupsData, watchlist] = + // The roll-ups can only fan out once we know which frameworks exist. + const [catalog, providersData, providerGroupsData, watchlist] = await Promise.all([ - Promise.all( - CROSS_PROVIDER_FRAMEWORKS.map((entry) => - getCrossProviderComplianceOverview({ - complianceId: entry.complianceId, - filters, - }).then((result) => ({ entry, result })), - ), - ), + loadCrossProviderFrameworks(), getAllProviders(), getAllProviderGroups(), // No provider type narrowing: a universal framework spans many types and @@ -86,6 +83,21 @@ export const CrossProviderOverview = async ({ loadComplianceWatchlistContext(), ]); + // The empty state would claim there is no data. We just don't know. + if (catalog.unavailable) { + return ; + } + const frameworks = catalog.frameworks; + + const responses = await Promise.all( + frameworks.map((entry) => + getCrossProviderComplianceOverview({ + complianceId: entry.complianceId, + filters, + }).then((result) => ({ entry, result })), + ), + ); + // Action errors (402 usage limit, 403) gate the whole feature, not one // framework, so any of them replaces the tab instead of degrading it. const actionError = responses.find( @@ -137,17 +149,18 @@ export const CrossProviderOverview = async ({ }; }); - const compatibleTypes = Array.from( - new Set( - CROSS_PROVIDER_FRAMEWORKS.flatMap((entry) => entry.compatibleProviders), - ), + const coveredTypes = Array.from( + new Set(frameworks.flatMap((entry) => entry.providerTypes)), ).sort(); + // Externally registered types have no icon or label; their accounts still + // reach the account select below. + const selectableTypes = coveredTypes.filter(isKnownProviderType); const providerAccounts: CrossProviderAccountOption[] = ( providersData?.data || [] ) .filter((provider) => - compatibleTypes.some((type) => type === provider.attributes.provider), + coveredTypes.some((type) => type === provider.attributes.provider), ) .map((provider) => ({ id: provider.id, @@ -168,9 +181,8 @@ export const CrossProviderOverview = async ({ watchlist: resolveUniversalWatchlistState({ complianceId: summary.complianceId, compatibleProviders: - CROSS_PROVIDER_FRAMEWORKS.find( - (entry) => entry.complianceId === summary.complianceId, - )?.compatibleProviders ?? [], + frameworks.find((entry) => entry.complianceId === summary.complianceId) + ?.providerTypes ?? [], eligibleProviderTypes: watchlist.eligibleProviderTypes, catalogIndex, }), @@ -197,7 +209,7 @@ export const CrossProviderOverview = async ({ /> ))} @@ -237,7 +249,6 @@ export const CrossProviderOverview = async ({ 0} /> diff --git a/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts b/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts new file mode 100644 index 0000000000..4df0c382c2 --- /dev/null +++ b/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-catalog.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist"; +import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist"; + +import { loadCrossProviderFrameworks } from "../cross-provider-catalog"; +import { loadComplianceWatchlistContext } from "../watchlist-context"; + +// Reads the session through next-auth, unimportable here; tested separately. +vi.mock("../watchlist-context", () => ({ + loadComplianceWatchlistContext: vi.fn(), +})); + +const universalEntry = ( + complianceId: string, + overrides: Partial = {}, +) => + makeComplianceCatalogEntry({ + complianceId, + providerType: "*", + framework: complianceId.toUpperCase(), + ...overrides, + }); + +const withCatalog = (entries: ComplianceCatalogEntry[], unavailable = false) => + vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({ + entries, + eligibleProviderTypes: ["aws", "azure", "gcp"], + canManage: true, + unavailable, + }); + +describe("loadCrossProviderFrameworks", () => { + beforeEach(() => { + vi.mocked(loadComplianceWatchlistContext).mockReset(); + }); + + it("maps the universal catalog entries onto framework cards", async () => { + withCatalog([ + universalEntry("dora_2022_2554", { + framework: "DORA", + version: "2022/2554", + description: "Digital Operational Resilience Act.", + }), + ]); + + const { frameworks, unavailable } = await loadCrossProviderFrameworks(); + + expect(unavailable).toBe(false); + expect(frameworks).toEqual([ + { + complianceId: "dora_2022_2554", + title: "DORA", + version: "2022/2554", + description: "Digital Operational Resilience Act.", + providerTypes: ["aws", "azure", "gcp"], + }, + ]); + }); + + it("includes a framework registered outside the SDK", async () => { + withCatalog([ + universalEntry("acme_1.0", { framework: "ACME" }), + universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }), + ]); + + const { frameworks } = await loadCrossProviderFrameworks(); + + expect(frameworks.map((entry) => entry.complianceId)).toContain("acme_1.0"); + }); + + it("keeps externally registered provider types", async () => { + // Dropping them here would leave the framework with no way to be pinned. + withCatalog([ + universalEntry("acme_1.0", { + framework: "ACME", + providerTypes: ["aws", "totally-external-provider"], + }), + ]); + + const { frameworks } = await loadCrossProviderFrameworks(); + + expect(frameworks[0].providerTypes).toEqual([ + "aws", + "totally-external-provider", + ]); + }); + + it("drops provider-scoped entries", async () => { + withCatalog([ + universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }), + makeComplianceCatalogEntry({ + complianceId: "cis_1.4_aws", + providerType: "aws", + framework: "CIS", + }), + ]); + + const { frameworks } = await loadCrossProviderFrameworks(); + + expect(frameworks.map((entry) => entry.complianceId)).toEqual([ + "csa_ccm_4.0", + ]); + }); + + it("drops a framework with no title, which has no route to link to", async () => { + withCatalog([ + universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }), + universalEntry("nameless_1.0", { framework: " " }), + ]); + + const { frameworks } = await loadCrossProviderFrameworks(); + + expect(frameworks.map((entry) => entry.complianceId)).toEqual([ + "csa_ccm_4.0", + ]); + }); + + it("orders the cards by title", async () => { + withCatalog([ + universalEntry("dora_2022_2554", { framework: "DORA" }), + universalEntry("cmmc_2.0", { framework: "CMMC" }), + ]); + + const { frameworks } = await loadCrossProviderFrameworks(); + + expect(frameworks.map((entry) => entry.title)).toEqual(["CMMC", "DORA"]); + }); + + it("titles the shipped frameworks so their icon resolves", async () => { + withCatalog([ + universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }), + universalEntry("cis_controls_8.1", { framework: "CIS-Controls" }), + universalEntry("dora_2022_2554", { framework: "DORA" }), + universalEntry("cmmc_2.0", { framework: "CMMC" }), + ]); + + const { frameworks } = await loadCrossProviderFrameworks(); + for (const entry of frameworks) { + expect(getComplianceIcon(entry.title), entry.title).not.toBeNull(); + } + }); + + it("returns nothing when the catalog is empty", async () => { + withCatalog([]); + + expect(await loadCrossProviderFrameworks()).toEqual({ + frameworks: [], + unavailable: false, + }); + }); + + it("reports a catalog that could not be read", async () => { + withCatalog([], true); + + expect(await loadCrossProviderFrameworks()).toEqual({ + frameworks: [], + unavailable: true, + }); + }); +}); diff --git a/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-frameworks.test.ts b/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-frameworks.test.ts index b087f6b99e..79934ff874 100644 --- a/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-frameworks.test.ts +++ b/ui/app/(prowler)/compliance/_lib/__tests__/cross-provider-frameworks.test.ts @@ -1,60 +1,17 @@ import { describe, expect, it } from "vitest"; -import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; -import { PROVIDER_TYPES } from "@/types/providers"; +import type { CrossProviderFrameworkEntry } from "../cross-provider-frameworks"; +import { buildCrossProviderDetailHref } from "../cross-provider-frameworks"; -import { - buildCrossProviderDetailHref, - CROSS_PROVIDER_FRAMEWORKS, - resolveCrossProviderFramework, -} from "../cross-provider-frameworks"; - -describe("CROSS_PROVIDER_FRAMEWORKS catalog", () => { - it("uses titles that resolve to a compliance icon", () => { - for (const entry of CROSS_PROVIDER_FRAMEWORKS) { - expect(getComplianceIcon(entry.title), entry.title).not.toBeNull(); - } - }); - - it("only lists providers the UI knows how to render", () => { - for (const entry of CROSS_PROVIDER_FRAMEWORKS) { - for (const provider of entry.compatibleProviders) { - expect(PROVIDER_TYPES).toContain(provider); - } - expect(new Set(entry.compatibleProviders).size).toBe( - entry.compatibleProviders.length, - ); - } - }); -}); - -describe("resolveCrossProviderFramework", () => { - it.each([ - [undefined, "CSA-CCM"], - ["csa_ccm_4.0", "DORA"], - ["csa_ccm_4.0", "csa-ccm"], - ])("rejects invalid detail links", (complianceId, title) => { - expect(resolveCrossProviderFramework(complianceId, title)).toBeUndefined(); - }); - - it("resolves the catalog entry for a valid detail link", () => { - // Given - const expected = CROSS_PROVIDER_FRAMEWORKS[0]; - - // When - const framework = resolveCrossProviderFramework( - expected.complianceId, - expected.title, - ); - - // Then - expect(framework).toEqual(expected); - }); -}); +const entry: CrossProviderFrameworkEntry = { + complianceId: "csa_ccm_4.0", + title: "CSA-CCM", + version: "4.0", + description: "CSA Cloud Controls Matrix v4.0.", + providerTypes: ["aws", "azure"], +}; describe("buildCrossProviderDetailHref", () => { - const entry = CROSS_PROVIDER_FRAMEWORKS[0]; - it("builds the detail path with cross-provider mode and identity params", () => { const href = buildCrossProviderDetailHref(entry); diff --git a/ui/app/(prowler)/compliance/_lib/__tests__/watchlist-context.test.ts b/ui/app/(prowler)/compliance/_lib/__tests__/watchlist-context.test.ts index 11e2644e7c..a50a7e4145 100644 --- a/ui/app/(prowler)/compliance/_lib/__tests__/watchlist-context.test.ts +++ b/ui/app/(prowler)/compliance/_lib/__tests__/watchlist-context.test.ts @@ -90,17 +90,17 @@ describe("loadComplianceWatchlistContext in Cloud", () => { }); it("degrades to the empty context when the session lookup rejects", async () => { - // The catalog already swallows its own failures; `auth()` rejecting has to - // cost the page its watchlist affordances rather than its compliance data, - // since every surface awaits this loader during the server render. + // Every surface awaits this loader, so a rejected `auth()` costs the + // watchlist affordances, not the compliance data. authMock.mockRejectedValue(new Error("session unavailable")); const consoleError = vi .spyOn(console, "error") .mockImplementation(() => {}); - expect(await loadComplianceWatchlistContext()).toEqual( - EMPTY_WATCHLIST_CONTEXT, - ); + expect(await loadComplianceWatchlistContext()).toEqual({ + ...EMPTY_WATCHLIST_CONTEXT, + unavailable: true, + }); consoleError.mockRestore(); }); diff --git a/ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts b/ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts new file mode 100644 index 0000000000..398d758677 --- /dev/null +++ b/ui/app/(prowler)/compliance/_lib/cross-provider-catalog.ts @@ -0,0 +1,35 @@ +import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist"; + +import type { CrossProviderFrameworkEntry } from "./cross-provider-frameworks"; +import { loadComplianceWatchlistContext } from "./watchlist-context"; + +export interface CrossProviderCatalog { + frameworks: CrossProviderFrameworkEntry[]; + /** Empty for lack of an answer, not for lack of frameworks. */ + unavailable: boolean; +} + +/** + * Universal frameworks for the "Across providers" section, read from the API + * catalog (`scope=universal`) so entry-point-registered ones show up too. + * Backed by the same per-render cached request the watchlist context makes. + */ +export const loadCrossProviderFrameworks = + async (): Promise => { + const { entries, unavailable } = await loadComplianceWatchlistContext(); + + const frameworks = entries + .filter((entry) => entry.scope === WATCHLIST_SCOPE.UNIVERSAL) + .map((entry) => ({ + complianceId: entry.complianceId, + // Short name (CSA-CCM, DORA), not `name`: it keys the icon and the route. + title: entry.framework, + version: entry.version, + description: entry.description, + providerTypes: entry.providerTypes, + })) + .filter((entry) => entry.title.trim().length > 0) + .sort((a, b) => a.title.localeCompare(b.title)); + + return { frameworks, unavailable }; + }; diff --git a/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts b/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts index 86643aaa86..00499ef7c9 100644 --- a/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts +++ b/ui/app/(prowler)/compliance/_lib/cross-provider-frameworks.ts @@ -1,103 +1,24 @@ -import type { KnownProviderType } from "@/types/providers"; - import type { CrossProviderApiFilters } from "../_types"; -// Catalog of universal compliance frameworks served by the cross-provider -// endpoint. Hardcoded because the API has no listing endpoint for universal -// framework ids: when a new universal JSON ships in the SDK -// (prowler/compliance/.json), add an entry here. - +// One universal framework card. Built from the API catalog, never hardcoded: +// see `./cross-provider-catalog`. export interface CrossProviderFrameworkEntry { - /** Universal framework id used as filter[compliance_id]. */ complianceId: string; - /** Card/detail title; also the [compliancetitle] path segment and the - * key getComplianceIcon resolves the framework icon from. */ + /** Also the [compliancetitle] segment and the icon lookup key. */ title: string; version: string; description: string; - /** Static fallback for the per-provider chips; the API response's - * compatible_providers is authoritative at runtime. */ - compatibleProviders: KnownProviderType[]; + /** Raw from the catalog — narrow with `isKnownProviderType` only where an + * icon or label is needed. */ + providerTypes: string[]; } -export const CROSS_PROVIDER_FRAMEWORKS: CrossProviderFrameworkEntry[] = [ - { - complianceId: "csa_ccm_4.0", - title: "CSA-CCM", - version: "4.0", - description: - "CSA Cloud Controls Matrix v4.0 — a cybersecurity control framework with 197 control objectives across 17 domains.", - compatibleProviders: ["aws", "azure", "gcp", "alibabacloud", "oraclecloud"], - }, - { - complianceId: "cis_controls_8.1", - title: "CIS-Controls", - version: "8.1", - description: - "CIS Critical Security Controls v8.1 — prioritized safeguards organized into 18 controls to mitigate the most prevalent cyber-attacks.", - compatibleProviders: [ - "aws", - "azure", - "gcp", - "m365", - "kubernetes", - "github", - "googleworkspace", - "okta", - "oraclecloud", - "alibabacloud", - "cloudflare", - "mongodbatlas", - "openstack", - "vercel", - ], - }, - { - complianceId: "dora_2022_2554", - title: "DORA", - version: "2022/2554", - description: - "Digital Operational Resilience Act (EU 2022/2554) — the EU framework for the digital operational resilience of the financial sector.", - compatibleProviders: ["aws", "azure", "gcp", "alibabacloud", "cloudflare"], - }, - { - complianceId: "cmmc_2.0", - title: "CMMC", - version: "2.0", - description: - "Cybersecurity Maturity Model Certification (CMMC) 2.0 (32 CFR Part 170) — the U.S. Department of Defense program verifying that defense contractors protect FCI and CUI across three levels.", - compatibleProviders: [ - "aws", - "azure", - "gcp", - "m365", - "alibabacloud", - "oraclecloud", - ], - }, -]; - -/** Resolves only canonical catalog links. Missing, unknown, or mismatched - * identities must not reach the API as an `undefined` or unrelated filter. */ -export const resolveCrossProviderFramework = ( - complianceId: string | undefined, - title: string, -): CrossProviderFrameworkEntry | undefined => - CROSS_PROVIDER_FRAMEWORKS.find( - (entry) => entry.complianceId === complianceId && entry.title === title, - ); - -/** Cross-provider filter params forwarded from the overview into detail - * links (and consumed back by the detail page). */ const CROSS_PROVIDER_FILTER_PARAMS = [ "filter[provider_type__in]", "filter[provider_id__in]", "filter[provider_groups__in]", ] as const; -/** Parses the URL filter params every cross-provider endpoint accepts. Kept - * next to CROSS_PROVIDER_FILTER_PARAMS so the overview and detail islands - * build identical, typed filter objects. */ export const parseCrossProviderFilters = ( searchParams: Record, ): CrossProviderApiFilters => ({ diff --git a/ui/app/(prowler)/compliance/_lib/watchlist-context.ts b/ui/app/(prowler)/compliance/_lib/watchlist-context.ts index bef5a01a09..686e6b9926 100644 --- a/ui/app/(prowler)/compliance/_lib/watchlist-context.ts +++ b/ui/app/(prowler)/compliance/_lib/watchlist-context.ts @@ -9,12 +9,15 @@ export interface ComplianceWatchlistContext { entries: ComplianceCatalogEntry[]; eligibleProviderTypes: string[]; canManage: boolean; + /** Could not be read — not the same as legitimately empty. */ + unavailable: boolean; } export const EMPTY_WATCHLIST_CONTEXT: ComplianceWatchlistContext = { entries: [], eligibleProviderTypes: [], canManage: false, + unavailable: false, }; // One cached catalog/session lookup feeds every compliance surface in a render. @@ -34,10 +37,11 @@ const loadContextForKey = cache( entries: catalog.entries, eligibleProviderTypes: catalog.meta.eligibleProviderTypes, canManage: Boolean(session?.user?.permissions?.manage_scans), + unavailable: catalog.unavailable, }; } catch (error) { console.error("Error loading the compliance watchlist context:", error); - return EMPTY_WATCHLIST_CONTEXT; + return { ...EMPTY_WATCHLIST_CONTEXT, unavailable: true }; } }, ); diff --git a/ui/changelog.d/cross-provider-catalog-unavailable.fixed.md b/ui/changelog.d/cross-provider-catalog-unavailable.fixed.md new file mode 100644 index 0000000000..8bd42d749e --- /dev/null +++ b/ui/changelog.d/cross-provider-catalog-unavailable.fixed.md @@ -0,0 +1 @@ +The compliance "Across providers" section reports a failed catalog request instead of rendering the "no data yet" empty state diff --git a/ui/changelog.d/cross-provider-framework-catalog.fixed.md b/ui/changelog.d/cross-provider-framework-catalog.fixed.md new file mode 100644 index 0000000000..18d5cef965 --- /dev/null +++ b/ui/changelog.d/cross-provider-framework-catalog.fixed.md @@ -0,0 +1 @@ +The compliance "Across providers" section builds its framework list from the API catalog instead of a hardcoded set of ids, so a universal framework registered by an installed package renders like a shipped one diff --git a/ui/types/compliance-watchlist.ts b/ui/types/compliance-watchlist.ts index 6f8d2385bb..4acd08e279 100644 --- a/ui/types/compliance-watchlist.ts +++ b/ui/types/compliance-watchlist.ts @@ -51,6 +51,11 @@ export interface ComplianceCatalog { meta: ComplianceCatalogMeta; } +export interface ComplianceCatalogLoad extends ComplianceCatalog { + /** No answer was obtained — not the same as a legitimately empty catalog. */ + unavailable: boolean; +} + export interface FindingComplianceFramework { id: string; complianceId: string;