fix(ui): harden Lighthouse context compiler against invalid items and budget pressure (#12286)

This commit is contained in:
Alejandro Bailo
2026-08-04 13:41:20 +02:00
committed by GitHub
parent ab13d111c2
commit f9dbb0eee9
4 changed files with 238 additions and 17 deletions
+40 -8
View File
@@ -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(
+176 -5
View File
@@ -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();
});
});