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:
@@ -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