Files
prowler/ui/actions/finding-groups/finding-groups.adapter.ts
T
Alan Buscaglia 587187419f feat(ui): add findings triage (#11704)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-07-01 17:55:33 +02:00

233 lines
7.4 KiB
TypeScript

import { adaptFindingTriageSummariesResponse } from "@/actions/findings/findings-triage.adapter";
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
import type {
FindingGroupRow,
FindingResourceRow,
FindingStatus,
ProviderType,
Severity,
} from "@/types";
import { FINDINGS_ROW_TYPE } from "@/types";
/**
* API response shape for a finding group (JSON:API).
* Each group represents a unique check_id with aggregated counts.
*
* Fields come from FindingGroupSerializer which aggregates
* FindingGroupDailySummary rows by check_id.
*/
interface FindingGroupAttributes {
check_id: string;
check_title: string | null;
check_description: string | null;
severity: string;
status: string; // "FAIL" | "PASS" | "MANUAL" (already uppercase)
muted?: boolean;
impacted_providers: string[];
resources_total: number;
resources_fail: number;
pass_count: number;
fail_count: number;
manual_count?: number;
pass_muted_count?: number;
fail_muted_count?: number;
manual_muted_count?: number;
muted_count: number;
new_count: number;
changed_count: number;
new_fail_count?: number;
new_fail_muted_count?: number;
new_pass_count?: number;
new_pass_muted_count?: number;
new_manual_count?: number;
new_manual_muted_count?: number;
changed_fail_count?: number;
changed_fail_muted_count?: number;
changed_pass_count?: number;
changed_pass_muted_count?: number;
changed_manual_count?: number;
changed_manual_muted_count?: number;
first_seen_at: string | null;
last_seen_at: string | null;
failing_since: string | null;
}
interface FindingGroupApiItem {
type: "finding-groups";
id: string;
attributes: FindingGroupAttributes;
}
/**
* Transforms the API response for finding groups into FindingGroupRow[].
*/
export function adaptFindingGroupsResponse(
apiResponse: unknown,
): FindingGroupRow[] {
if (
!apiResponse ||
typeof apiResponse !== "object" ||
!("data" in apiResponse) ||
!Array.isArray((apiResponse as { data: unknown }).data)
) {
return [];
}
const data = (apiResponse as { data: FindingGroupApiItem[] }).data;
return data.map((item) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.GROUP,
checkId: item.attributes.check_id,
checkTitle: item.attributes.check_title || item.attributes.check_id,
severity: item.attributes.severity as Severity,
status: item.attributes.status as FindingStatus,
muted:
item.attributes.muted ??
(item.attributes.muted_count > 0 &&
(item.attributes.muted_count === item.attributes.resources_fail ||
item.attributes.muted_count === item.attributes.resources_total)),
resourcesTotal: item.attributes.resources_total,
resourcesFail: item.attributes.resources_fail,
passCount: item.attributes.pass_count,
failCount: item.attributes.fail_count,
manualCount: item.attributes.manual_count ?? 0,
passMutedCount: item.attributes.pass_muted_count ?? 0,
failMutedCount: item.attributes.fail_muted_count ?? 0,
manualMutedCount: item.attributes.manual_muted_count ?? 0,
newCount: item.attributes.new_count,
changedCount: item.attributes.changed_count,
newFailCount: item.attributes.new_fail_count ?? 0,
newFailMutedCount: item.attributes.new_fail_muted_count ?? 0,
newPassCount: item.attributes.new_pass_count ?? 0,
newPassMutedCount: item.attributes.new_pass_muted_count ?? 0,
newManualCount: item.attributes.new_manual_count ?? 0,
newManualMutedCount: item.attributes.new_manual_muted_count ?? 0,
changedFailCount: item.attributes.changed_fail_count ?? 0,
changedFailMutedCount: item.attributes.changed_fail_muted_count ?? 0,
changedPassCount: item.attributes.changed_pass_count ?? 0,
changedPassMutedCount: item.attributes.changed_pass_muted_count ?? 0,
changedManualCount: item.attributes.changed_manual_count ?? 0,
changedManualMutedCount: item.attributes.changed_manual_muted_count ?? 0,
mutedCount: item.attributes.muted_count,
providers: (item.attributes.impacted_providers || []) as ProviderType[],
updatedAt: item.attributes.last_seen_at || "",
}));
}
/**
* API response shape for a finding group resource (drill-down).
* Endpoint: /finding-groups/{check_id}/resources
*
* Each item has nested `resource` and `provider` objects in attributes
* (NOT JSON:API included — it's a custom serializer).
*/
interface ResourceInfo {
uid: string;
name: string;
service: string;
region: string;
type: string;
resource_group: string;
}
interface ProviderInfo {
type: string;
uid: string;
alias: string;
}
interface FindingGroupResourceAttributes {
finding_id: string;
resource: ResourceInfo;
provider: ProviderInfo;
status: string;
status_extended?: string;
muted?: boolean;
delta?: string | null;
severity: string;
first_seen_at: string | null;
last_seen_at: string | null;
muted_reason?: string | null;
finding_uid?: string;
triage_status?: string;
triage_has_note?: boolean;
}
interface FindingGroupResourceApiItem {
type: "finding-group-resources";
id: string;
attributes: FindingGroupResourceAttributes;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object";
const isFindingGroupResourceApiItem = (
value: unknown,
): value is FindingGroupResourceApiItem => {
if (!isRecord(value) || typeof value.id !== "string") {
return false;
}
const attributes = value.attributes;
return (
isRecord(attributes) &&
typeof attributes.finding_id === "string" &&
isRecord(attributes.resource) &&
isRecord(attributes.provider)
);
};
/**
* Transforms the API response for finding group resources (drill-down)
* into FindingResourceRow[].
*/
export function adaptFindingGroupResourcesResponse(
apiResponse: unknown,
checkId: string,
): FindingResourceRow[] {
if (
!apiResponse ||
typeof apiResponse !== "object" ||
!("data" in apiResponse) ||
!Array.isArray((apiResponse as { data: unknown }).data)
) {
return [];
}
const data = (apiResponse as { data: unknown[] }).data.filter(
isFindingGroupResourceApiItem,
);
const triageSummaries = adaptFindingTriageSummariesResponse(
{ ...apiResponse, data },
getFindingTriageAdapterOptions(),
);
return data.map((item, index) => ({
id: item.id,
rowType: FINDINGS_ROW_TYPE.RESOURCE,
findingId: item.attributes.finding_id || item.id,
checkId,
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
providerAlias: item.attributes.provider?.alias || "",
providerUid: item.attributes.provider?.uid || "",
resourceName: item.attributes.resource?.name || "-",
resourceType: item.attributes.resource?.type || "-",
resourceGroup: item.attributes.resource?.resource_group || "-",
resourceUid: item.attributes.resource?.uid || "-",
service: item.attributes.resource?.service || "-",
region: item.attributes.resource?.region || "-",
severity: (item.attributes.severity || "informational") as Severity,
status: item.attributes.status,
statusExtended: item.attributes.status_extended,
delta: item.attributes.delta || null,
isMuted: item.attributes.muted ?? item.attributes.status === "MUTED",
mutedReason: item.attributes.muted_reason || undefined,
firstSeenAt: item.attributes.first_seen_at,
lastSeenAt: item.attributes.last_seen_at,
triage: triageSummaries[index],
}));
}