Files
prowler/ui/lib/lighthouse/context/pages.ts
T

452 lines
13 KiB
TypeScript

import {
LIGHTHOUSE_CONTEXT_KIND,
LIGHTHOUSE_CONTEXT_LIMIT,
LIGHTHOUSE_CONTEXT_SOURCE,
LIGHTHOUSE_PAGE_ID,
type LighthouseContextFilters,
type LighthousePageContextItem,
type LighthousePageId,
} from "@/types/lighthouse-context";
export type LighthousePageSuggestions = readonly [
string,
string,
string,
string,
];
export interface LighthousePageDefinition {
id: LighthousePageId;
label: string;
match: (pathname: string) => boolean;
allowedSearchParams: readonly string[];
suggestions: LighthousePageSuggestions;
buildPageContext: (
pathname: string,
searchParams: URLSearchParams,
) => LighthousePageContextItem;
}
interface LighthousePageDefinitionInput {
id: LighthousePageId;
label: string;
match: (pathname: string) => boolean;
allowedSearchParams: readonly string[];
suggestions: LighthousePageSuggestions;
}
const PROVIDER_SCOPE_PARAMS = [
"filter[provider__in]",
"filter[provider_id__in]",
"filter[provider_uid]",
"filter[provider_uid__in]",
"filter[provider_type__in]",
"filter[provider]",
"filter[provider_type]",
"filter[provider_groups__in]",
] as const;
const COMMON_LIST_PARAMS = [
"query",
"search",
"filter[search]",
"sort",
] as const;
const GLOBAL_SUGGESTIONS = [
"Summarize my most critical open findings and what to fix first.",
"What are my highest-impact compliance gaps right now?",
"Find risky attack paths and explain the exposure.",
"How can I improve my cloud security posture today?",
] as const satisfies LighthousePageSuggestions;
const PAGE_DEFINITIONS: readonly LighthousePageDefinition[] = [
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.OVERVIEW,
label: "Overview",
match: (pathname) => pathname === "/",
allowedSearchParams: PROVIDER_SCOPE_PARAMS,
suggestions: [
"What should I prioritize from this overview?",
"Explain my threat score, its trend, and weakest section.",
"Which providers, services, or frameworks carry the most risk?",
"Build a practical security plan for today.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.FINDINGS,
label: "Findings",
match: (pathname) => pathname === "/findings",
allowedSearchParams: [
...PROVIDER_SCOPE_PARAMS,
...COMMON_LIST_PARAMS,
"filter[region__in]",
"filter[service__in]",
"filter[severity__in]",
"filter[status__in]",
"filter[delta]",
"filter[delta__in]",
"filter[resource_type__in]",
"filter[category__in]",
"filter[resource_groups__in]",
"filter[scan]",
"filter[scan__in]",
"filter[scan_id]",
"filter[scan_id__in]",
"filter[inserted_at]",
"filter[muted]",
],
suggestions: [
"Which visible critical findings need attention first?",
"Prioritize remediation for the current findings.",
"Identify likely root causes across these findings.",
"Create a remediation plan for this findings view.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.RESOURCES,
label: "Resources",
match: (pathname) => pathname === "/resources",
allowedSearchParams: [
...PROVIDER_SCOPE_PARAMS,
...COMMON_LIST_PARAMS,
"filter[region__in]",
"filter[service__in]",
"filter[type__in]",
"filter[groups__in]",
],
suggestions: [
"Which visible resources carry the most risk?",
"Find likely exposures among these resources.",
"Identify security patterns across the current resources.",
"Recommend a hardening plan for this resource scope.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.COMPLIANCE_DETAIL,
label: "Compliance detail",
match: (pathname) => pathname.startsWith("/compliance/"),
allowedSearchParams: [
...PROVIDER_SCOPE_PARAMS,
"scanId",
"scan_id",
"complianceId",
"section",
"mode",
"version",
"filter[cis_profile_level]",
"filter[region__in]",
"filter[status__in]",
],
suggestions: [
"Which failed requirements need attention first?",
"Prioritize remediation for this compliance framework.",
"Which sections are weakest and why?",
"Create a plan to improve this framework score.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.COMPLIANCE,
label: "Compliance",
match: (pathname) => pathname === "/compliance",
allowedSearchParams: [
...PROVIDER_SCOPE_PARAMS,
"tab",
"scanId",
"scan_id",
"framework",
"version",
"mode",
"section",
"filter[compliance_id]",
"filter[region__in]",
],
suggestions: [
"Summarize the most important compliance gaps.",
"Which frameworks should I prioritize?",
"Explain the visible compliance score.",
"Which controls need remediation first?",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.ATTACK_PATHS,
label: "Attack Paths",
match: (pathname) => pathname.startsWith("/attack-paths"),
allowedSearchParams: ["scanId", "queryId"],
suggestions: [
"Explain the current attack path.",
"Which nodes are most critical in this graph?",
"Where should I break this attack path first?",
"Recommend remediations for the current attack path.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.SCANS,
label: "Scans",
match: (pathname) => pathname.startsWith("/scans"),
allowedSearchParams: [
...PROVIDER_SCOPE_PARAMS,
...COMMON_LIST_PARAMS,
"tab",
"scanId",
"filter[state]",
"filter[state__in]",
"filter[trigger]",
],
suggestions: [
"Summarize recent scan activity.",
"Which visible scans look problematic?",
"Explain the most important scan failures.",
"What should I investigate next from this scans view?",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.PROVIDERS,
label: "Providers",
match: (pathname) => pathname === "/providers",
allowedSearchParams: [
...PROVIDER_SCOPE_PARAMS,
...COMMON_LIST_PARAMS,
"tab",
"filter[status]",
"filter[connected]",
],
suggestions: [
"Which visible providers need attention?",
"Assess security coverage across these providers.",
"Which providers may have stale scans?",
"What should I improve in provider onboarding?",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.ALERTS,
label: "Alerts",
match: (pathname) => pathname === "/alerts",
allowedSearchParams: [
"filter[search]",
"sort",
"filter[enabled]",
"filter[trigger]",
],
suggestions: [
"Which alert rules should I enable or tune?",
"Summarize my alerting coverage and its gaps.",
"Could any alert rule be too noisy or too broad?",
"Design an alerting strategy for critical findings.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.SERVICES,
label: "Services",
match: (pathname) => pathname === "/services",
allowedSearchParams: [...PROVIDER_SCOPE_PARAMS, ...COMMON_LIST_PARAMS],
suggestions: [
"Which services carry the most failing findings?",
"Compare risk across my cloud services.",
"Which services should I harden first?",
"Summarize service coverage across providers.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.WORKLOADS,
label: "Workloads",
match: (pathname) => pathname === "/workloads",
allowedSearchParams: [...PROVIDER_SCOPE_PARAMS],
suggestions: [
"Which workloads look most exposed?",
"How should I group workloads for triage?",
"Which workloads need attention first?",
"Build a workload hardening plan.",
],
}),
createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.MUTELIST,
label: "Mute list",
match: (pathname) => pathname === "/mutelist",
allowedSearchParams: [...COMMON_LIST_PARAMS, "tab"],
suggestions: [
"Which mute rules are active and why?",
"Could a mute rule be hiding important findings?",
"Review my mute rules for risky exclusions.",
"How should I structure mute rules safely?",
],
}),
];
const KNOWN_ROUTE_LABELS = {
integrations: "Integrations",
} as const;
function normalizeLighthousePath(pathname: string): string {
const normalized = `/${pathname
.split("?")[0]
.split("/")
.filter(Boolean)
.map(decodePathSegment)
.join("/")}`;
return normalized === "/"
? normalized
: normalized.slice(0, LIGHTHOUSE_CONTEXT_LIMIT.STRING_LENGTH);
}
export function resolveLighthousePage(
pathname: string,
): LighthousePageDefinition {
const normalizedPath = normalizeLighthousePath(pathname);
return (
PAGE_DEFINITIONS.find((definition) => definition.match(normalizedPath)) ??
createFallbackDefinition(normalizedPath)
);
}
export function buildLighthousePageContext(
pathname: string,
searchParams: URLSearchParams,
): LighthousePageContextItem {
const normalizedPath = normalizeLighthousePath(pathname);
return resolveLighthousePage(normalizedPath).buildPageContext(
normalizedPath,
searchParams,
);
}
export function getLighthouseScopeKey(pathname: string): string {
const normalizedPath = normalizeLighthousePath(pathname);
const page = resolveLighthousePage(normalizedPath);
return buildLighthouseScopeKey(page.id, normalizedPath);
}
function createPageDefinition(
input: LighthousePageDefinitionInput,
): LighthousePageDefinition {
return {
...input,
buildPageContext: (pathname, searchParams) => {
const filters = buildFilters(searchParams, input.allowedSearchParams);
return {
kind: LIGHTHOUSE_CONTEXT_KIND.PAGE,
id: input.id,
source: LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC,
scopeKey: buildLighthouseScopeKey(input.id, pathname),
label: input.label,
path: pathname,
...(Object.keys(filters).length > 0 ? { filters } : {}),
};
},
};
}
function buildLighthouseScopeKey(
pageId: LighthousePageId,
pathname: string,
): string {
const prefix = `${pageId}:`;
return `${prefix}${pathname.slice(
0,
LIGHTHOUSE_CONTEXT_LIMIT.STRING_LENGTH - prefix.length,
)}`;
}
function createFallbackDefinition(pathname: string): LighthousePageDefinition {
const segment = pathname.split("/").filter(Boolean)[0] ?? "overview";
const label =
KNOWN_ROUTE_LABELS[segment as keyof typeof KNOWN_ROUTE_LABELS] ??
toTitleCase(segment);
return createPageDefinition({
id: LIGHTHOUSE_PAGE_ID.OTHER,
label,
match: () => true,
allowedSearchParams: [],
suggestions: GLOBAL_SUGGESTIONS,
});
}
function buildFilters(
searchParams: URLSearchParams,
allowedSearchParams: readonly string[],
): LighthouseContextFilters {
const filters: LighthouseContextFilters = {};
let remainingValues: number = LIGHTHOUSE_CONTEXT_LIMIT.FILTER_VALUES;
for (const param of [...allowedSearchParams].sort()) {
if (remainingValues === 0) break;
const values = searchParams
.getAll(param)
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(
(value) =>
value.length > 0 && !containsSensitiveLighthouseContextValue(value),
)
.map((value) => value.slice(0, LIGHTHOUSE_CONTEXT_LIMIT.STRING_LENGTH))
.slice(0, remainingValues);
if (values.length === 0) continue;
const key = toContextFilterKey(param);
filters[key] = [...(filters[key] ?? []), ...values];
remainingValues -= values.length;
}
return Object.fromEntries(
Object.entries(filters).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
);
}
function toContextFilterKey(param: string): string {
if (!param.startsWith("filter[")) return param === "query" ? "search" : param;
return param.slice(7, -1).replace(/__in$/, "");
}
export function containsSensitiveLighthouseContextValue(
value: string,
): boolean {
return (
/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/.test(value) ||
/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(value) ||
containsIpv6Address(value) ||
/\bAKIA[A-Z0-9]{16}\b/.test(value) ||
/\bbearer\s+\S+/i.test(value) ||
/\b(?:password|secret|token|credential)\s*[:=]/i.test(value)
);
}
function containsIpv6Address(value: string): boolean {
return value
.split(/[^0-9A-Fa-f:]+/)
.some((candidate) => isIpv6AddressCandidate(candidate));
}
function isIpv6AddressCandidate(candidate: string): boolean {
if (!candidate.includes(":") || candidate.includes(":::")) return false;
const compressedParts = candidate.split("::");
if (compressedParts.length > 2) return false;
const segments = candidate.split(":").filter(Boolean);
if (segments.some((segment) => segment.length > 4)) return false;
return compressedParts.length === 2
? segments.length < 8
: segments.length === 8;
}
function toTitleCase(value: string): string {
if (!value) return "Current page";
return value
.split("-")
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join(" ")
.slice(0, LIGHTHOUSE_CONTEXT_LIMIT.STRING_LENGTH);
}
function decodePathSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}