mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix(ui): harden Lighthouse context compiler against invalid items and budget pressure (#12286)
This commit is contained in:
@@ -52,6 +52,18 @@ describe("buildOverviewProviderContextItems", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes repeated provider ids before filling the bounded slots", () => {
|
||||
const items = buildOverviewProviderContextItems({
|
||||
searchParams: {
|
||||
"filter[provider_id__in]": "prov-1,prov-1,prov-1,prov-2,prov-3",
|
||||
},
|
||||
providers,
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(["prov-1", "prov-2"]);
|
||||
});
|
||||
|
||||
it("resolves URL-filtered group ids to labeled group items", () => {
|
||||
const items = buildOverviewProviderContextItems({
|
||||
searchParams: { "filter[provider_groups__in]": "group-1,unknown" },
|
||||
|
||||
@@ -10,8 +10,8 @@ import type { ProviderProps } from "@/types/providers";
|
||||
import { parseFilterIds } from "./provider-scope";
|
||||
|
||||
const OVERVIEW_PATHNAME = "/";
|
||||
// Bounded so provider items cannot crowd out the page, ThreatScore, and
|
||||
// posture summaries within the shared context item budget.
|
||||
// Bounded so provider items take a small share of the context item budget;
|
||||
// under byte pressure the compiler additionally evicts provider items first.
|
||||
const MAX_PROVIDER_ITEMS = 2;
|
||||
const MAX_TOTAL_ITEMS = 3;
|
||||
|
||||
@@ -26,8 +26,14 @@ export function buildOverviewProviderContextItems({
|
||||
providers,
|
||||
groups,
|
||||
}: OverviewProviderContextInput): LighthouseProviderContextItem[] {
|
||||
const providerIds = parseFilterIds(searchParams["filter[provider_id__in]"]);
|
||||
const groupIds = parseFilterIds(searchParams["filter[provider_groups__in]"]);
|
||||
// Dedupe before slicing so a repeated id cannot fill the bounded slots and
|
||||
// silently push the remaining selected providers out of the context.
|
||||
const providerIds = Array.from(
|
||||
new Set(parseFilterIds(searchParams["filter[provider_id__in]"])),
|
||||
);
|
||||
const groupIds = Array.from(
|
||||
new Set(parseFilterIds(searchParams["filter[provider_groups__in]"])),
|
||||
);
|
||||
|
||||
const providerItems = providerIds
|
||||
.map((id) => providers.find((provider) => provider.id === id))
|
||||
|
||||
@@ -18,13 +18,29 @@ const LIGHTHOUSE_CONTEXT_MAX_BYTES = 4 * 1024;
|
||||
export function prepareLighthouseContext(
|
||||
value: unknown,
|
||||
): LighthouseContextEnvelope | undefined {
|
||||
const result = lighthouseContextEnvelopeSchema.safeParse(value);
|
||||
if (!result.success) return undefined;
|
||||
// Only the wrapper is checked here; compileLighthouseContext validates each
|
||||
// item so a single malformed one drops alone instead of voiding the send.
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("items" in value) ||
|
||||
!Array.isArray(value.items)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scopeKey = result.data.items[0]?.scopeKey;
|
||||
return scopeKey
|
||||
? compileLighthouseContext(result.data.items, scopeKey)
|
||||
: undefined;
|
||||
const scopeKey = findCandidateScopeKey(value.items);
|
||||
return scopeKey ? compileLighthouseContext(value.items, scopeKey) : undefined;
|
||||
}
|
||||
|
||||
function findCandidateScopeKey(candidates: unknown[]): string | undefined {
|
||||
// Scope comes from the first item that survives validation — a malformed
|
||||
// item carrying a foreign scopeKey must not decide the compiled scope.
|
||||
for (const candidate of candidates) {
|
||||
const result = lighthouseContextItemSchema.safeParse(candidate);
|
||||
if (result.success) return result.data.scopeKey;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function compileLighthouseContext(
|
||||
@@ -36,7 +52,9 @@ export function compileLighthouseContext(
|
||||
for (const candidate of candidates) {
|
||||
if (hasDifferentScope(candidate, scopeKey)) continue;
|
||||
const result = lighthouseContextItemSchema.safeParse(candidate);
|
||||
if (!result.success) return undefined;
|
||||
// A malformed candidate (a null in an optional field, or a kind this
|
||||
// build doesn't know) drops alone instead of voiding the whole envelope.
|
||||
if (!result.success) continue;
|
||||
parsedItems.push(result.data);
|
||||
}
|
||||
|
||||
@@ -63,10 +81,24 @@ function hasDifferentScope(candidate: unknown, scopeKey: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Eviction drops items from the end, so automatic items rank by how much
|
||||
// posture signal they carry: scores and summaries outlive provider labels.
|
||||
const AUTOMATIC_KIND_ORDER: Record<LighthouseContextItem["kind"], number> = {
|
||||
[LIGHTHOUSE_CONTEXT_KIND.PAGE]: 0,
|
||||
[LIGHTHOUSE_CONTEXT_KIND.COMPLIANCE]: 1,
|
||||
[LIGHTHOUSE_CONTEXT_KIND.FINDING]: 2,
|
||||
[LIGHTHOUSE_CONTEXT_KIND.ATTACK_PATH]: 3,
|
||||
[LIGHTHOUSE_CONTEXT_KIND.RESOURCE]: 4,
|
||||
[LIGHTHOUSE_CONTEXT_KIND.SCAN]: 5,
|
||||
[LIGHTHOUSE_CONTEXT_KIND.ALERT]: 6,
|
||||
[LIGHTHOUSE_CONTEXT_KIND.PROVIDER]: 7,
|
||||
};
|
||||
|
||||
function getItemOrder(item: LighthouseContextItem): number {
|
||||
if (item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE) return 0;
|
||||
if (item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED) return 1;
|
||||
return item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC ? 3 : 2;
|
||||
if (item.source !== LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC) return 2;
|
||||
return 3 + AUTOMATIC_KIND_ORDER[item.kind];
|
||||
}
|
||||
|
||||
function buildEnvelopeWithinLimits(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { compileLighthouseContext } from "./compiler";
|
||||
import { compileLighthouseContext, prepareLighthouseContext } from "./compiler";
|
||||
import {
|
||||
buildComplianceContext,
|
||||
buildFilteredProviderContext,
|
||||
@@ -636,8 +636,9 @@ describe("compileLighthouseContext", () => {
|
||||
expect(context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should discard valid items together with an invalid same-scope item", () => {
|
||||
// Given / When
|
||||
it("should drop only the invalid item and keep the valid ones", () => {
|
||||
// Given a valid page plus a finding whose optional field was
|
||||
// normalized to null (e.g. by a backend or storage layer)
|
||||
const context = compileLighthouseContext(
|
||||
[
|
||||
{
|
||||
@@ -653,14 +654,184 @@ describe("compileLighthouseContext", () => {
|
||||
id: "finding-1",
|
||||
source: "selection",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Invalid finding without findingId",
|
||||
label: "Selected finding",
|
||||
findingId: "finding-1",
|
||||
checkId: null,
|
||||
},
|
||||
{
|
||||
kind: "finding",
|
||||
id: "finding-2",
|
||||
source: "selection",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Selected finding",
|
||||
findingId: "finding-2",
|
||||
},
|
||||
],
|
||||
"findings:/findings",
|
||||
);
|
||||
|
||||
// Then the null-carrying item drops alone
|
||||
expect(context?.items.map((item) => item.id)).toEqual([
|
||||
"findings",
|
||||
"finding-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should drop items of unknown kinds without voiding the envelope", () => {
|
||||
// Given an item kind from a newer (or rolled-back) UI build
|
||||
const context = compileLighthouseContext(
|
||||
[
|
||||
{
|
||||
kind: "page",
|
||||
id: "findings",
|
||||
source: "automatic",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Findings",
|
||||
path: "/findings",
|
||||
},
|
||||
{
|
||||
kind: "future-widget",
|
||||
id: "widget-1",
|
||||
source: "automatic",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Unknown widget",
|
||||
},
|
||||
],
|
||||
"findings:/findings",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(context).toBeUndefined();
|
||||
expect(context?.items.map((item) => item.id)).toEqual(["findings"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when automatic items compete for the byte budget", () => {
|
||||
it("should evict provider labels before posture summaries", () => {
|
||||
// Given a page, a ThreatScore summary, and enough oversized provider
|
||||
// labels to exceed the byte budget regardless of arrival order
|
||||
const scopeKey = "overview:/";
|
||||
const page = {
|
||||
kind: "page",
|
||||
id: "overview",
|
||||
source: "automatic",
|
||||
scopeKey,
|
||||
label: "Overview",
|
||||
path: "/",
|
||||
};
|
||||
const threatScore = {
|
||||
kind: "compliance",
|
||||
id: "prowler-threat-score",
|
||||
source: "automatic",
|
||||
scopeKey,
|
||||
label: "Prowler ThreatScore",
|
||||
framework: "Prowler ThreatScore",
|
||||
score: 62.4,
|
||||
};
|
||||
const providers = Array.from({ length: 10 }, (_, index) => ({
|
||||
kind: "provider",
|
||||
id: `provider-${index}`,
|
||||
source: "automatic",
|
||||
scopeKey,
|
||||
label: `Provider ${index} ${"x".repeat(240)}`,
|
||||
providerUid: `uid-${index}-${"y".repeat(240)}`,
|
||||
}));
|
||||
|
||||
// When the providers mount before the ThreatScore summary
|
||||
const context = compileLighthouseContext(
|
||||
[page, ...providers, threatScore],
|
||||
scopeKey,
|
||||
);
|
||||
|
||||
// Then the summary survives and only provider labels are evicted
|
||||
expect(context?.items.map((item) => item.kind)).toContain("compliance");
|
||||
expect(
|
||||
context?.items.filter((item) => item.kind === "provider").length,
|
||||
).toBeLessThan(providers.length);
|
||||
expect(context?.items[1]?.id).toBe("prowler-threat-score");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareLighthouseContext", () => {
|
||||
it("should keep the valid items when one carries a malformed optional", () => {
|
||||
// Given a stored envelope whose finding had checkId normalized to null
|
||||
const context = prepareLighthouseContext({
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{
|
||||
kind: "page",
|
||||
id: "findings",
|
||||
source: "automatic",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Findings",
|
||||
path: "/findings",
|
||||
},
|
||||
{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "selection",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Selected finding",
|
||||
findingId: "finding-1",
|
||||
checkId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Then the malformed item drops alone instead of voiding the send
|
||||
expect(context?.items.map((item) => item.id)).toEqual(["findings"]);
|
||||
});
|
||||
|
||||
it("should scope from the first usable item when the leading one is malformed", () => {
|
||||
// Given a leading item with no scopeKey at all
|
||||
const context = prepareLighthouseContext({
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{ kind: "finding", id: "broken" },
|
||||
{
|
||||
kind: "page",
|
||||
id: "findings",
|
||||
source: "automatic",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Findings",
|
||||
path: "/findings",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Then the later valid page item still compiles
|
||||
expect(context?.items.map((item) => item.id)).toEqual(["findings"]);
|
||||
});
|
||||
|
||||
it("should not let a malformed foreign-scope item decide the scope", () => {
|
||||
// Given a malformed leading item whose scopeKey points at another page
|
||||
const context = prepareLighthouseContext({
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{ kind: "resource", id: "broken", scopeKey: "resources:/resources" },
|
||||
{
|
||||
kind: "page",
|
||||
id: "findings",
|
||||
source: "automatic",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Findings",
|
||||
path: "/findings",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Then the valid page defines the scope and compiles
|
||||
expect(context?.items.map((item) => item.scopeKey)).toEqual([
|
||||
"findings:/findings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return no context for values without an item list", () => {
|
||||
expect(prepareLighthouseContext(undefined)).toBeUndefined();
|
||||
expect(prepareLighthouseContext({ items: "not-a-list" })).toBeUndefined();
|
||||
expect(prepareLighthouseContext({ items: [] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user