diff --git a/apps/web/src/lib/findingPresentation.ts b/apps/web/src/lib/findingPresentation.ts index 663fe955..bf320a9c 100644 --- a/apps/web/src/lib/findingPresentation.ts +++ b/apps/web/src/lib/findingPresentation.ts @@ -9,6 +9,7 @@ import type { Finding } from "@dockermap/contracts"; */ export interface FindingPresentation { title: string; + summary: string; category: string; hint: string; recommendation: string; @@ -60,6 +61,14 @@ const SPECS: readonly FindingSpec[] = [ 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.daemon_state_bind_mount_publishes_port", severity: "warning", + summary: "A container with Docker daemon state access also has a published host port.", + recommendation: "Review whether the daemon-state access and host-port publication are both intended.", + idPrefix: "finding_docker_daemon_state_bind_mount_publishes_port_", subjectPrefix: "docker_container_", targetRef: "host_risk_docker_daemon_state", + evidenceCount: 2, evidence: { version: 1, provider: "docker", kind: "docker_daemon_state_bind_mount", assertionKind: "observed", providerSlot: null }, + title: "Docker daemon-state and host-port publication need review", category: "Docker daemon state + host port", hint: "Observed Docker facts", tone: "warn", severityLabel: "Warning", inspectChanges: true + }, { ruleId: "docker.compose_declared_target_not_active", severity: "advisory", summary: "A running Docker Compose service declares a dependency whose container is not active.", @@ -100,7 +109,7 @@ export function presentationForFinding(value: unknown): FindingPresentation | nu // 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" + || ((spec.ruleId === "docker.compose_declared_target_not_active" || spec.ruleId === "docker.daemon_state_bind_mount_publishes_port") ? evidence.providerSlot !== undefined && evidence.providerSlot !== null : evidence.providerSlot !== spec.evidence.providerSlot) || evidence.freshness !== "fresh" @@ -115,5 +124,18 @@ export function presentationForFinding(value: unknown): FindingPresentation | nu || port.subjectRef !== finding.subjectRef) return null; } + // The daemon-state + host-port advisory is a paired observation from one + // Docker collection. The UI never renders either fact's values, but it must + // not present the static warning if the opaque pair is incomplete, crossed, + // stale, or from different observations. Version-one Docker evidence may + // omit its legacy null slot at the browser boundary. + if (spec.ruleId === "docker.daemon_state_bind_mount_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 !== undefined && port.providerSlot !== null) + || port.freshness !== "fresh" || port.subjectRef !== finding.subjectRef + || port.collectedAt !== evidence.collectedAt || port.providerRevision !== evidence.providerRevision) return null; + } + return spec; } diff --git a/apps/web/src/screens/Findings.tsx b/apps/web/src/screens/Findings.tsx index 2a324e62..6b865f60 100644 --- a/apps/web/src/screens/Findings.tsx +++ b/apps/web/src/screens/Findings.tsx @@ -38,6 +38,7 @@ export default function Findings() { if (!presentation) return null; return
{presentation.severityLabel}{presentation.category}
+

{presentation.summary}

