diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts new file mode 100644 index 0000000000..3e5335fe42 --- /dev/null +++ b/ui/actions/resources/index.ts @@ -0,0 +1 @@ +export * from "./resources"; diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts new file mode 100644 index 0000000000..eb2a67e6e9 --- /dev/null +++ b/ui/actions/resources/resources.ts @@ -0,0 +1,101 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { + apiBaseUrl, + getAuthHeaders, + getErrorMessage, + parseStringify, +} from "@/lib"; + +export const getResources = async ({ + page = 1, + query = "", + sort = "", + filters = {}, + pageSize = 10, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) + redirect("resources?include=findings,provider"); + + const url = new URL(`${apiBaseUrl}/resources?include=findings,provider`); + + if (page) url.searchParams.append("page[number]", page.toString()); + + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const resources = await fetch(url.toString(), { + headers, + }); + + const data = await resources.json(); + const parsedData = parseStringify(data); + + revalidatePath("/resources"); + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching resources:", error); + return undefined; + } +}; + +export const getResourceById = async (resourceId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/resources/${resourceId}?fields[resources]=provider,region&include=findings,provider`, + ); + + try { + const resource = await fetch(url.toString(), { + headers, + }); + const data = await resource.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; + +export const getResourceFields = async (fields: string, filters = {}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/resources`); + + url.searchParams.append("fields[resources]", fields); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const resource = await fetch(url.toString(), { + headers, + }); + const data = await resource.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index b3115ea0e9..019fc58d9c 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -16,6 +16,12 @@ export const getScans = async ({ sort = "", filters = {}, pageSize = 10, +}: { + page?: number; + query?: string; + sort?: string; + filters?: Record; + pageSize?: number; }) => { const headers = await getAuthHeaders({ contentType: false }); @@ -28,18 +34,14 @@ export const getScans = async ({ if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); - // Handle multiple filters + // Add dynamic filters (e.g., "filter[state]", "fields[scans]") Object.entries(filters).forEach(([key, value]) => { - if (key !== "filter[search]") { - url.searchParams.append(key, String(value)); - } + url.searchParams.append(key, String(value)); }); try { - const scans = await fetch(url.toString(), { - headers, - }); - const data = await scans.json(); + const response = await fetch(url.toString(), { headers }); + const data = await response.json(); const parsedData = parseStringify(data); revalidatePath("/scans"); return parsedData; diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx new file mode 100644 index 0000000000..bda5ba9a13 --- /dev/null +++ b/ui/app/(prowler)/resources/page.tsx @@ -0,0 +1,218 @@ +import { Spacer } from "@nextui-org/react"; +import { format, parseISO } from "date-fns"; +import { Suspense } from "react"; + +import { getResourceFields, getResources } from "@/actions/resources"; +import { getScans } from "@/actions/scans"; +import { FilterControls } from "@/components/filters"; +import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources"; +import { ColumnResources } from "@/components/resources/table/column-resources"; +import { ContentLayout } from "@/components/ui"; +import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { + createDict, + extractFiltersAndQuery, + extractSortAndKey, + hasDateOrScanFilter, + replaceFilterFieldKey, +} from "@/lib"; +import { ResourceProps, SearchParamsProps } from "@/types"; + +export default async function Resources({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) { + const searchParamsKey = JSON.stringify(searchParams || {}); + + // Check if the searchParams contain any date or filter + const hasDateOrScan = hasDateOrScanFilter(searchParams); + + const { filters } = extractFiltersAndQuery(searchParams); + filters["page[size]"] = "100"; // TODO: Remove page[size] 100 when metadata endpoint implemented + + if (!hasDateOrScan) { + const scansData = await getScans({ + filters: { + "fields[scans]": "inserted_at", + }, + }); + + if (scansData?.data?.length !== 0) { + const latestScandate = scansData.data?.[0]?.attributes?.inserted_at; + if (latestScandate) { + const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); + filters["filter[updated_at]"] = formattedDate; + } + } + } + + const outputFilters = replaceFilterFieldKey( + filters, + "inserted_at", + "updated_at", + ); + + // Resource call for filters + const resourcesData = await getResourceFields( + "name,type,region,service", + outputFilters, + ); + + let resourceNameList: string[] = []; + let typeList: string[] = []; + let regionList: string[] = []; + let serviceList: string[] = []; + + if (resourcesData?.data) { + resourceNameList = Array.from( + new Set( + resourcesData.data.map((item: ResourceProps) => item.attributes.name) || + [], + ), + ); + + typeList = Array.from( + new Set( + resourcesData.data.map((item: ResourceProps) => item.attributes.type) || + [], + ), + ); + + regionList = Array.from( + new Set( + resourcesData.data.map( + (item: ResourceProps) => item.attributes.region, + ) || [], + ), + ); + + serviceList = Array.from( + new Set( + resourcesData.data.map( + (item: ResourceProps) => item.attributes.service, + ) || [], + ), + ); + } + + return ( + + + + + + }> + + + + ); +} + +const SSRDataTable = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const defaultSort = "name"; + const { encodedSort } = extractSortAndKey({ + ...searchParams, + sort: searchParams.sort ?? defaultSort, + }); + + // Check if the searchParams contain any date or filter + const hasDateOrScan = hasDateOrScanFilter(searchParams); + + const { filters, query } = extractFiltersAndQuery(searchParams); + + if (!hasDateOrScan) { + // Fetch scans data latest date + const scansData = await getScans({ + filters: { + "fields[scans]": "inserted_at", + }, + }); + + if (scansData?.data?.length !== 0) { + const latestScandate = scansData?.data?.[0]?.attributes?.inserted_at; + const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd"); + filters["filter[updated_at]"] = formattedDate; + } + } + + const outputFilters = replaceFilterFieldKey( + filters, + "inserted_at", + "updated_at", + ); + const resourcesData = await getResources({ + query, + page, + filters: outputFilters, + sort: encodedSort, + pageSize, + }); + + const findingsDict = createDict("findings", resourcesData); + const providerDict = createDict("providers", resourcesData); + + // Expand each resources with its corresponding findings and provider + const expandedResources = resourcesData?.data + ? resourcesData.data.map((resource: ResourceProps) => { + const findings = { + meta: resource.relationships.findings.meta, + data: resource.relationships.findings.data?.map( + (finding) => findingsDict[finding.id], + ), + }; + + const provider = { + data: providerDict[resource.relationships.provider.data.id], + }; + + return { + ...resource, + relationships: { findings, provider }, + }; + }) + : []; + + const expandedResponse = { + ...resourcesData, + data: expandedResources, + }; + + return ( + + ); +}; diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index bcf97dd47a..383695c7e3 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -147,7 +147,13 @@ const SSRDataTableScans = async ({ const query = (filters["filter[search]"] as string) || ""; // Fetch scans data - const scansData = await getScans({ query, page, sort, filters, pageSize }); + const scansData = await getScans({ + query, + page, + sort, + filters: filters as Record, + pageSize, + }); // Handle expanded scans data const expandedScansData = await Promise.all( diff --git a/ui/components/resources/skeleton/skeleton-finding-summary.tsx b/ui/components/resources/skeleton/skeleton-finding-summary.tsx new file mode 100644 index 0000000000..f3bc958c79 --- /dev/null +++ b/ui/components/resources/skeleton/skeleton-finding-summary.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +export const SkeletonFindingSummary = () => { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/ui/components/resources/skeleton/skeleton-table-resources.tsx b/ui/components/resources/skeleton/skeleton-table-resources.tsx new file mode 100644 index 0000000000..92db74fce9 --- /dev/null +++ b/ui/components/resources/skeleton/skeleton-table-resources.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableResources = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx new file mode 100644 index 0000000000..9c8a5c6927 --- /dev/null +++ b/ui/components/resources/table/column-resources.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { useSearchParams } from "next/navigation"; + +import { InfoIcon } from "@/components/icons"; +import { EntityInfoShort } from "@/components/ui/entities"; +import { TriggerSheet } from "@/components/ui/sheet"; +import { DataTableColumnHeader } from "@/components/ui/table"; +import { ResourceProps } from "@/types"; + +import { DataTableRowDetails } from "./data-table-row-details"; + +const getResourceData = ( + row: { original: ResourceProps }, + field: keyof ResourceProps["attributes"], +) => { + return row.original.attributes?.[field] || `No ${field} found in resource`; +}; + +const getChipStyle = (count: number) => { + if (count === 0) return "bg-green-100 text-green-800"; + if (count >= 10) return "bg-red-100 text-red-800"; + if (count >= 1) return "bg-yellow-100 text-yellow-800"; +}; + +const getProviderData = ( + row: { original: ResourceProps }, + field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"], +) => { + return ( + row.original.relationships?.provider?.data?.attributes?.[field] ?? + `No ${field} found in provider` + ); +}; + +const ResourceDetailsCell = ({ row }: { row: any }) => { + const searchParams = useSearchParams(); + const resourceId = searchParams.get("resourceId"); + const isOpen = resourceId === row.original.id; + + return ( +
+ } + title="Resource Details" + description="View the Resource details" + defaultOpen={isOpen} + > + + +
+ ); +}; + +export const ColumnResources: ColumnDef[] = [ + { + id: "moreInfo", + header: "Details", + cell: ({ row }) => , + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const resourceName = getResourceData(row, "name"); + return ( + <> +
+
+

+ {typeof resourceName === "string" + ? resourceName + : "Invalid name"} +

+
+
+ + ); + }, + }, + { + accessorKey: "failedFindings", + header: () =>
Failed Findings
, + cell: ({ row }) => { + const count = row.original.relationships.findings.data.filter( + (data) => + data.attributes.status === "FAIL" && data.attributes.delta === "new", + ).length; + + return ( + <> +

+ + {count} + +

+ + ); + }, + }, + { + accessorKey: "region", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const region = getResourceData(row, "region"); + + return ( +
+ {typeof region === "string" ? region : "Invalid region"} +
+ ); + }, + }, + { + accessorKey: "type", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const type = getResourceData(row, "type"); + + return ( +
+ {typeof type === "string" ? type : "Invalid type"} +
+ ); + }, + }, + { + accessorKey: "service", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const service = getResourceData(row, "service"); + + return ( +
+ {typeof service === "string" ? service : "Invalid region"} +
+ ); + }, + }, + { + accessorKey: "provider", + header: "Cloud Provider", + cell: ({ row }) => { + const provider = getProviderData(row, "provider"); + const alias = getProviderData(row, "alias"); + const uid = getProviderData(row, "uid"); + return ( + <> + + + ); + }, + }, +]; diff --git a/ui/components/resources/table/data-table-row-details.tsx b/ui/components/resources/table/data-table-row-details.tsx new file mode 100644 index 0000000000..44d987045f --- /dev/null +++ b/ui/components/resources/table/data-table-row-details.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getResourceById } from "@/actions/resources"; +import { ResourceApiResponse, ResourceProps } from "@/types"; + +import { ResourceDetail } from "./resource-detail"; + +export const DataTableRowDetails = ({ + resourceId, + resourceData, +}: { + resourceId: string; + resourceData: ResourceProps; +}) => { + const [isLoading, setIsLoading] = useState(true); + const [resourceDetails, setResourceDetails] = + useState(null); + + useEffect(() => { + const fetchScanDetails = async () => { + try { + const result = await getResourceById(resourceId); + setResourceDetails(result); + setIsLoading(false); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error in fetchScanDetails:", error); + } finally { + setIsLoading(false); + } + }; + + fetchScanDetails(); + }, [resourceId]); + + return ( + + ); +}; diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts new file mode 100644 index 0000000000..c8c06fca14 --- /dev/null +++ b/ui/components/resources/table/index.ts @@ -0,0 +1,4 @@ +export * from "../skeleton/skeleton-table-resources"; +export * from "./column-resources"; +export * from "./data-table-row-details"; +export * from "./resource-detail"; diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx new file mode 100644 index 0000000000..ee57e1ffec --- /dev/null +++ b/ui/components/resources/table/resource-detail.tsx @@ -0,0 +1,179 @@ +import { Snippet } from "@nextui-org/react"; +import { format, parseISO } from "date-fns"; +import { InfoIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { + DateWithTime, + EntityInfoShort, + InfoField, +} from "@/components/ui/entities"; +import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; +import { ResourceApiResponse, ResourceProps } from "@/types"; + +import { SkeletonFindingSummary } from "../skeleton/skeleton-finding-summary"; + +const renderValue = (value: string | null | undefined) => { + return value && value.trim() !== "" ? value : "-"; +}; + +const Section = ({ + title, + children, + action, +}: { + title: string; + children: React.ReactNode; + action?: React.ReactNode; +}) => ( +
+
+

+ {title} +

+ {action &&
{action}
} +
+ {children} +
+); + +export const ResourceDetail = ({ + resourceDetails, + resourceData, + isLoading, +}: { + resourceDetails: ResourceApiResponse | null; + resourceData: ResourceProps; + isLoading: boolean; +}) => { + const router = useRouter(); + + const failedFindings = resourceDetails?.included.filter( + (item) => + item.type === "findings" && + item.attributes?.status === "FAIL" && + item.attributes?.delta === "new", + ); + + const linkToFindingsFromResources = ( + uid: string, + inserted_at: string, + resourceId: string, + ) => { + const formattedDate = format(parseISO(inserted_at), "yyyy-MM-dd"); + router.push( + `/findings?filter[uid]=${uid}&filter[inserted_at]=${formattedDate}&id=${resourceId}`, + ); + }; + + return ( +
+ {/* Resource Details section */} +
+
+ + + + {renderValue(resourceData?.attributes.uid)} + + + +
+ +
+
+ +
+ + {renderValue(resourceData.attributes.name)} + + + {renderValue(resourceData.attributes.type)} + +
+
+ + {renderValue(resourceData.attributes.service)} + + + {renderValue(resourceData.attributes.region)} + +
+
+ + + + + + +
+
+ + {/* Finding associated with this resource section */} +

+ Findings associated with this resource +

+ {isLoading ? ( + + ) : failedFindings && failedFindings?.length > 0 ? ( + failedFindings.map((finding, index) => { + const { attributes, id } = finding; + const { severity, uid, inserted_at, check_metadata, status } = + attributes; + + const { checktitle } = check_metadata; + + return ( +
+
+

+ {checktitle} +

+
+ + + + linkToFindingsFromResources(uid, inserted_at, id) + } + /> +
+
+
+ ); + }) + ) : ( +

+ No data found. +

+ )} +
+ ); +}; diff --git a/ui/components/ui/entities/info-field.tsx b/ui/components/ui/entities/info-field.tsx index 3d75bb98ae..600231bda2 100644 --- a/ui/components/ui/entities/info-field.tsx +++ b/ui/components/ui/entities/info-field.tsx @@ -64,7 +64,7 @@ export const InfoField = ({ {variant === "simple" ? ( -
+
{children}
) : variant === "transparent" ? ( diff --git a/ui/components/ui/feedback-banner/feedback-banner.tsx b/ui/components/ui/feedback-banner/feedback-banner.tsx new file mode 100644 index 0000000000..806761e5ed --- /dev/null +++ b/ui/components/ui/feedback-banner/feedback-banner.tsx @@ -0,0 +1,66 @@ +import { AlertIcon } from "@/components/icons/Icons"; +import { cn } from "@/lib/utils"; + +type FeedbackType = "error" | "warning" | "info" | "success"; + +interface FeedbackBannerProps { + type?: FeedbackType; + title: string; + message: string; + className?: string; +} + +const typeStyles: Record< + FeedbackType, + { border: string; bg: string; text: string } +> = { + error: { + border: "border-danger", + bg: "bg-system-error-light/30 dark:bg-system-error-light/80", + text: "text-danger", + }, + warning: { + border: "border-warning", + bg: "bg-yellow-100 dark:bg-yellow-200", + text: "text-yellow-800", + }, + info: { + border: "border-blue-400", + bg: "bg-blue-50 dark:bg-blue-100", + text: "text-blue-800", + }, + success: { + border: "border-green-500", + bg: "bg-green-50 dark:bg-green-100", + text: "text-green-800", + }, +}; + +export const FeedbackBanner: React.FC = ({ + type = "info", + title, + message, + className, +}) => { + const styles = typeStyles[type]; + + return ( +
+
+ + + +

+ {title} {message} +

+
+
+ ); +}; diff --git a/ui/components/ui/index.ts b/ui/components/ui/index.ts index 6fb4555d80..fb81c25dc1 100644 --- a/ui/components/ui/index.ts +++ b/ui/components/ui/index.ts @@ -7,6 +7,7 @@ export * from "./content-layout/content-layout"; export * from "./dialog/dialog"; export * from "./download-icon-button/download-icon-button"; export * from "./dropdown/Dropdown"; +export * from "./feedback-banner/feedback-banner"; export * from "./headers/navigation-header"; export * from "./label/Label"; export * from "./main-layout/main-layout"; diff --git a/ui/lib/helper-filters.ts b/ui/lib/helper-filters.ts index fe17185746..3eeaa84ea3 100644 --- a/ui/lib/helper-filters.ts +++ b/ui/lib/helper-filters.ts @@ -44,3 +44,30 @@ export const extractSortAndKey = (searchParams: Record) => { return { searchParamsKey, rawSort, encodedSort }; }; + +/** + * Replaces a specific field name inside a filter-style key of an object. + * @param obj - The input object with filter-style keys (e.g., { 'filter[inserted_at]': '2025-05-21' }). + * @param oldField - The field name to be replaced (e.g., 'inserted_at'). + * @param newField - The field name to replace with (e.g., 'updated_at'). + * @returns A new object with the updated filter key if a match is found. + */ +export function replaceFilterFieldKey( + obj: Record, + oldField: string, + newField: string, +): Record { + const fieldObj: Record = {}; + + for (const key in obj) { + const match = key.match(/^filter\[(.+)\]$/); + if (match && match[1] === oldField) { + const newKey = `filter[${newField}]`; + fieldObj[newKey] = obj[key]; + } else { + fieldObj[key] = obj[key]; + } + } + + return fieldObj; +} diff --git a/ui/lib/menu-list.ts b/ui/lib/menu-list.ts index 454d320ce6..9b0fb5dbb1 100644 --- a/ui/lib/menu-list.ts +++ b/ui/lib/menu-list.ts @@ -9,6 +9,7 @@ import { Group, LayoutGrid, Mail, + Package, Settings, ShieldCheck, SquareChartGantt, @@ -18,6 +19,7 @@ import { User, UserCog, Users, + Warehouse, } from "lucide-react"; import { @@ -122,7 +124,24 @@ export const getMenuList = (pathname: string): GroupProps[] => { }, ], }, - + { + groupLabel: "Inventory", + menus: [ + { + href: "", + label: "Resources", + icon: Warehouse, + submenus: [ + { + href: "/resources", + label: "Browse all resources", + icon: Package, + }, + ], + defaultOpen: true, + }, + ], + }, { groupLabel: "Settings", menus: [ diff --git a/ui/types/index.ts b/ui/types/index.ts index 1483946f3e..4148f21f46 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -3,4 +3,5 @@ export * from "./components"; export * from "./filters"; export * from "./formSchemas"; export * from "./providers"; +export * from "./resources"; export * from "./scans"; diff --git a/ui/types/resources.ts b/ui/types/resources.ts new file mode 100644 index 0000000000..5db29804a1 --- /dev/null +++ b/ui/types/resources.ts @@ -0,0 +1,142 @@ +export interface ResourceProps { + type: "resources"; + id: string; + attributes: { + inserted_at: string; + updated_at: string; + uid: string; + name: string; + region: string; + service: string; + tags: Record; + type: string; + }; + relationships: { + provider: { + data: { + type: "providers"; + id: string; + attributes: { + inserted_at: string; + updated_at: string; + provider: string; + uid: string; + alias: string | null; + connection: { + connected: boolean; + last_checked_at: string; + }; + }; + relationships: { + secret: { + data: { + type: "provider-secrets"; + id: string; + }; + }; + }; + links: { + self: string; + }; + }; + }; + findings: { + meta: { + count: number; + }; + data: { + type: "findings"; + id: string; + attributes: { status: string; delta: string }; + }[]; + }; + }; + links: { + self: string; + }; +} + +interface ResourceItemProps { + type: "providers" | "findings"; + id: string; + attributes: { + uid: string; + delta: string; + status: "PASS" | "FAIL" | "MANUAL"; + status_extended: string; + severity: "informational" | "low" | "medium" | "high" | "critical"; + check_id: string; + check_metadata: CheckMetadataProps; + raw_result: Record; + inserted_at: string; + updated_at: string; + first_seen_at: string; + muted: boolean; + }; + relationships: { + secret: { + data: { + type: string; + id: string; + }; + }; + scan: { + data: { + type: string; + id: string; + }; + }; + provider_groups: { + meta: { + count: number; + }; + data: []; + }; + }; + links: { + self: string; + }; +} + +interface CheckMetadataProps { + risk: string; + notes: string; + checkid: string; + provider: string; + severity: string; + checktype: string[]; + dependson: string[]; + relatedto: string[]; + categories: string[]; + checktitle: string; + compliance: any; + relatedurl: string; + description: string; + remediation: { + code: { + cli: string; + other: string; + nativeiac: string; + terraform: string; + }; + recommendation: { + url: string; + text: string; + }; + }; + servicename: string; + checkaliases: string[]; + resourcetype: string; + subservicename: string; + resourceidtemplate: string; +} + +interface Meta { + version: string; +} + +export interface ResourceApiResponse { + data: ResourceProps; + included: ResourceItemProps[]; + meta: Meta; +}