From 6fc32f69d9d0f1ac945018a58e28e32976b3304a Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:03:44 +0800 Subject: [PATCH] feat: surface bounded compose target advisory --- apps/web/src/lib/findingPresentation.test.ts | 27 +++++ apps/web/src/lib/findingPresentation.ts | 119 +++++++++++++++++++ apps/web/src/screens/Findings.tsx | 36 +++--- apps/web/src/screens/findings.test.tsx | 64 +++++++++- 4 files changed, 219 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/lib/findingPresentation.test.ts create mode 100644 apps/web/src/lib/findingPresentation.ts diff --git a/apps/web/src/lib/findingPresentation.test.ts b/apps/web/src/lib/findingPresentation.test.ts new file mode 100644 index 00000000..89940e1c --- /dev/null +++ b/apps/web/src/lib/findingPresentation.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { presentationForFinding } from "./findingPresentation"; + +const composeFinding = { + id: "finding_docker_compose_declared_target_not_active_opaque", + ruleId: "docker.compose_declared_target_not_active", + severity: "advisory", + summary: "A running Docker Compose service declares a dependency whose container is not active.", + recommendation: "Review the declared dependency and the target container state.", + subjectRef: "docker_container_source", targetRef: "docker_container_target", + evidenceRefs: [{ version: 1, provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", providerSlot: null, freshness: "fresh", subjectRef: "docker_container_source" }] +}; + +describe("finding presentation boundary", () => { + it("admits only the static closed Compose advisory shape", () => { + expect(presentationForFinding(composeFinding)).toMatchObject({ + title: "Declared Compose dependency needs review", category: "Docker Compose", inspectChanges: true + }); + }); + + it("fails closed for unrecognized, malformed, or stale Compose-shaped data", () => { + expect(presentationForFinding({ ...composeFinding, ruleId: "server.supplied" })).toBeNull(); + expect(presentationForFinding({ ...composeFinding, summary: "runtime drift detected" })).toBeNull(); + expect(presentationForFinding({ ...composeFinding, evidenceRefs: [{ ...composeFinding.evidenceRefs[0], freshness: "stale" }] })).toBeNull(); + expect(presentationForFinding({ ...composeFinding, subjectRef: "docker_container_same", targetRef: "docker_container_same" })).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/findingPresentation.ts b/apps/web/src/lib/findingPresentation.ts new file mode 100644 index 00000000..663fe955 --- /dev/null +++ b/apps/web/src/lib/findingPresentation.ts @@ -0,0 +1,119 @@ +import type { Finding } from "@dockermap/contracts"; + +/** + * Findings cross the browser boundary as untrusted JSON. The API owns the + * complete collision-resistant identity check; the UI deliberately treats + * that opaque identifier, references, evidence metadata, and provider text as + * non-displayable. This second, small guard only admits the closed shapes the + * screen can describe without turning a finding into a metadata viewer. + */ +export interface FindingPresentation { + title: string; + category: string; + hint: string; + recommendation: string; + tone: "warn" | "muted"; + severityLabel: "Warning" | "Advisory"; + inspectChanges?: boolean; +} + +type FindingSpec = FindingPresentation & { + ruleId: Finding["ruleId"]; + severity: Finding["severity"]; + summary: string; + idPrefix: string; + subjectPrefix: string; + targetPrefix?: string; + targetRef?: string; + evidenceCount: number; + evidence: { + version: number; + provider: string; + kind: string; + assertionKind: string; + providerSlot?: string | null; + }; +}; + +const SPECS: readonly FindingSpec[] = [ + { + ruleId: "systemd.requires_target_not_active", severity: "warning", + summary: "An active systemd service requires a target that is inactive or failed", + recommendation: "Inspect the target service state and its declared dependency configuration.", + idPrefix: "finding_systemd_requires_target_not_active_", subjectPrefix: "systemd_service_", targetPrefix: "systemd_service_", + evidenceCount: 1, evidence: { version: 2, provider: "systemd", kind: "systemd_requires", assertionKind: "declared", providerSlot: "systemd" }, + title: "Declared dependency needs review", category: "Systemd Requires", hint: "Observed declaration", tone: "warn", severityLabel: "Warning" + }, + { + ruleId: "docker.internal_network_member_publishes_port", severity: "advisory", + summary: "A container on an internal Docker network also has a published host port.", + recommendation: "Review whether the host-port publication is intended for this internal-network service.", + idPrefix: "finding_docker_internal_network_member_publishes_port_", subjectPrefix: "docker_container_", targetPrefix: "docker_network_", + evidenceCount: 2, evidence: { version: 1, provider: "docker", kind: "docker_network_membership", assertionKind: "observed", providerSlot: null }, + title: "Internal-network port publication needs review", category: "Internal network + host port", hint: "Observed Docker facts", tone: "muted", severityLabel: "Advisory" + }, + { + ruleId: "docker.daemon_state_bind_mount", severity: "warning", + summary: "A container has Docker daemon state access that may provide Docker daemon API authority.", + recommendation: "Review whether this container requires Docker daemon API authority.", + idPrefix: "finding_docker_daemon_state_bind_mount_", subjectPrefix: "docker_container_", targetRef: "host_risk_docker_daemon_state", + evidenceCount: 1, evidence: { version: 1, provider: "docker", kind: "docker_daemon_state_bind_mount", assertionKind: "observed", providerSlot: null }, + title: "Docker daemon-state access needs review", category: "Docker daemon state", hint: "Observed Docker fact", tone: "warn", severityLabel: "Warning" + }, + { + ruleId: "docker.compose_declared_target_not_active", severity: "advisory", + summary: "A running Docker Compose service declares a dependency whose container is not active.", + recommendation: "Review the declared dependency and the target container state.", + idPrefix: "finding_docker_compose_declared_target_not_active_", subjectPrefix: "docker_container_", targetPrefix: "docker_container_", + evidenceCount: 1, evidence: { version: 1, provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", providerSlot: null }, + title: "Declared Compose dependency needs review", category: "Docker Compose", hint: "Observed Compose declaration", tone: "muted", severityLabel: "Advisory", inspectChanges: true + } +]; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +/** Return static presentation only for a fully bounded supported finding. */ +export function presentationForFinding(value: unknown): FindingPresentation | null { + const finding = record(value); + if (!finding) return null; + const spec = SPECS.find((candidate) => candidate.ruleId === finding.ruleId); + if (!spec + || finding.severity !== spec.severity + || finding.summary !== spec.summary + || finding.recommendation !== spec.recommendation + || typeof finding.id !== "string" || !finding.id.startsWith(spec.idPrefix) + || typeof finding.subjectRef !== "string" || !finding.subjectRef.startsWith(spec.subjectPrefix) + || typeof finding.targetRef !== "string" + || (spec.targetPrefix !== undefined && !finding.targetRef.startsWith(spec.targetPrefix)) + || (spec.targetRef !== undefined && finding.targetRef !== spec.targetRef) + || finding.subjectRef === finding.targetRef + || !Array.isArray(finding.evidenceRefs) || finding.evidenceRefs.length !== spec.evidenceCount) return null; + + const evidence = record(finding.evidenceRefs[0]); + if (!evidence + || evidence.version !== spec.evidence.version + || evidence.provider !== spec.evidence.provider + || evidence.kind !== spec.evidence.kind + || evidence.assertionKind !== spec.evidence.assertionKind + // The API permits the Compose observation's legacy absent slot as well as + // null. Both mean the Docker-wide collector, never a provider-supplied + // slot name; all other supported shapes require their exact slot value. + || (spec.ruleId === "docker.compose_declared_target_not_active" + ? evidence.providerSlot !== undefined && evidence.providerSlot !== null + : evidence.providerSlot !== spec.evidence.providerSlot) + || evidence.freshness !== "fresh" + || evidence.subjectRef !== finding.subjectRef) return null; + + // The two-fact internal-network condition has a fixed complementary port + // fact. No evidence field is rendered, but its shape is still fail-closed. + if (spec.ruleId === "docker.internal_network_member_publishes_port") { + const port = record(finding.evidenceRefs[1]); + if (!port || port.version !== 1 || port.provider !== "docker" || port.kind !== "docker_port_publication" + || port.assertionKind !== "observed" || port.providerSlot !== null || port.freshness !== "fresh" + || port.subjectRef !== finding.subjectRef) return null; + } + + return spec; +} diff --git a/apps/web/src/screens/Findings.tsx b/apps/web/src/screens/Findings.tsx index 3d9a4cb5..2a324e62 100644 --- a/apps/web/src/screens/Findings.tsx +++ b/apps/web/src/screens/Findings.tsx @@ -2,17 +2,15 @@ import { Link } from "react-router-dom"; import { useApp } from "../context"; import Icon from "../components/Icon"; import { EmptyState, Loading, Panel, Tag } from "../components/primitives"; +import { presentationForFinding } from "../lib/findingPresentation"; export default function Findings() { - const { findings, loading } = useApp(); + const { findings, loading, evidenceMode, modelProvenance } = useApp(); + // AppShell already revision-gates findings. Keep the surface defensive too: + // direct demo/mock rendering must never turn fixture data into host advice. + const liveFindings = evidenceMode === "live" && modelProvenance === "live" ? findings : null; - if (loading && !findings) return ; - - const presentationFor = (ruleId: string) => { - if (ruleId === "systemd.requires_target_not_active") return ["Declared dependency needs review", "Observed declaration"] as const; - if (ruleId === "docker.daemon_state_bind_mount") return ["Docker daemon-state access needs review", "Observed Docker fact"] as const; - return ["Internal-network port publication needs review", "Observed Docker facts"] as const; - }; + if (loading && !liveFindings) return ; return (
@@ -25,27 +23,23 @@ export default function Findings() { Open Runtime - {!findings ? ( + {!liveFindings ? ( - ) : findings.findings.length === 0 ? ( + ) : liveFindings.findings.length === 0 ? ( ) : (
- {findings.findings.map((finding) => { - const [title, hint] = presentationFor(finding.ruleId); - const category = finding.ruleId === "systemd.requires_target_not_active" ? "Systemd Requires" : finding.ruleId === "docker.daemon_state_bind_mount" ? "Docker daemon state" : "Internal network + host port"; - return -
{finding.severity === "warning" ? "Warning" : "Advisory"}{category}{finding.evidenceRefs.length} supporting fact{finding.evidenceRefs.length === 1 ? "" : "s"}
-

{finding.summary}

-

{finding.recommendation}

-
-
Declaring service
{finding.subjectRef}
-
Target service
{finding.targetRef}
-
+ {liveFindings.findings.map((finding, index) => { + const presentation = presentationForFinding(finding); + if (!presentation) return null; + return +
{presentation.severityLabel}{presentation.category}
+

{presentation.recommendation}

+ {presentation.inspectChanges && Inspect recent changes }
; })}
diff --git a/apps/web/src/screens/findings.test.tsx b/apps/web/src/screens/findings.test.tsx index 4c6262ae..dcdb2815 100644 --- a/apps/web/src/screens/findings.test.tsx +++ b/apps/web/src/screens/findings.test.tsx @@ -26,19 +26,21 @@ const findings: FindingsResponse = { function render(value: Partial): string { const context: AppContextValue = { - model: null, modelProvenance: null, loading: false, error: null, health: null, - findings: null, tick: 0, evidenceMode: null, openCommand: () => {}, ...value + model: null, loading: false, error: null, health: null, + findings: null, tick: 0, evidenceMode: "live", modelProvenance: "live", openCommand: () => {}, ...value }; return renderToStaticMarkup(); } describe("Findings screen", () => { - it("renders only the bounded declaration conclusion and its static recommendation", () => { + it("renders only the static declaration presentation, not server finding text or references", () => { const html = render({ findings }); expect(html).toContain("Declared dependency needs review"); - expect(html).toContain(findings.findings[0].summary); expect(html).toContain(findings.findings[0].recommendation); expect(html).toContain("Systemd Requires"); + expect(html).not.toContain(findings.findings[0].subjectRef); + expect(html).not.toContain(findings.findings[0].targetRef); + expect(html).not.toContain(findings.findings[0].evidenceRefs[0].id); expect(html).toContain("not health, readiness, traffic, Internet-reachability, or security conclusions"); }); @@ -66,7 +68,7 @@ describe("Findings screen", () => { const html = render({ findings: internalPort }); expect(html).toContain("Internal-network port publication needs review"); expect(html).toContain("Observed Docker facts"); - expect(html).toContain("2 supporting facts"); + expect(html).not.toContain("supporting facts"); expect(html).not.toContain("Internet exposure"); }); @@ -85,7 +87,57 @@ describe("Findings screen", () => { const html = render({ findings: daemonState }); expect(html).toContain("Docker daemon-state access needs review"); expect(html).toContain("Docker daemon state"); - expect(html).toContain("may provide Docker daemon API authority"); + expect(html).toContain("Review whether this container requires Docker daemon API authority."); expect(html).not.toContain("/var/run/docker.sock"); }); + + it("renders the Compose advisory with generic copy and a generic changes inspection link", () => { + const compose = structuredClone(findings); + compose.findings[0] = { + id: "finding_docker_compose_declared_target_not_active_opaque", + ruleId: "docker.compose_declared_target_not_active", + severity: "advisory", + summary: "A running Docker Compose service declares a dependency whose container is not active.", + recommendation: "Review the declared dependency and the target container state.", + subjectRef: "docker_container_source_secret", targetRef: "docker_container_target_secret", + evidenceRefs: [{ version: 1, id: "opaque-evidence", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", summary: "Docker recorded Compose dependency declaration", subjectRef: "docker_container_source_secret", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" }] + }; + const html = render({ findings: compose }); + expect(html).toContain("Declared Compose dependency needs review"); + expect(html).toContain("Docker Compose"); + expect(html).toContain("Review the declared dependency and the target container state."); + expect(html).toContain('href="/changes"'); + expect(html).not.toContain("docker_container_source_secret"); + expect(html).not.toContain("docker_container_target_secret"); + expect(html).not.toContain("opaque-evidence"); + expect(html).toContain("These are not health, readiness, traffic, Internet-reachability, or security conclusions."); + }); + + it("renders the Compose advisory when the V1 Docker evidence omits its null provider slot", () => { + const compose = structuredClone(findings); + compose.findings[0] = { + id: "finding_docker_compose_declared_target_not_active_opaque", + ruleId: "docker.compose_declared_target_not_active", + severity: "advisory", + summary: "A running Docker Compose service declares a dependency whose container is not active.", + recommendation: "Review the declared dependency and the target container state.", + subjectRef: "docker_container_source", targetRef: "docker_container_target", + evidenceRefs: [{ version: 1, id: "opaque-evidence", provider: "docker", kind: "docker_compose_depends_on", assertionKind: "observed", summary: "Docker recorded Compose dependency declaration", subjectRef: "docker_container_source", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" }] + }; + delete (compose.findings[0].evidenceRefs[0] as { providerSlot?: unknown }).providerSlot; + const html = render({ findings: compose }); + expect(html).toContain("Declared Compose dependency needs review"); + expect(html).toContain("Review the declared dependency and the target container state."); + }); + + it("suppresses findings in demo and mock contexts even if fixture data is injected", () => { + for (const context of [ + { evidenceMode: "demo" as const, modelProvenance: "demo" as const }, + { evidenceMode: "mock" as const, modelProvenance: "mock" as const } + ]) { + const html = render({ findings, ...context }); + expect(html).toContain("Live evidence is not established"); + expect(html).not.toContain("Declared dependency needs review"); + } + }); });