Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/web/src/lib/findingPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
119 changes: 119 additions & 0 deletions apps/web/src/lib/findingPresentation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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;
}
36 changes: 15 additions & 21 deletions apps/web/src/screens/Findings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Loading label="Checking bounded findings…" />;

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 <Loading label="Checking bounded findings…" />;

return (
<div className="screen">
Expand All @@ -25,27 +23,23 @@ export default function Findings() {
<Link className="ghost-link" to="/runtime">Open Runtime <Icon name="arrow" size={14} /></Link>
</header>

{!findings ? (
{!liveFindings ? (
<Panel title="Not collected" icon="alert">
<EmptyState icon="alert" title="Live evidence is not established" body="Findings appear only when their model revision matches the current live Docker model." />
</Panel>
) : findings.findings.length === 0 ? (
) : liveFindings.findings.length === 0 ? (
<Panel title="Findings" icon="check" hint="Live evidence">
<EmptyState icon="check" title="No current findings" body="No supported declared-dependency condition is currently detected." />
</Panel>
) : (
<div className="stack">
{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 <Panel key={finding.id} title={title} icon="alert" hint={hint}>
<div className="tag-wrap"><Tag tone={finding.severity === "warning" ? "warn" : "muted"}>{finding.severity === "warning" ? "Warning" : "Advisory"}</Tag><Tag tone="muted">{category}</Tag><Tag tone="muted">{finding.evidenceRefs.length} supporting fact{finding.evidenceRefs.length === 1 ? "" : "s"}</Tag></div>
<p>{finding.summary}</p>
<p className="muted-copy">{finding.recommendation}</p>
<dl className="detail-grid">
<div><dt>Declaring service</dt><dd>{finding.subjectRef}</dd></div>
<div><dt>Target service</dt><dd>{finding.targetRef}</dd></div>
</dl>
{liveFindings.findings.map((finding, index) => {
const presentation = presentationForFinding(finding);
if (!presentation) return null;
return <Panel key={`${finding.ruleId}-${index}`} title={presentation.title} icon="alert" hint={presentation.hint}>
<div className="tag-wrap"><Tag tone={presentation.tone}>{presentation.severityLabel}</Tag><Tag tone="muted">{presentation.category}</Tag></div>
<p className="muted-copy">{presentation.recommendation}</p>
{presentation.inspectChanges && <Link className="ghost-link" to="/changes">Inspect recent changes <Icon name="arrow" size={14} /></Link>}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Link the advisory to evidence that can be inspected

When this Compose card renders, the screen has already restricted it to the live/live context, but changeFeed returns CHANGE_HISTORY_CLAIM for every non-demo context, so /changes is guaranteed to show “Not collected” rather than any relevant history. The new “Inspect recent changes” action is therefore a dead end for every real Compose advisory; point it to current runtime/service evidence or omit it until live change collection is available.

Useful? React with 👍 / 👎.

</Panel>;
})}
</div>
Expand Down
64 changes: 58 additions & 6 deletions apps/web/src/screens/findings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,21 @@ const findings: FindingsResponse = {

function render(value: Partial<AppContextValue>): 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(<AppContext.Provider value={context}><MemoryRouter><Findings /></MemoryRouter></AppContext.Provider>);
}

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");
});

Expand Down Expand Up @@ -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");
});

Expand All @@ -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");
}
});
});
Loading