{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 dcdb2815..256ca210 100644 --- a/apps/web/src/screens/findings.test.tsx +++ b/apps/web/src/screens/findings.test.tsx @@ -130,14 +130,86 @@ describe("Findings screen", () => { expect(html).toContain("Review the declared dependency and the target container state."); }); + it("renders daemon-state host-port advice with static generic copy and a changes link", () => { + const daemonStatePort = structuredClone(findings); + daemonStatePort.findings[0] = { + id: "finding_docker_daemon_state_bind_mount_publishes_port_docker_container_redacted", + ruleId: "docker.daemon_state_bind_mount_publishes_port", + severity: "warning", + summary: "A container with Docker daemon state access also has a published host port.", + recommendation: "Review whether the daemon-state access and host-port publication are both intended.", + subjectRef: "docker_container_redacted", targetRef: "host_risk_docker_daemon_state", + evidenceRefs: [ + { version: 1, id: "mount-path-secret", provider: "docker", kind: "docker_daemon_state_bind_mount", assertionKind: "observed", summary: "provider-text-secret", subjectRef: "docker_container_redacted", collectedAt: 1, providerRevision: "opaque-pair", providerSlot: null, freshness: "fresh" }, + { version: 1, id: "host-port-secret", provider: "docker", kind: "docker_port_publication", assertionKind: "observed", summary: "provider-text-secret", subjectRef: "docker_container_redacted", collectedAt: 1, providerRevision: "opaque-pair", providerSlot: null, freshness: "fresh" } + ] + }; + const html = render({ findings: daemonStatePort }); + expect(html).toContain("Docker daemon-state and host-port publication need review"); + expect(html).toContain("A container with Docker daemon state access also has a published host port."); + expect(html).toContain("Review whether the daemon-state access and host-port publication are both intended."); + expect(html).toContain('href="/changes"'); + for (const hidden of ["docker_container_redacted", "mount-path-secret", "host-port-secret", "provider-text-secret", "opaque-pair"]) expect(html).not.toContain(hidden); + }); + + it("accepts the legacy omitted V1 Docker provider slots for a coherent daemon-state host-port pair", () => { + const daemonStatePort = structuredClone(findings); + daemonStatePort.findings[0] = { + id: "finding_docker_daemon_state_bind_mount_publishes_port_docker_container_legacy", + ruleId: "docker.daemon_state_bind_mount_publishes_port", severity: "warning", + summary: "A container with Docker daemon state access also has a published host port.", + recommendation: "Review whether the daemon-state access and host-port publication are both intended.", + subjectRef: "docker_container_legacy", targetRef: "host_risk_docker_daemon_state", + evidenceRefs: [ + { version: 1, id: "opaque-mount", provider: "docker", kind: "docker_daemon_state_bind_mount", assertionKind: "observed", summary: "opaque", subjectRef: "docker_container_legacy", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" }, + { version: 1, id: "opaque-port", provider: "docker", kind: "docker_port_publication", assertionKind: "observed", summary: "opaque", subjectRef: "docker_container_legacy", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" } + ] + }; + for (const evidence of daemonStatePort.findings[0].evidenceRefs) delete (evidence as { providerSlot?: unknown }).providerSlot; + const html = render({ findings: daemonStatePort }); + expect(html).toContain("Docker daemon-state and host-port publication need review"); + }); + + it("fails closed for malformed or mismatched daemon-state host-port pairs", () => { + const daemonStatePort = structuredClone(findings); + daemonStatePort.findings[0] = { + id: "finding_docker_daemon_state_bind_mount_publishes_port_docker_container_pair", + ruleId: "docker.daemon_state_bind_mount_publishes_port", severity: "warning", + summary: "A container with Docker daemon state access also has a published host port.", + recommendation: "Review whether the daemon-state access and host-port publication are both intended.", + subjectRef: "docker_container_pair", targetRef: "host_risk_docker_daemon_state", + evidenceRefs: [ + { version: 1, id: "opaque-mount", provider: "docker", kind: "docker_daemon_state_bind_mount", assertionKind: "observed", summary: "opaque", subjectRef: "docker_container_pair", collectedAt: 1, providerRevision: "first", providerSlot: null, freshness: "fresh" }, + { version: 1, id: "opaque-port", provider: "docker", kind: "docker_port_publication", assertionKind: "observed", summary: "opaque", subjectRef: "docker_container_other", collectedAt: 2, providerRevision: "second", providerSlot: null, freshness: "fresh" } + ] + }; + const html = render({ findings: daemonStatePort }); + expect(html).not.toContain("Docker daemon-state and host-port publication need review"); + expect(html).not.toContain("docker_container_pair"); + expect(html).not.toContain("docker_container_other"); + }); + it("suppresses findings in demo and mock contexts even if fixture data is injected", () => { + const daemonStatePort = structuredClone(findings); + daemonStatePort.findings[0] = { + id: "finding_docker_daemon_state_bind_mount_publishes_port_docker_container_fixture", + ruleId: "docker.daemon_state_bind_mount_publishes_port", severity: "warning", + summary: "A container with Docker daemon state access also has a published host port.", + recommendation: "Review whether the daemon-state access and host-port publication are both intended.", + subjectRef: "docker_container_fixture", targetRef: "host_risk_docker_daemon_state", + evidenceRefs: [ + { version: 1, id: "opaque-mount", provider: "docker", kind: "docker_daemon_state_bind_mount", assertionKind: "observed", summary: "opaque", subjectRef: "docker_container_fixture", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" }, + { version: 1, id: "opaque-port", provider: "docker", kind: "docker_port_publication", assertionKind: "observed", summary: "opaque", subjectRef: "docker_container_fixture", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" } + ] + }; 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 }); + const html = render({ findings: daemonStatePort, ...context }); expect(html).toContain("Live evidence is not established"); - expect(html).not.toContain("Declared dependency needs review"); + expect(html).not.toContain("Docker daemon-state and host-port publication need review"); + expect(html).not.toContain("docker_container_fixture"); } }); });