diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 392b369206..36f452ad32 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🚀 Added - `okta` provider support [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184) +- `metadata` field on resources included in finding responses (`?include=resources`), so finding consumers can read the affected resource's metadata without an extra request [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187) --- diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 008189e313..398f8b4399 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -7158,6 +7158,32 @@ class TestFindingViewSet: "id" ] == str(finding_1.resources.first().id) + def test_findings_retrieve_include_resource_metadata( + self, authenticated_client, findings_fixture + ): + finding_1, *_ = findings_fixture + resource = finding_1.resources.first() + resource.metadata = '{"VulnerabilityID": "CVE-2026-0001"}' + resource.details = "Python 3.12 base image" + resource.save() + + response = authenticated_client.get( + reverse("finding-detail", kwargs={"pk": finding_1.id}), + {"include": "resources"}, + ) + assert response.status_code == status.HTTP_200_OK + + included_resource = next( + item + for item in response.json()["included"] + if item["type"] == "resources" and item["id"] == str(resource.id) + ) + assert ( + included_resource["attributes"]["metadata"] + == '{"VulnerabilityID": "CVE-2026-0001"}' + ) + assert included_resource["attributes"]["details"] == "Python 3.12 base image" + def test_findings_invalid_retrieve(self, authenticated_client): response = authenticated_client.get( reverse("finding-detail", kwargs={"pk": "random_id"}), diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 18971452e8..03d2fecad2 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1397,6 +1397,7 @@ class ResourceIncludeSerializer(RLSSerializer): "service", "type_", "tags", + "metadata", "details", "partition", ] @@ -1404,6 +1405,7 @@ class ResourceIncludeSerializer(RLSSerializer): "id": {"read_only": True}, "inserted_at": {"read_only": True}, "updated_at": {"read_only": True}, + "metadata": {"read_only": True}, "details": {"read_only": True}, "partition": {"read_only": True}, } diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index dc1b0c62a5..132087d723 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added - `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213) +- "Resource Metadata / Evidence" tab in the finding detail drawer—reachable from the compliance requirement findings view, the Findings page, and the Resources view—rendering the affected resource's details and metadata as syntax-highlighted JSON with copy-to-clipboard, via a shared `ResourceMetadataPanel` also reused by the resource detail view [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187) --- diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index 87d37c192c..a94f34a9c4 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -116,6 +116,84 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { expect(result[0].checkId).toBe("s3_check"); }); + it("should extract resource metadata and details from the included resource", () => { + // Given — finding with an included resource exposing metadata + details + createDictMock.mockImplementation((type: string) => + type === "resources" + ? { + "resource-1": { + id: "resource-1", + attributes: { + uid: "image:python:3.12", + name: "python", + type: "Python", + details: "Python 3.12 base image", + metadata: '{"PkgName":"requests","Versions":["2.0"]}', + }, + }, + } + : {}, + ); + + const input = { + data: { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "image_vulnerability", + status: "FAIL", + severity: "critical", + check_metadata: { checktitle: "Image Vulnerability" }, + }, + relationships: { + resources: { data: [{ id: "resource-1" }] }, + scan: { data: null }, + }, + }, + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0].resourceDetails).toBe("Python 3.12 base image"); + expect(result[0].resourceMetadata).toBe( + '{"PkgName":"requests","Versions":["2.0"]}', + ); + }); + + it("should default resource metadata and details to null when absent", () => { + // Given — valid finding without an included resource + const input = { + data: [ + { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "critical", + check_metadata: { checktitle: "S3 Check" }, + }, + relationships: { + resources: { data: [] }, + scan: { data: null }, + }, + }, + ], + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result[0].resourceDetails).toBeNull(); + expect(result[0].resourceMetadata).toBeNull(); + }); + it("should normalize a single finding response into a one-item drawer array", () => { // Given — getFindingById returns a single JSON:API resource object const input = { diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index d1685eaa9f..0312df00da 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -57,6 +57,8 @@ export interface ResourceDrawerFinding { resourceRegion: string; resourceType: string; resourceGroup: string; + resourceDetails: string | null; + resourceMetadata: Record | string | null; // Provider providerType: ProviderType; providerAlias: string; @@ -260,6 +262,14 @@ export function adaptFindingsByResourceResponse( resourceRegion: (resourceAttrs.region as string | undefined) || "-", resourceType: (resourceAttrs.type as string | undefined) || "-", resourceGroup: (meta.resourcegroup as string | undefined) || "-", + resourceDetails: + (resourceAttrs.details as string | null | undefined) ?? null, + resourceMetadata: + (resourceAttrs.metadata as + | Record + | string + | null + | undefined) ?? null, // Provider providerType: ((providerAttrs.provider as string | undefined) || "aws") as ProviderType, diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index 2b617888c0..e9cae8740a 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -215,6 +215,7 @@ vi.mock("@/components/shared/query-code-editor", () => ({ HCL: "hcl", BICEP: "bicep", YAML: "yaml", + JSON: "json", }, QueryCodeEditor: ({ ariaLabel, @@ -426,6 +427,8 @@ const mockFinding: ResourceDrawerFinding = { resourceRegion: "us-east-1", resourceType: "Bucket", resourceGroup: "default", + resourceDetails: null, + resourceMetadata: null, providerType: "aws", providerAlias: "prod", providerUid: "123456789", @@ -1764,3 +1767,160 @@ describe("ResourceDetailDrawerContent — other findings delta/muted indicator", }); }); }); + +describe("ResourceDetailDrawerContent — Metadata tab", () => { + const getMetadataEditor = () => + screen + .queryAllByTestId("query-code-editor") + .find( + (editor) => + editor.getAttribute("data-aria-label") === "Resource metadata", + ); + + it("should render a Metadata tab trigger", () => { + // Given/When + render( + , + ); + + // Then + expect( + screen.getByRole("button", { name: "Resource Metadata / Evidence" }), + ).toBeInTheDocument(); + }); + + it("should render the resource metadata as formatted JSON and copy it to the clipboard", async () => { + // Given + const user = userEvent.setup(); + const findingWithMetadata: ResourceDrawerFinding = { + ...mockFinding, + resourceDetails: "Python", + resourceMetadata: { + VulnerabilityID: "CVE-2026-0001", + PkgName: "requests", + }, + }; + + render( + , + ); + + // Then — Details section + JSON editor are rendered + expect(screen.getByText("Details:")).toBeInTheDocument(); + expect(screen.getByText("Python")).toBeInTheDocument(); + + const metadataEditor = getMetadataEditor(); + expect(metadataEditor).toBeDefined(); + expect(metadataEditor).toHaveAttribute("data-language", "json"); + expect(metadataEditor?.textContent).toContain("CVE-2026-0001"); + + // When — copy the metadata JSON + await user.click( + screen.getByRole("button", { name: "Copy Resource metadata" }), + ); + + // Then + expect(mockClipboardWriteText).toHaveBeenCalledWith( + JSON.stringify(findingWithMetadata.resourceMetadata, null, 2), + ); + }); + + it("should parse stringified resource metadata", () => { + // Given + const findingWithStringMetadata: ResourceDrawerFinding = { + ...mockFinding, + resourceMetadata: '{"PkgName":"requests"}', + }; + + render( + , + ); + + // Then + expect(getMetadataEditor()?.textContent).toContain("requests"); + }); + + it("should show an empty state when no metadata or details are available", () => { + // Given/When + render( + , + ); + + // Then + expect( + screen.getByText("No metadata available for this resource."), + ).toBeInTheDocument(); + expect(getMetadataEditor()).toBeUndefined(); + }); + + it("should show a metadata skeleton while navigating", () => { + // Given/When + render( + , + ); + + // Then + expect( + screen.getByTestId("metadata-navigation-skeleton"), + ).toBeInTheDocument(); + expect( + screen.queryByText("No metadata available for this resource."), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 5175e64af0..12d3f9f465 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -53,6 +53,7 @@ import { QueryCodeEditor, type QueryEditorLanguage, } from "@/components/shared/query-code-editor"; +import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; @@ -845,6 +846,9 @@ export function ResourceDetailDrawerContent({ Finding Overview Remediation + + Resource Metadata / Evidence + Findings for this resource @@ -1072,6 +1076,21 @@ export function ResourceDetailDrawerContent({ )} + {/* Metadata */} + + {isNavigating ? ( + + ) : ( + + )} + + {/* Findings for this resource */} - {attributes.details && attributes.details.trim() !== "" && ( - -
- - Details: - -

- {attributes.details} -

-
-
- )} - - {hasMetadata && parsedMetadata && ( - {}} - /> - )} - - {!attributes.details?.trim() && !hasMetadata && ( -

- No metadata available for this resource. -

- )} +
diff --git a/ui/components/shared/resource-metadata-panel.test.tsx b/ui/components/shared/resource-metadata-panel.test.tsx new file mode 100644 index 0000000000..dfe5748bb0 --- /dev/null +++ b/ui/components/shared/resource-metadata-panel.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { ResourceMetadataPanel } from "./resource-metadata-panel"; + +vi.mock("@/components/shadcn/card/card", () => ({ + Card: ({ children, variant }: { children: ReactNode; variant?: string }) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/components/shared/query-code-editor", () => ({ + QUERY_EDITOR_LANGUAGE: { + JSON: "json", + }, + QueryCodeEditor: ({ + ariaLabel, + value, + copyValue, + }: { + ariaLabel: string; + value: string; + copyValue?: string; + }) => ( +
+ ), +})); + +const EMPTY_STATE = "No metadata available for this resource."; + +describe("ResourceMetadataPanel", () => { + it("renders only the details card when just details are present", () => { + render( + , + ); + + expect(screen.getByText("Details:")).toBeInTheDocument(); + expect(screen.getByText("Some resource details")).toBeInTheDocument(); + expect(screen.queryByTestId("query-code-editor")).not.toBeInTheDocument(); + expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument(); + }); + + it("renders only the metadata editor when just metadata is present", () => { + render( + , + ); + + const editor = screen.getByTestId("query-code-editor"); + expect(editor).toBeInTheDocument(); + // value and copyValue must reference the same serialized string. + const serialized = JSON.stringify( + { VulnerabilityID: "CVE-2026-0001" }, + null, + 2, + ); + expect(editor).toHaveAttribute("data-value", serialized); + expect(editor).toHaveAttribute("data-copy-value", serialized); + expect(screen.queryByText("Details:")).not.toBeInTheDocument(); + expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument(); + }); + + it("renders both the details card and the metadata editor", () => { + render( + , + ); + + expect(screen.getByText("Detected on instance i-123")).toBeInTheDocument(); + expect(screen.getByTestId("query-code-editor")).toBeInTheDocument(); + expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument(); + }); + + it("renders the empty state when neither details nor metadata are present", () => { + render(); + + expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument(); + expect(screen.queryByText("Details:")).not.toBeInTheDocument(); + expect(screen.queryByTestId("query-code-editor")).not.toBeInTheDocument(); + }); + + it("falls back to the empty state for an empty metadata object", () => { + render(); + + expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument(); + }); +}); diff --git a/ui/components/shared/resource-metadata-panel.tsx b/ui/components/shared/resource-metadata-panel.tsx new file mode 100644 index 0000000000..ad7c94858f --- /dev/null +++ b/ui/components/shared/resource-metadata-panel.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Card } from "@/components/shadcn/card/card"; +import { + QUERY_EDITOR_LANGUAGE, + QueryCodeEditor, +} from "@/components/shared/query-code-editor"; +import { parseMetadata } from "@/lib/resource-metadata"; + +interface ResourceMetadataPanelProps { + metadata: Record | string | null | undefined; + details: string | null | undefined; +} + +/** + * Shared "Metadata" panel for a resource. + * + * Renders the resource `details` text and its `metadata` as formatted JSON + * with a copy-to-clipboard action, falling back to an empty state when + * neither is available. Reused by the resource detail view and the finding + * detail drawer (compliance requirement findings view) to keep the UX + * consistent across surfaces. + */ +export function ResourceMetadataPanel({ + metadata, + details, +}: ResourceMetadataPanelProps) { + const parsedMetadata = parseMetadata(metadata); + const hasMetadata = + parsedMetadata !== null && Object.keys(parsedMetadata).length > 0; + const hasDetails = Boolean(details?.trim()); + const formattedMetadata = + hasMetadata && parsedMetadata + ? JSON.stringify(parsedMetadata, null, 2) + : null; + + return ( + <> + {hasDetails && ( + +
+ + Details: + +

+ {details} +

+
+
+ )} + + {formattedMetadata && ( + {}} + /> + )} + + {!hasDetails && !hasMetadata && ( +

+ No metadata available for this resource. +

+ )} + + ); +} diff --git a/ui/lib/resource-metadata.test.ts b/ui/lib/resource-metadata.test.ts new file mode 100644 index 0000000000..c5ab633787 --- /dev/null +++ b/ui/lib/resource-metadata.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { parseMetadata } from "./resource-metadata"; + +describe("parseMetadata", () => { + it("should return null for nullish or empty values", () => { + expect(parseMetadata(null)).toBeNull(); + expect(parseMetadata(undefined)).toBeNull(); + expect(parseMetadata("")).toBeNull(); + }); + + it("should parse a JSON object string into an object", () => { + expect(parseMetadata('{"PkgName":"requests","Versions":["2.0"]}')).toEqual({ + PkgName: "requests", + Versions: ["2.0"], + }); + }); + + it("should return null when the string is not valid JSON", () => { + expect(parseMetadata("not-json")).toBeNull(); + }); + + it("should return null when the JSON string is not an object", () => { + expect(parseMetadata("42")).toBeNull(); + expect(parseMetadata('"plain string"')).toBeNull(); + }); + + it("should return null when the JSON string is an array", () => { + expect(parseMetadata("[1,2,3]")).toBeNull(); + expect(parseMetadata("[]")).toBeNull(); + expect(parseMetadata('[{"PkgName":"requests"}]')).toBeNull(); + }); + + it("should return null when the value is already an array", () => { + expect( + parseMetadata([1, 2, 3] as unknown as Record), + ).toBeNull(); + }); + + it("should return the object as-is when already an object", () => { + const metadata = { VulnerabilityID: "CVE-2026-0001" }; + expect(parseMetadata(metadata)).toBe(metadata); + }); +}); diff --git a/ui/lib/resource-metadata.ts b/ui/lib/resource-metadata.ts new file mode 100644 index 0000000000..57043593ac --- /dev/null +++ b/ui/lib/resource-metadata.ts @@ -0,0 +1,34 @@ +/** + * Normalizes a resource `metadata` value into a plain object. + * + * The API stores resource metadata as a `TextField`, so it can arrive as a + * JSON string, an already-parsed object, or be empty. Returns `null` when the + * value is missing or not a JSON object so callers can render an empty state. + */ +export const parseMetadata = ( + metadata: Record | string | null | undefined, +): Record | null => { + if (!metadata) return null; + + if (typeof metadata === "string") { + try { + const parsed = JSON.parse(metadata); + return typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } + } + + // After the !metadata check above, metadata can only be a non-null object + // here (null was filtered, string was handled). Arrays are excluded too so + // the Record return type stays honest. + if (typeof metadata === "object" && !Array.isArray(metadata)) { + return metadata as Record; + } + + return null; +};