import { ATTACK_PATH_QUERY_KIND, type AttackPathGraphData, type AttackPathQueryKind, type GraphEdge, } from "@/types/attack-paths"; import { LIGHTHOUSE_CONTEXT_KIND, LIGHTHOUSE_CONTEXT_LIMIT, LIGHTHOUSE_CONTEXT_SOURCE, type LighthouseAttackPathContextItem, type LighthouseAttackPathParameter, type LighthouseComplianceContextItem, type LighthouseFindingContextItem, type LighthouseProviderContextItem, type LighthouseResourceContextItem, type LighthouseScanContextItem, } from "@/types/lighthouse-context"; import type { LighthouseComplianceContextMode } from "./constants"; import { containsSensitiveLighthouseContextValue, getLighthouseScopeKey, } from "./pages"; const FINDINGS_SCOPE_KEY = getLighthouseScopeKey("/findings"); const RESOURCES_SCOPE_KEY = getLighthouseScopeKey("/resources"); const SCANS_SCOPE_KEY = getLighthouseScopeKey("/scans"); const PROVIDERS_SCOPE_KEY = getLighthouseScopeKey("/providers"); interface FindingGroupContextInput { id: string; checkId: string; checkTitle: string; severity: string; status: string; } interface FindingResourceContextInput { findingId: string; checkId?: string; severity?: string; status?: string; providerUid?: string; resourceUid?: string; region?: string; } interface FocusedFindingContextInput extends FindingResourceContextInput { pathname: string; } interface ResourceContextAttributes { uid: string; service: string; region: string; type: string; failed_findings_count: number; } interface ResourceContextInput { id: string; attributes: ResourceContextAttributes; providerUid?: string; } interface FocusedResourceContextInput extends ResourceContextInput { pathname: string; } interface ComplianceContextInput { pathname: string; id: string; framework: string; version?: string; scanId?: string; providerUid?: string; mode?: LighthouseComplianceContextMode; section?: string; region?: string; score?: number; passed?: number; failed?: number; total?: number; } interface AttackPathSelectedNodeInput { id: string; type?: string; } interface AttackPathContextInput { pathname: string; scanId: string; queryId?: string | null; queryLabel?: string; queryKind?: AttackPathQueryKind; parameters?: Record; graphData?: AttackPathGraphData | null; nodeCount?: number; edgeCount?: number; selectedNode?: AttackPathSelectedNodeInput | null; } interface ScanContextInput { id: string; state?: string; providerUid?: string; } interface ProviderContextInput { id: string; uid?: string; type?: string; } export function buildFindingSummaryContext( total: number, ): LighthouseFindingContextItem { const safeTotal = toSafeCount(total); return { kind: LIGHTHOUSE_CONTEXT_KIND.FINDING, id: "summary", source: LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC, scopeKey: FINDINGS_SCOPE_KEY, label: `${safeTotal} findings`, findingId: "summary", total: safeTotal, }; } export function buildFindingGroupContext( group: FindingGroupContextInput, ): LighthouseFindingContextItem { return { kind: LIGHTHOUSE_CONTEXT_KIND.FINDING, id: toBoundedString(group.id), source: LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, scopeKey: FINDINGS_SCOPE_KEY, label: toBoundedString(group.checkTitle), findingId: toBoundedString(group.id), checkId: toBoundedString(group.checkId), severity: toBoundedString(group.severity), status: toBoundedString(group.status), }; } export function buildFindingResourceContext( finding: FindingResourceContextInput, ): LighthouseFindingContextItem { const safeFindingId = toBoundedString(finding.findingId); return { kind: LIGHTHOUSE_CONTEXT_KIND.FINDING, id: safeFindingId, source: LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, scopeKey: FINDINGS_SCOPE_KEY, label: "Selected finding", findingId: safeFindingId, checkId: optionalBoundedString(finding.checkId), severity: optionalBoundedString(finding.severity), status: optionalBoundedString(finding.status), providerUid: optionalBoundedString(finding.providerUid), resourceUid: optionalBoundedString(finding.resourceUid), region: optionalBoundedString(finding.region), }; } export function buildFocusedFindingContext( finding: FocusedFindingContextInput, ): LighthouseFindingContextItem { const safeFindingId = toBoundedString(finding.findingId); return { kind: LIGHTHOUSE_CONTEXT_KIND.FINDING, id: safeFindingId, source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED, scopeKey: getLighthouseScopeKey(finding.pathname), label: "Focused finding", findingId: safeFindingId, checkId: optionalBoundedString(finding.checkId), severity: optionalBoundedString(finding.severity), status: optionalBoundedString(finding.status), providerUid: optionalBoundedString(finding.providerUid), resourceUid: optionalBoundedString(finding.resourceUid), region: optionalBoundedString(finding.region), }; } export function buildResourceSummaryContext( total: number, ): LighthouseResourceContextItem { const safeTotal = toSafeCount(total); return { kind: LIGHTHOUSE_CONTEXT_KIND.RESOURCE, id: "summary", source: LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC, scopeKey: RESOURCES_SCOPE_KEY, label: `${safeTotal} resources`, resourceId: "summary", total: safeTotal, }; } export function buildResourceContext( resource: ResourceContextInput, ): LighthouseResourceContextItem { return { kind: LIGHTHOUSE_CONTEXT_KIND.RESOURCE, id: toBoundedString(resource.id), source: LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, scopeKey: RESOURCES_SCOPE_KEY, label: "Selected resource", resourceId: toBoundedString(resource.id), resourceUid: toBoundedString(resource.attributes.uid), providerUid: optionalBoundedString(resource.providerUid), service: toBoundedString(resource.attributes.service), region: toBoundedString(resource.attributes.region), resourceType: toBoundedString(resource.attributes.type), failedFindingsCount: toSafeCount(resource.attributes.failed_findings_count), }; } export function buildFocusedResourceContext( resource: FocusedResourceContextInput, ): LighthouseResourceContextItem { return { kind: LIGHTHOUSE_CONTEXT_KIND.RESOURCE, id: toBoundedString(resource.id), source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED, scopeKey: getLighthouseScopeKey(resource.pathname), label: "Focused resource", resourceId: toBoundedString(resource.id), resourceUid: toBoundedString(resource.attributes.uid), providerUid: optionalBoundedString(resource.providerUid), service: toBoundedString(resource.attributes.service), region: toBoundedString(resource.attributes.region), resourceType: toBoundedString(resource.attributes.type), failedFindingsCount: toSafeCount(resource.attributes.failed_findings_count), }; } export function buildComplianceContext( input: ComplianceContextInput, ): LighthouseComplianceContextItem { const total = optionalSafeCount(input.total); const passed = optionalSafeCount(input.passed); const failed = optionalSafeCount(input.failed); const score = input.score !== undefined ? toSafeScore(input.score) : total && passed !== undefined ? toSafeScore((passed / total) * 100) : undefined; const hasTotals = passed !== undefined || failed !== undefined || total !== undefined; return { kind: LIGHTHOUSE_CONTEXT_KIND.COMPLIANCE, id: toBoundedString(input.id), source: LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC, scopeKey: getLighthouseScopeKey(input.pathname), label: toBoundedString(input.framework), framework: toBoundedString(input.framework), version: optionalBoundedString(input.version), scanId: optionalBoundedString(input.scanId), providerUid: optionalBoundedString(input.providerUid), mode: input.mode, section: optionalBoundedString(input.section), region: optionalBoundedString(input.region), score, totals: hasTotals ? { passed, failed, total } : undefined, }; } export function buildAttackPathContext( input: AttackPathContextInput, ): LighthouseAttackPathContextItem { const { parameters, redactedParameters } = sanitizeAttackPathParameters( input.parameters, ); const graphSummary = summarizeAttackPathGraph(input.graphData); return { kind: LIGHTHOUSE_CONTEXT_KIND.ATTACK_PATH, id: input.queryId ? "current-query" : "current-scan", source: LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC, scopeKey: getLighthouseScopeKey(input.pathname), label: toBoundedString(input.queryLabel || "Selected attack-path scan"), scanId: toBoundedString(input.scanId), queryId: optionalBoundedString(input.queryId ?? undefined), ...(input.queryKind ? { queryKind: input.queryKind, canReplayQuery: getCanReplayAttackPathQuery( input.queryKind, redactedParameters, ), } : {}), parameters: Object.keys(parameters).length > 0 ? parameters : undefined, ...(redactedParameters.length > 0 ? { redactedParameters } : {}), nodeCount: graphSummary?.nodeCount ?? optionalSafeCount(input.nodeCount), edgeCount: graphSummary?.edgeCount ?? optionalSafeCount(input.edgeCount), ...(graphSummary ? { connectedComponentCount: graphSummary.connectedComponentCount, nodeTypeCounts: graphSummary.nodeTypeCounts, relationshipTypeCounts: graphSummary.relationshipTypeCounts, } : {}), selectedNodeId: optionalBoundedString(input.selectedNode?.id), selectedNodeType: optionalBoundedString(input.selectedNode?.type), }; } export function buildScanSummaryContext( total: number, state: string, ): LighthouseScanContextItem { const safeTotal = toSafeCount(total); const safeState = toBoundedString(state); return { kind: LIGHTHOUSE_CONTEXT_KIND.SCAN, id: "summary", source: LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC, scopeKey: SCANS_SCOPE_KEY, label: `${safeTotal} ${safeState} scans`, state: safeState, total: safeTotal, }; } export function buildScanContext( input: ScanContextInput, ): LighthouseScanContextItem { return { kind: LIGHTHOUSE_CONTEXT_KIND.SCAN, id: toBoundedString(input.id), source: LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, scopeKey: SCANS_SCOPE_KEY, label: "Selected scan", scanId: toBoundedString(input.id), state: optionalBoundedString(input.state), providerUid: optionalBoundedString(input.providerUid), }; } export function buildProviderSummaryContext( total: number, ): LighthouseProviderContextItem { const safeTotal = toSafeCount(total); return { kind: LIGHTHOUSE_CONTEXT_KIND.PROVIDER, id: "summary", source: LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC, scopeKey: PROVIDERS_SCOPE_KEY, label: `${safeTotal} providers`, total: safeTotal, }; } export function buildProviderContext( input: ProviderContextInput, ): LighthouseProviderContextItem { return { kind: LIGHTHOUSE_CONTEXT_KIND.PROVIDER, id: toBoundedString(input.id), source: LIGHTHOUSE_CONTEXT_SOURCE.SELECTION, scopeKey: PROVIDERS_SCOPE_KEY, label: "Selected provider", providerId: toBoundedString(input.id), providerUid: optionalBoundedString(input.uid), providerType: optionalBoundedString(input.type), }; } function toBoundedString(value: string): string { return value.slice(0, LIGHTHOUSE_CONTEXT_LIMIT.STRING_LENGTH); } function optionalBoundedString(value: string | undefined): string | undefined { return value ? toBoundedString(value) : undefined; } function toSafeCount(value: number): number { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } function optionalSafeCount(value: number | undefined): number | undefined { return value === undefined ? undefined : toSafeCount(value); } function toSafeScore(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(100, Math.max(0, Math.round(value * 100) / 100)); } function sanitizeAttackPathParameters( parameters: AttackPathContextInput["parameters"], ): SanitizedAttackPathParameters { if (!parameters) return { parameters: {}, redactedParameters: [] }; const safeParameters: Record = {}; const redactedParameters: string[] = []; for (const [key, value] of Object.entries(parameters)) { if (value === "") continue; const isRedacted = /password|secret|token|credential|query/i.test(key) || (typeof value === "string" && containsSensitiveLighthouseContextValue(value)); if (isRedacted) { if ( redactedParameters.length < LIGHTHOUSE_CONTEXT_LIMIT.ATTACK_PATH_REDACTED_PARAMETERS ) { redactedParameters.push(toBoundedString(key)); } continue; } if ( Object.keys(safeParameters).length >= LIGHTHOUSE_CONTEXT_LIMIT.ATTACK_PATH_PARAMETERS ) { continue; } safeParameters[toBoundedString(key)] = typeof value === "string" ? toBoundedString(value) : value; } return { parameters: safeParameters, redactedParameters: redactedParameters.sort(), }; } interface SanitizedAttackPathParameters { parameters: Record; redactedParameters: string[]; } interface AttackPathGraphSummary { nodeCount: number; edgeCount: number; connectedComponentCount: number; nodeTypeCounts?: Record; relationshipTypeCounts?: Record; } function getCanReplayAttackPathQuery( queryKind: AttackPathQueryKind | undefined, redactedParameters: string[], ): boolean | undefined { if (!queryKind) return undefined; return ( queryKind === ATTACK_PATH_QUERY_KIND.PREDEFINED && redactedParameters.length === 0 ); } function summarizeAttackPathGraph( graphData: AttackPathGraphData | null | undefined, ): AttackPathGraphSummary | undefined { if (!graphData) return undefined; const edges = graphData.edges ?? graphData.relationships?.map((relationship) => ({ source: relationship.source, target: relationship.target, type: relationship.label, })) ?? []; return { nodeCount: toSafeCount(graphData.nodes.length), edgeCount: toSafeCount(edges.length), connectedComponentCount: countConnectedComponents(graphData, edges), nodeTypeCounts: buildBoundedTypeCounts( graphData.nodes.map((node) => node.labels[0]).filter(Boolean), ), relationshipTypeCounts: buildBoundedTypeCounts( edges.map((edge) => edge.type).filter(Boolean), ), }; } function countConnectedComponents( graphData: AttackPathGraphData, edges: ReadonlyArray>, ): number { const nodeIdList = graphData.nodes.map((node) => node.id); const nodeIds = new Set(nodeIdList); const adjacency = new Map>( nodeIdList.map((nodeId) => [nodeId, new Set()]), ); for (const edge of edges) { if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) continue; adjacency.get(edge.source)?.add(edge.target); adjacency.get(edge.target)?.add(edge.source); } const visited = new Set(); let componentCount = 0; for (const nodeId of nodeIdList) { if (visited.has(nodeId)) continue; componentCount += 1; const pending = [nodeId]; while (pending.length > 0) { const current = pending.pop(); if (!current || visited.has(current)) continue; visited.add(current); adjacency.get(current)?.forEach((neighbor) => { if (!visited.has(neighbor)) pending.push(neighbor); }); } } return componentCount; } function buildBoundedTypeCounts( values: string[], ): Record | undefined { const counts = values.reduce>((result, value) => { const boundedValue = toBoundedString(value); result[boundedValue] = (result[boundedValue] ?? 0) + 1; return result; }, {}); const boundedCounts = Object.fromEntries( Object.entries(counts) .sort( ([leftLabel, leftCount], [rightLabel, rightCount]) => rightCount - leftCount || leftLabel.localeCompare(rightLabel), ) .slice(0, LIGHTHOUSE_CONTEXT_LIMIT.ATTACK_PATH_TYPE_COUNTS), ); return Object.keys(boundedCounts).length > 0 ? boundedCounts : undefined; }